Une machine tombe en panne au milieu d'un calcul distribué. Comment reprendre sans tout recommencer, et sans reconstruire un état impossible ? On sauvegarde, on rejoue, on évite l'effet domino.
On veut qu'un système survive à des pannes. La brique de base : sauvegarder de temps en temps l'état sur un stockage stable (checkpoint), pour pouvoir reculer après une panne. Piège : reculer chacun à son dernier checkpoint peut donner une photo incohérente (un message reçu mais jamais envoyé), et le rattrapage peut cascader jusqu'au début (effet domino). Deux grandes familles de solutions : le checkpointing (coordonné, ou induit par les communications CIC) qui garantit une coupe cohérente, et le message logging qui journalise les événements non déterministes pour rejouer seulement les processus fautés. Chandy-Lamport calcule une photo cohérente sans figer personne.
01 · Généralités
Avant de se protéger, il faut nommer ce contre quoi. Les fautes vont de la plus simple (une machine s'arrête) à la plus vicieuse (une machine ment).
Le serveur fonctionne correctement, puis s'arrête net et ne fait plus rien. C'est l'hypothèse de travail de tout le chapitre. Détectable par temporisation si le canal est fiable et borné.
Omission : le serveur oublie de répondre, d'émettre ou de recevoir. Timing : il répond hors du délai spécifié. Response : la valeur ou la transition d'état est fausse.
Le composant peut produire n'importe quelle réponse à n'importe quel instant, y compris mentir de façon cohérente ou dire des choses différentes à deux pairs. Le pire cas.
Un système est k-fault tolerant s'il continue de respecter sa spécification malgré la panne de k composants. L'ordre de grandeur de la redondance dépend du modèle : pour des crashs, souvent k+1 répliques suffisent ; pour des fautes byzantines, il en faut 2k+1 (et jusqu'à 3k+1 pour se mettre d'accord). Plus la faute est méchante, plus la redondance coûte.
La reprise relit un état sauvegardé après la panne. Ce support doit donc survivre à la panne visée. Pour tolérer une seule panne, la mémoire volatile d'un autre processus suffit. Pour des pannes transitoires arbitraires, un disque local par hôte. Pour des pannes non transitoires (l'hôte ne revient pas), il faut un médium persistant hors de l'hôte : un système de fichiers répliqué, par exemple.
02 · Le décor
Rappel express du chapitre 2, car toute la reprise en dépend : ce qui compte n'est pas l'heure, mais l'ordre causal des événements.
Deux exécutions sont identiques si l'ordre de réception des messages est le même, quels que soient les temps physiques.
Une coupe découpe, pour chaque processus, un passé (à gauche) et un futur. Elle est cohérente si son passé est clos par causalité : jamais de réception dans le passé dont l'envoi serait dans le futur. C'est exactement un état qui aurait pu exister sans panne.
Orphelin = message reçu (passé) dont l'envoi est dans le futur : rend la coupe incohérente, à bannir. En transit = message envoyé (passé) mais pas encore reçu (futur) : la coupe reste cohérente, mais si le canal est fiable il faut le sauvegarder pour le rejouer. Toute la suite du chapitre consiste à produire des coupes sans orphelin, et à gérer les en-transit.
03 · Principe de la reprise
Un état recouvrable contient assez d'information pour rejouer l'exécution jusqu'à une coupe cohérente de l'exécution originale. Il en existe deux grandes constructions.
Chaque processus stocke son état local (checkpoint) de temps en temps. Après une panne, on redémarre assez de machines et on repart d'une coupe cohérente : presque tout le monde rejoue une nouvelle exécution.
On journalise en plus les événements non déterministes (surtout les réceptions). Après une panne, seul le processus fauté (ou quelques-uns) redémarre et rejoue les messages journalisés jusqu'au point de panne.
Si la livraison n'est pas garantie, un message en transit peut être oublié sans rien violer. Si le protocole est fiable, il faut le stocker (avec le checkpoint de l'émetteur ou du récepteur) et le rejouer. Pour les orphelins, deux écoles : le checkpointing évite les coupes incohérentes (donc pas d'orphelin par construction) ; le message logging évite de ré-émettre le message et rejoue la même exécution.
04 · Non coordonné
Le plus simple : chaque processus prend ses checkpoints sans se concerter. Hypothèse fail-stop. Après une panne, on recalcule la ligne de recovery. Problème : elle peut être très loin en arrière.
Chaque rollback annule des réceptions, qui deviennent orphelines, ce qui force d'autres processus à reculer, et ainsi de suite. Avec un mauvais entrelacement, la cascade ramène jusqu'au tout premier checkpoint : tout le travail est perdu. C'est le défaut rédhibitoire du checkpointing non coordonné, et la raison pour laquelle on va vouloir synchroniser les checkpoints.
05 · Ligne de recovery
Pour trouver la coupe cohérente la plus récente après une panne, on raisonne sur le graphe de dépendances de rollback : un nœud par intervalle entre deux checkpoints, une arête par message.
1) Marquer l'état au point de panne. 2) Marquer par atteignabilité tout nœud atteignable depuis un nœud marqué (les messages orphelins propagent le marquage). 3) La ligne de recovery est l'union, par processus, du dernier checkpoint non marqué. C'est la coupe cohérente la plus récente.
06 · Coordonné
Pour tuer le domino : garantir que les checkpoints forment toujours une coupe cohérente. Un initiateur déclenche le snapshot global, tout le monde suit.
// initiateur Init : checkpoint provisoire ; broadcast <prends un checkpoint> Sur reception de la requete (Pi) : stop execution ; flush tous les canaux ; checkpoint provisoire ; send <ack> a l'initiateur Init recoit tous les acks : broadcast <commit> Sur reception de commit : jeter l'ancien checkpoint // le nouveau devient permanent
Les communications sont bloquées pendant le protocole : latence, tout le monde attend. En échange : coupe toujours cohérente, un seul (ou deux) checkpoint par processus, pas d'effet domino.
Si les canaux sont FIFO : propager la requête de checkpoint avant tout autre message (ou la piggybacker sur le premier message, et checkpointer avant de le traiter). Aucun message ne franchit la coupe à l'envers, sans jamais bloquer.
07 · CIC
Communication Induced Checkpointing : pas un seul message de contrôle. On accroche une information (un index croissant) aux messages applicatifs, et la réception peut forcer un checkpoint.
Deux sortes de checkpoints : locaux (chacun en prend spontanément) et forcés (imposés par un message). Objectif : garantir qu'une coupe cohérente existe toujours, sans jamais se parler explicitement, et sans créer de checkpoint inutile.
Chaque processus a un index courant croissant, piggybacké sur ses messages. À réception d'un message d'index supérieur au sien, on prend un checkpoint forcé avant de traiter le message, puis on rattrape l'index. Une ligne de recovery = tous les checkpoints de même index.
Le checkpoint forcé empêche qu'un message franchisse « à l'envers » la coupe des checkpoints de même index. Donc pas d'orphelin, donc pas d'effet domino, et zéro message de contrôle. C'est le compromis élégant entre le non coordonné (léger mais dangereux) et le coordonné (sûr mais bloquant).
08 · Snapshot cohérent
L'algorithme du chapitre 2 est le cousin de la CIC : canaux FIFO, messages jamais perdus, un marqueur se propage et déclenche l'enregistrement, en parallèle de l'application. Ici on l'applique à un anneau qui fait tourner un jeton.
Premier marqueur reçu sur un canal : enregistrer son état, marquer ce canal vide, rediffuser le marqueur. Messages arrivant ensuite sur les autres canaux entrants (avant leur marqueur) : journalisés comme état de canal. Fin : chaque processus a reçu le marqueur sur tous ses canaux entrants. FIFO est nécessaire pour que le marqueur sépare proprement l'avant et l'après sur chaque canal.
09 · Message logging
L'autre famille. On journalise les événements non déterministes pour rejouer à l'identique. Seuls les processus fautés redémarrent : bien plus scalable, mais il faut une hypothèse forte.
Hypothèse : tous les événements non déterministes peuvent être identifiés et leur déterminant stocké sur stockage stable. Entre deux tels événements, l'exécution est déterministe (donc rejouable). Une exécution = une suite d'événements déterministes (rejoués) et non déterministes (logués puis simulés depuis le log). Plus des checkpoints pour ne pas repartir du début.
Un processus est orphelin s'il dépend d'un événement non déterministe dont le déterminant n'est pas logué (irrécupérable). On note Log(e) les processus stockant e, Stable(e) si son déterminant est sur stockage stable, Depend(e) les processus affectés par e. La condition « toujours sans orphelin » impose que tout déterminant dont dépend un autre processus soit stable.
Loguer le déterminant avant d'exécuter. Jamais d'orphelin, reprise simple et rapide (seul le fauté redémarre), garbage collection simple, monde extérieur facile à gérer. Coût : la pénalité de synchronisation à chaque événement.
Déterminant gardé localement, flushé parfois. Suivre les dépendances causales ; à la reprise, calculer l'état maximum recouvrable (synchrone) ou propager la reprise aux processus liés (asynchrone). Risque de rollbacks exponentiels.
Matériel : NVRAM plutôt que disque. Relâcher l'atomicité : si la perte de messages est tolérée, il suffit de loguer une réception avant d'émettre le suivant, plus besoin d'un log strictement synchrone.
Checkpointing : cible les systèmes petits à moyens, surcoût plutôt faible, mais tout le monde recule ensemble. Message logging : cible la grande échelle (on ne relance que les fautés), surcoût moyen à élevé (journaliser coûte). Comme souvent en tolérance aux fautes, les résultats forts exigent des hypothèses fortes ou beaucoup de redondance ; heureusement, en pratique, la plupart des systèmes sont assez fiables.
10 · Exercices corrigés
Les exercices du homework (rendu du 29/09) et ceux vus en cours, corrigés en détail. Les diagrammes sont redessinés inline : pas besoin de la feuille du prof.
Exécuter le protocole CIC indexé (Briatico) sur un exemple, puis expliquer ce qui se passe à la reprise quand P2 tombe en panne.
Le sujet donne un exemple à 6 processus et 17 messages. On le redessine fidèlement (mêmes checkpoints, mêmes messages, dans le même ordre sur chaque ligne), puis on applique le protocole : l'index est écrit à côté de chaque checkpoint, l'index piggybacké sur chaque message, et les checkpoints forcés ajoutés par le protocole sont en or. Règle : recevoir un index plus grand que le sien force un checkpoint avant de traiter le message.
Tout le monde démarre à l'index 0. Chaque checkpoint local incrémente l'index. On lit chaque ligne de gauche à droite :
P2 tombe en panne. Son dernier checkpoint est d'index 3. La reprise prend la ligne d'index 3 : le checkpoint d'index 3 de chaque processus. P4 n'a jamais atteint l'index 3, il garde son état courant (tout ce qu'il a envoyé porte au plus 2, donc aucun orphelin possible). La ligne est cohérente par construction, sans graphe de dépendances, sans effet domino.
Six messages traversent la ligne vers l'avant : m10 et m14 (vers P2), m11 (vers P0), m15 (vers P3), m16 (vers P1), m17 (vers P5). Ils sont en transit : il faut les rejouer, par exemple depuis un log côté émetteur.
P2 n'a rien envoyé après son dernier checkpoint. Strictement, seul P2 avait besoin de reculer, et seuls m10 et m14 seraient à rejouer. La CIC ne le sait pas : elle garantit une ligne cohérente sans calcul, au prix de faire reculer tout le monde. Trouver la ligne minimale demande l'analyse de dépendances de la section 05.
Écrire les fonctions ReceiveMsg / sendMsg avec index piggybacké, appelant ProcessMessage à la fin.
Chaque processus Pi tient un index local idx, incrémenté à chaque checkpoint (local ou forcé). Tout message emporte l'index courant de son émetteur. À la réception, si l'index reçu est plus grand, on force un checkpoint avant de délivrer le message.
// etat local de Pi idx := 0 // index courant checkpointLocal() : idx := idx + 1 store(etat local, idx) // checkpoint sur stockage stable sendMsg(m, Pj) : send (m, idx) to Pj // on piggyback l'index courant ReceiveMsg(m, i) // i = index piggybacke, recu de Pj : if (i > idx) : idx := i store(etat local, idx) // CHECKPOINT FORCE, avant de traiter m else if (i == idx) : // meme index : rien de force // (i < idx impossible d'apporter une contrainte : cf remarque) ProcessMessage(m) // traitement applicatif normal
Messages en transit (bonus). Pour aussi restaurer les en-transit côté récepteur, on les ajoute au checkpoint forcé au moment où on le prend (receiver-based), et on les traite à la reprise. On le mentionne car le sujet le rend optionnel : ici on choisit de ne pas les gérer pour garder le pseudo-code lisible, l'ajout est direct.
Que faire si l'index reçu dépasse de plus de 1 ? On saute directement à cet index (idx := i) : on peut avoir « raté » des index intermédiaires, ce n'est pas un problème, un seul checkpoint forcé suffit à rejoindre la bonne coupe. Et si l'index reçu est plus petit que le sien ? Le message vient d'un checkpoint plus ancien : on n'a rien à forcer, mais cette information dit que l'émetteur est « en retard », on peut l'utiliser pour la garbage collection (les vieux checkpoints d'index inférieur sont encore utiles à quelqu'un).
Si chaque processus prend aussi des checkpoints locaux réguliers (toutes les 10 min), la CIC multiplie les checkpoints. Comment rester linéaire tout en gardant toujours un checkpoint ?
Le risque : additionner naïvement checkpoints locaux (périodiques) et checkpoints forcés (induits) fait exploser le total. L'idée est de fusionner quand c'est possible, en gardant un invariant simple.
Intuition : on ne veut pas deux checkpoints à quelques instants d'intervalle pour le même index. Un seul suffit à la cohérence, donc on garde le premier venu (local ou forcé) et on ignore la demande redondante.
Le déterminisme par morceaux est-il indispensable aux protocoles CIC ? Et aux autres checkpointing coordonné / non coordonné ?
Non, la PWD n'est pas nécessaire aux protocoles de checkpointing, qu'ils soient non coordonnés, coordonnés ou CIC. Raison : le checkpointing ne rejoue pas l'exécution passée. Il restaure un état sauvegardé (une coupe cohérente) et relance une nouvelle exécution à partir de là. Cette nouvelle exécution a parfaitement le droit de diverger de l'ancienne (autres choix non déterministes, autre ordre de réception) : tant qu'on repart d'un état qui aurait pu exister sans panne, la spécification est respectée. On n'a donc jamais besoin de reproduire à l'identique les événements non déterministes.
À l'inverse, le message logging en a absolument besoin. Son principe est de rejouer l'exécution depuis le dernier checkpoint jusqu'au point de panne, à l'identique. Sans PWD, on ne saurait pas quels événements sont non déterministes ni comment les reproduire : le rejeu produirait une exécution différente, incohérente avec l'état des processus survivants (qui, eux, ont vu l'exécution d'origine). D'où des orphelins. La PWD est la pierre angulaire qui rend le rejeu possible.
Checkpointing = « je restaure et je repars, tant pis si ça diverge » → PWD inutile. Message logging = « je dois rejouer exactement pareil » → PWD indispensable.
On veut un protocole simple sans effet domino : un checkpoint forcé avant/après l'envoi, ou avant/après la réception. Laquelle est utile ?
Exécution du sujet redessinée à l'identique : 4 processus, 3 ou 4 checkpoints locaux chacun, 8 messages (nommés m0 à m7 comme dans le corrigé), P1 et P3 tombent en panne à droite.
Pour chaque stratégie, on ajoute un checkpoint forcé (en or) à côté de chaque envoi ou de chaque réception. Si un checkpoint local est déjà collé à l'événement (aucun autre événement entre les deux), il tient lieu de forcé. Ensuite la ligne de recovery (pointillés verts) : P1 et P3 repartent de leur dernier checkpoint, P0 et P2 de leur état courant, puis on recule tant qu'un message est reçu avant la ligne mais envoyé après (orphelin). C'est exactement le marquage par atteignabilité du graphe de dépendances (section 05).
L'émission reste hors du checkpoint. P1 repart de son forcé d'avant m3, mais m3 est déjà reçu par P0 : orphelin, P0 recule. Ce recul rend m2 orphelin, P1 recule avant m2, puis m4 fait reculer P3 avant m4, et m5 fait reculer P2 jusqu'au début. Cascade m3 → m2 → m4 → m5 : 7 checkpoints perdus, c'est l'effet domino qu'on voulait éviter. Stratégie la moins utile.
L'émission est toujours dans le checkpoint. Un processus qui recule ne peut donc jamais « désenvoyer » un message déjà reçu : aucun orphelin. P1 et P3 repartent de leur dernier checkpoint, personne d'autre ne recule, 0 checkpoint perdu. La ligne est établie immédiatement après la panne. Stratégie la plus utile.
Le corrigé fait aussi repartir P0 et P2 de leur dernier checkpoint. Ce n'est pas nécessaire : aucun message ne relie la ligne de P1/P3 à un événement de P0 ou P2 qui la précède. Ils peuvent garder leur état courant, ce qui renforce la conclusion.
La réception reste hors du checkpoint. P1 et P3 repartent de leur dernier checkpoint local, mais m3 et m7, envoyés après, sont déjà reçus : P0 et P2 reculent, et tombent tout de suite sur le forcé posé juste avant ces réceptions. Recul borné à un cran, 2 checkpoints perdus, pas de cascade. Utile, juste un peu moins efficace que « après l'envoi ».
La réception est toujours capturée : chaque forcé contient une réception dont l'émission peut être annulée par la reprise. P0 recule à cause de m3, P1 à cause de m2, P3 à cause de m4, P2 à cause de m7 : 6 checkpoints perdus, tous les processus reculent. Mieux que « avant l'envoi » (la cascade s'arrête avant l'état initial), mais peu utile.
| Stratégie | Ce que le forcé capture | Checkpoints perdus | Verdict |
|---|---|---|---|
| après l'envoi | l'émission | 0 | la meilleure |
| avant la réception | ni l'un ni l'autre | 2 | utile |
| après la réception | la réception | 6 | peu utile |
| avant l'envoi | rien, émission dehors | 7 (P2 au début) | la pire |
Après l'envoi (meilleure) > avant la réception > après la réception > avant l'envoi (pire). La règle générale : capturer l'émission dans le checkpoint tue les orphelins et rapproche la ligne de recovery du point de panne.
Un unique jeton m tourne entre P et Q, l'état = nombre de réceptions. P a m, état 101, initie juste après avoir envoyé. Quels états globaux possibles ?
L'animation de la section Snapshot déroule ce scénario. Le raisonnement :
Les deux sont des coupes cohérentes : dans les deux cas il y a exactement un exemplaire du jeton (chez Q, ou dans le canal), et la somme des états est cohérente. L'algorithme peut rapporter l'un ou l'autre selon l'entrelacement, jamais un état incohérent.
Construire le graphe de dépendances de rollback, trouver la ligne de recovery, refaire si P3 tombe seul, puis étendre les règles aux messages en transit.
Exécution du cours (figure du survey d'Elnozahy et al.), redessinée : 4 processus, checkpoints ci,x, P0 et P1 tombent en panne. Les messages sont nommés a à k, sauf m1 et m2 que le prof a annotés.
Un nœud par checkpoint, plus un nœud par état volatil (ce qui a été fait depuis le dernier checkpoint). Pour chaque message envoyé dans l'intervalle qui se termine par ci,x et reçu dans l'intervalle qui se termine par cj,y, on trace l'arête ci,x → cj,y. Sens : si on annule l'intervalle qui contient l'envoi, il faut annuler celui qui contient la réception. Par exemple m1, envoyé par P1 après c1,3 et reçu par P0 avant c0,3, donne volatil(P1) → c0,3.
On marque les états volatils de P0 et P1 (perdus), puis tout ce qui est atteignable. m1 marque c0,3, puis m2 (envoyé par P0 dans l'intervalle de c0,3) marque c1,3. Rien d'autre n'est atteint. La ligne = le dernier nœud non marqué de chaque processus : {c0,2, c1,2, état courant de P2, état courant de P3}. P2 et P3 n'ont pas besoin de reculer.
P3 repart de c3,3. Mais g (P3→P2) est parti après, donc P2 recule avant sa réception : c2,2. Alors d (P2→P1) devient orphelin : P1 recule à c1,1. Puis c (P1→P0) fait reculer P0 à c0,1, b (P0→P2) fait reculer P2 à c2,1, et f (P2→P3) ramène P3 à c3,1. Ligne : {c0,1, c1,1, c2,1, c3,1}, presque au début. Une seule panne, pourtant plus de dégâts : P3 est au cœur de l'enchevêtrement de messages. C'est la loterie du non coordonné.
Autre exécution, P1 tombe. Avec la règle habituelle (pas d'orphelin), P1 recule à son 2e checkpoint à cause de h puis e, P0 à son 2e, P2 et P3 gardent leur état : c'est la ligne verte. Mais le message d (P2→P1, en or) est alors en transit : envoyé avant la ligne, reçu après.
Extension des règles : on ajoute l'arête inverse. Si un message est reçu dans un intervalle annulé, on annule aussi l'intervalle qui contient son envoi. Ici d oblige P2 à reculer avant de l'envoyer, donc à son checkpoint initial. Du coup c, f et g (P2→P3) deviennent orphelins et P3 recule aussi à son checkpoint initial. Nouvelle ligne : {2e checkpoint de P0, 2e de P1, initial de P2, initial de P3}. Plus aucun message ne la traverse, mais on perd beaucoup plus. D'où l'autre option : garder la première ligne et journaliser les messages en transit (c'est ce que fait Chandy-Lamport).
Lien entre Z-chemins et dépendances de checkpoints : pourquoi un checkpoint dans un Z-cycle est inutile ?
Un Z-path (zigzag path) enchaîne des intervalles de checkpoints reliés par des messages, en autorisant, sur un même processus, un message reçu avant qu'un autre soit envoyé dans le même intervalle. Un Z-cycle est un Z-path qui revient sur le même checkpoint (par exemple la chaîne m5 m3 m4 du cours).
Lecture du Z-cycle : m5 est envoyé par P2 après c2,2 et reçu par P0 avant c0,2. Dans le même intervalle de P0, m3 part vers P1 (avant la réception de m5 : c'est le « zig-zag », pas une chaîne causale). m3 arrive chez P1, qui envoie ensuite m4, reçu par P2 avant c2,2. On est revenu sur c2,2.
Preuve sur l'exemple : supposons une coupe cohérente qui contient c2,2. m5 est envoyé après c2,2, donc sa réception doit être hors de la coupe : P0 doit y figurer avant m5, donc au plus c0,1. m3 est alors envoyé après la coupe de P0 : P1 doit y figurer avant de recevoir m3, donc au plus c1,1. m4 est envoyé après : P2 doit y figurer avant de recevoir m4, donc avant c2,2. Contradiction. Aucune coupe cohérente ne contient c2,2.
Un checkpoint pris dans un Z-cycle ne sert jamais à la reprise. Intuition : appartenir à un Z-cycle signifie qu'il existe une chaîne causale qui, en partant de ce checkpoint, y revient. Aucune coupe cohérente ne peut donc « passer par » ce checkpoint sans se contredire (il faudrait qu'il soit à la fois dans le passé et le futur d'une même chaîne). Dans le graphe de dépendances de rollback, il est toujours marqué dès qu'un de ses voisins l'est, il n'est donc jamais le « dernier non marqué » d'un processus. Conséquence pratique : les protocoles CIC sont conçus pour ne pas créer de checkpoint dans un Z-cycle (checkpoints forcés bien placés), ce qui garantit qu'aucun checkpoint n'est gaspillé. Ce n'est pas une preuve formelle d'équivalence, mais l'argument informel qui la sous-tend.
11 · Révision active
Le QCM tire des questions au hasard et mélange les options à chaque partie. Les cartes mémo, pour l'auto-test rapide avant l'examen.
Pool de 34 questions. Tire un échantillon au hasard, correction immédiate et explication à chaque réponse.