Le verrou
Ce que ce chapitre apporte
- Nommer la section critique d'un programme et dire ce qui la caractérise.
- Poser un verrou qui garantit l'exclusion mutuelle, et lire l'attente qu'il crée.
- Délimiter une section critique, et reconnaître les deux fautes de délimitation.
- Protéger un journal écrit par plusieurs capteurs, où le dégât ne porte pas sur un nombre mais sur un fichier.
- Employer le bloc with de Python pour que la libération ait lieu même en cas d'erreur.
Le chapitre sur la condition de course s'est arrêté sur un constat et une exigence. Le constat : deux fils qui retirent chacun une pièce du même stock produisent un résultat faux dans dix-huit entrelacements sur vingt. L'exigence : empêcher le second fil d'entrer dans les trois instructions du premier tant qu'elles ne sont pas terminées. Ce chapitre donne l'outil qui réalise cette exigence, le verrou, et traite la seule question difficile qu'il pose. Poser un verrou est l'affaire d'une ligne ; décider où il commence et où il finit est l'affaire du reste du chapitre.
Ce qu'il faut protéger, et ce qui n'a pas besoin de l'être
Le programme du chapitre précédent, à l'identique, sert de point de départ. Un stock de pièces détachées, deux postes qui prélèvent chacun une pièce, et trois instructions par poste, parce que stock -= 1 en cache trois.
fil A
registre vide- lire stock
- ajouter -1
- écrire stock
fil B
registre vide- lire stock
- ajouter -1
- écrire stock
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Le compte donne vingt entrelacements, dont deux seulement laissent le stock à 8. Les dix-huit autres le laissent à 9 : une pièce sortie de l'armoire sans que le système le sache.
Ce ne sont pas les trois instructions qui posent problème, c'est leur indivisibilité manquante. Entre la lecture et l'écriture, la valeur lue vieillit dans le registre du fil, et rien n'interdit à l'autre fil d'écrire pendant ce temps.
Le chapitre précédent a déjà nommé cette propriété. Elle se rappelle ici parce que tout ce qui suit se joue sur sa délimitation, et non sur le mécanisme qui la protège.
Une section critique est un fragment de programme qui accède à une donnée partagée et qui doit s'exécuter sans être entrecoupé par un autre fil touchant à la même donnée. Elle n'est pas définie par le nombre de lignes, mais par cette propriété : découpée, elle donne un résultat faux.
Tout le reste du programme, calculs locaux, mise en forme d'un message, lecture d'un fichier personnel, ne demande aucune protection. Un fil qui travaille sur ses propres variables ne gêne personne, et ralentir cette partie-là ne rachète rien.
L'exclusion mutuelle
Le verrou est un objet partagé qui n'a que deux états, libre ou tenu, et deux opérations, acquérir et libérer. Un fil qui tente d'acquérir un verrou déjà tenu par un autre est bloqué : il n'exécute plus rien jusqu'à ce que le verrou redevienne libre.
fil A
registre vide- verrouiller caisse
- lire stock
- ajouter -1
- écrire stock
- libérer caisse
fil B
registre vide- verrouiller caisse
- lire stock
- ajouter -1
- écrire stock
- libérer caisse
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Deux choses se voient sur cette figure, et elles se passent de commentaire. Le bouton du second fil refuse le clic dès que le premier détient le verrou, et il dit ce qu'il attend. Le compte des entrelacements ne rend plus qu'un seul résultat, 8, obtenu de deux façons qui ne diffèrent que par l'ordre des deux postes.
L'exclusion mutuelle est la propriété obtenue lorsque, à tout instant, au plus un fil exécute la section critique. Un verrou la réalise : l'acquisition réussit si le verrou est libre et le prend, sinon elle bloque le fil ; la libération le rend disponible au premier fil en attente.
Le nombre d'entrelacements est passé de vingt à deux, et c'est la mesure exacte de ce qu'un verrou fait : il supprime des entrelacements. Ceux qu'il supprime sont précisément ceux qui coupaient la section critique en deux.
Un verrou ne protège pas une donnée, il discipline du code
Voici la faute qui vient en premier, et elle est logique : croire que caisse protège stock, et n'écrire la protection que dans le poste sur lequel le bug a été observé.
fil A
registre vide- verrouiller caisse
- lire stock
- ajouter -1
- écrire stock
- libérer caisse
fil B
registre vide- lire stock
- ajouter -1
- écrire stock
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Le bouton de B accepte le clic alors que A tient le verrou, et le compte remonte à cinquante-six entrelacements, dont quarante-huit laissent le stock à 9. Le verrou n'a rien empêché du tout.
Aucun lien n'existe entre un verrou et la variable qu'il est censé protéger. Le système ne connaît pas l'intention : il applique seulement la règle « un seul fil à la fois entre verrouiller et libérer ». La protection ne vaut donc que si tous les fils qui touchent à la donnée prennent le même verrou, sans exception. Un seul chemin d'accès oublié suffit à tout annuler, et c'est le cas typique du code écrit six mois plus tard par quelqu'un qui ignore la convention.
Trop étroite : le verrou posé autour de la seule écriture
La deuxième faute est plus subtile, et elle résiste à la relecture. L'écriture est l'instruction qui abîme la donnée, donc c'est elle qu'il faut protéger. Le raisonnement est faux, et la figure le montre en un clic.
fil A
registre vide- lire stock
- ajouter -1
- verrouiller caisse
- écrire stock
- libérer caisse
fil B
registre vide- lire stock
- ajouter -1
- verrouiller caisse
- écrire stock
- libérer caisse
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Quarante-deux entrelacements, dont trente-six laissent le stock à 9. Le bug est intact. Les deux fils lisent 10 avant que le premier ne verrouille, et à partir de là ils travaillent tous les deux sur une valeur périmée : les écritures ont beau être sérialisées, elles écrivent la même valeur fausse.
La section critique commence à la première lecture de la donnée partagée, pas à la première écriture. Tout ce qui dépend de la valeur lue en fait partie : le test qui décide s'il reste du stock, le calcul qui en tire la quantité à retirer, et l'écriture finale. Un verrou qui commence après la lecture protège une instruction qui n'en avait pas besoin.
Trop large : le verrou qui englobe une attente
La faute inverse ne produit aucun résultat faux, ce qui la rend invisible aux tests. Elle produit un programme lent. Ici, le premier capteur interroge une sonde lointaine, opération qui dure, et le verrou a été posé autour de tout le traitement.
fil capteur-1
registre vide- verrouiller journal
- afficher « interrogation de la sonde »
- afficher « attente de la réponse »
- afficher « réponse reçue »
- lire releves
- ajouter +1
- écrire releves
- libérer journal
fil capteur-2
registre vide- verrouiller journal
- lire releves
- ajouter +1
- écrire releves
- libérer journal
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Deux entrelacements, et deux seulement : le programme n'a plus aucune liberté. Pendant les trois étapes d'attente du premier capteur, le second ne peut strictement rien faire, alors que son travail ne touche pas encore à la donnée partagée. Deux capteurs mis en parallèle se comportent exactement comme deux capteurs mis à la suite, à ceci près que le programme est plus compliqué.
Un verrou trop large sérialise le programme : la concurrence ne sert plus à rien, et le coût de son écriture reste. Un verrou trop étroit laisse le bug en place. La règle de délimitation tient en une phrase : la section critique commence à la première instruction qui dépend de la donnée partagée, et finit à la dernière qui la modifie, sans rien contenir d'autre. Tout ce qui attend, tout ce qui calcule sur des valeurs locales, sort du verrou.
Plusieurs capteurs, un seul fichier de journal
Le stock donnait un nombre faux. Un journal donne pire : un fichier illisible. Une ligne de journal se compose souvent en plusieurs écritures, l'horodatage et l'identifiant d'abord, la mesure ensuite, et chaque écriture est un point d'interruption possible.
fil capteur-1
registre vide- afficher « 12:00:01 capteur 1 »
- afficher « 21.4 C »
- lire lignes
- ajouter +1
- écrire lignes
fil capteur-2
registre vide- afficher « 12:00:01 capteur 2 »
- afficher « 18.9 C »
- lire lignes
- ajouter +1
- écrire lignes
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Six ordres d'écriture sont possibles, et quatre d'entre eux séparent l'identifiant d'un capteur de sa mesure. Dans un fichier, où ces écritures se suivent sans qu'aucun saut de ligne ne les sépare, la mesure du capteur 2 se retrouve alors accolée à l'identifiant du capteur 1. Le fichier ne comporte alors aucune ligne fausse au sens strict, il comporte des lignes qui n'ont jamais existé. Un analyseur qui relira ce journal attribuera 18.9 °C au capteur 1, et la trace du contrôle qualité sera mensongère pour toujours, car rien dans le fichier ne signale l'incident. Le compteur de lignes, lui, est faux aussi, pour la raison vue plus haut.
fil capteur-1
registre vide- verrouiller journal
- afficher « 12:00:01 capteur 1 »
- afficher « 21.4 C »
- lire lignes
- ajouter +1
- écrire lignes
- libérer journal
fil capteur-2
registre vide- verrouiller journal
- afficher « 12:00:01 capteur 2 »
- afficher « 18.9 C »
- lire lignes
- ajouter +1
- écrire lignes
- libérer journal
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Deux entrelacements, deux journaux possibles, et les deux sont corrects : seul l'ordre des deux capteurs change. C'est exactement ce qu'un journal doit garantir, l'ordre des enregistrements n'ayant aucune importance, leur intégrité en ayant toute une.
Le verrou est ici étroitement délimité, autour de l'écriture de la ligne et du comptage. L'interrogation de la sonde, qui est la partie lente, reste dehors.
Le geste Python : with
Python offre un verrou dans le module threading. Il s'acquiert par acquire, se libère par release, et le programme ci-dessous montre les deux états sans démarrer aucun fil.
Écrire acquire et release à la main est correct tant que rien ne tourne mal entre les deux. Le programme suivant montre ce qui se passe quand quelque chose tourne mal, ce qui, sur une chaîne de mesure, arrive tous les jours.
L'exception est bien rattrapée, le programme continue, et le verrou n'a jamais été libéré. Le premier fil qui le demandera ensuite attendra indéfiniment, pour une erreur survenue ailleurs et déjà oubliée. La figure suivante rejoue exactement cette situation : le fil A ne libère pas.
fil A
registre vide- verrouiller caisse
- lire stock
- ajouter -1
- écrire stock
fil B
registre vide- verrouiller caisse
- lire stock
- ajouter -1
- écrire stock
- libérer caisse
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Un entrelacement sur deux se termine par un interblocage : A a fini, B ne finira jamais, et le stock reste à 9. L'autre entrelacement, celui où B passe en premier, se termine normalement avec un stock à 8, ce qui veut dire que ce bug-là ne se produit qu'une fois sur deux à l'exécution, et qu'un test peut très bien ne jamais le rencontrer.
Le bloc with supprime le problème en rendant la libération automatique à la sortie du bloc, quelle que soit la façon d'en sortir.
Le verrou est libre à l'arrivée, alors que l'exception a traversé le bloc. La sortie par return, par break ou par exception passe toujours par la libération, et c'est la seule écriture à employer.
Appliqué au journal des capteurs, cela donne la forme définitive : la ligne se compose dehors, une seule écriture se fait dedans.
La composition de la ligne, qui est du travail local, reste hors du verrou. La section critique se réduit à un appel, ce qui est la délimitation la plus étroite possible sans réintroduire le bug.
Exercices type
Un poste de contrôle lit le stock, vérifie qu'il reste au moins une pièce, en retire une, puis imprime une étiquette, opération qui prend deux secondes. Où placer l'acquisition et la libération du verrou, et pourquoi l'impression ne doit-elle pas s'y trouver ?
L'acquisition se place avant la lecture du stock, puisque la vérification « reste-t-il au moins une pièce » dépend de la valeur lue et fait donc partie de la section critique. La libération se place juste après l'écriture du stock, avant l'impression.
Mettre l'impression dans la section critique ne produirait aucun résultat faux, et c'est ce qui rend la faute durable : elle ne se voit qu'à la charge. Chaque poste attendrait deux secondes par étiquette imprimée par les autres, et la mise en parallèle des postes n'apporterait plus rien.
Mettre l'acquisition après la vérification produirait au contraire un résultat faux : deux postes pourraient constater ensemble qu'il reste une pièce, et la retirer tous les deux. C'est la faute du verrou trop étroit, avec un stock qui passe à -1.
La règle appliquée ici : ce qui dépend de la donnée partagée est dedans, ce qui est lent et local est dehors.
Dans le journal des capteurs, composer la ligne complète puis l'écrire en un seul appel réduit fortement le risque d'entremêlement, sans verrou. Pourquoi cela ne suffit-il pas ?
Une écriture unique est effectivement bien plus sûre qu'une écriture en deux temps, et elle supprime les quatre journaux entremêlés de la figure. Mais elle ne supprime pas la course, pour deux raisons.
D'abord, « un seul appel » dans le programme ne veut pas dire « une seule opération » pour le système : une écriture assez longue peut être découpée, et l'interruption se produit alors au milieu de la ligne. Ensuite, le compteur de lignes reste une donnée partagée lue puis écrite, avec les trois temps habituels, et il redevient faux même si le fichier, lui, est propre.
La conclusion n'est pas de renoncer à l'écriture unique, qui reste la bonne pratique, mais de ne pas la confondre avec une garantie. Une garantie s'écrit with verrou.
Un poste de contrôle compte séparément les pièces conformes et les rebuts. Faut-il un verrou pour les deux compteurs, ou un verrou par compteur ? Que change ce choix ?
Les deux fonctionnent et donnent des comptes justes. Ce qui change est le nombre de fils qui peuvent travailler en même temps.
Avec un seul verrou pour les deux compteurs, un poste qui enregistre un rebut bloque un poste qui enregistre une pièce conforme, alors qu'ils ne touchent pas à la même donnée. C'est une forme atténuée du verrou trop large.
fil poste-A
registre vide- verrouiller compteur_conformes
- lire conformes
- ajouter +1
- écrire conformes
- libérer compteur_conformes
fil poste-B
registre vide- verrouiller compteur_rebuts
- lire rebuts
- ajouter +1
- écrire rebuts
- libérer compteur_rebuts
Avancer un fil, puis l'autre, dans l'ordre voulu. Un fil peut aussi être avancé plusieurs fois de suite : c'est ce que fait l'ordonnanceur quand il ne l'interrompt pas.
Deux cent cinquante-deux entrelacements, aucune attente, et un seul résultat possible pour chaque compteur : les deux postes ne se croisent jamais parce qu'ils ne partagent rien. C'est la granularité du verrouillage, et elle se décide sur une question simple : quelles instructions touchent réellement à la même donnée ?
La contrepartie arrive au chapitre suivant. Dès qu'un poste a besoin des deux verrous à la fois, par exemple pour déplacer une pièce du compte des rebuts vers celui des conformes après une reprise, un autre défaut apparaît, et il est bien plus difficile à voir.
Vérification
1.Qu'est-ce qui définit une section critique ?
2.Un fil demande un verrou déjà tenu par un autre. Que fait-il ?
3.Le fil A prend le verrou avant de modifier le stock, le fil B modifie le stock sans le prendre. Le stock est-il protégé ?
4.Le verrou est posé autour de la seule écriture, la lecture restant dehors. Quel est l'effet ?
5.Quel est le symptôme d'un verrou trop large ?
6.Pourquoi préférer with verrou: à verrou.acquire() suivi de verrou.release() ?
7.Deux capteurs écrivent chacun leur ligne de journal en deux écritures, sans verrou. Qu'obtient-on dans le fichier ?
8.Quelle règle délimite correctement une section critique ?
La méthode
- Repérer les données partagées du programme, celles qu'au moins deux fils touchent, et les écrire noir sur blanc. Tout le reste est local et ne demande rien.
- Pour chaque donnée, lister les fragments qui y accèdent, en lecture comme en écriture, sans en oublier un seul : la protection vaut ce que vaut le fragment le plus mal protégé.
- Délimiter chaque section critique en partant de la première instruction qui dépend de la valeur lue et en s'arrêtant à la dernière qui la modifie.
- Sortir de la section critique tout ce qui attend, une interrogation de sonde, une écriture réseau, une impression, et tout ce qui se calcule sur des valeurs locales.
- Écrire la protection avec
with verrou:, jamais avec unacquireet unreleaseséparés, pour que la libération survive aux exceptions. - Vérifier la délimitation sur une figure : le compte des entrelacements doit rendre un seul résultat, et le second fil doit pouvoir avancer pendant les parties lentes.
Synthèse
- Une section critique est un fragment qui accède à une donnée partagée et qu'un autre fil ne doit pas entrecouper ; elle se reconnaît au résultat faux qu'une interruption produirait.
- Un verrou réalise l'exclusion mutuelle : l'acquisition bloque tant qu'un autre fil le détient, la libération réveille le premier en attente. Son effet mesurable est de supprimer des entrelacements, de vingt à deux sur le programme du stock.
- La protection est une discipline du code, pas une propriété de la donnée : elle ne vaut que si tous les accès prennent le même verrou.
- Une section critique trop étroite laisse le bug en place, une section trop large sérialise le programme sans le rendre faux, et c'est la faute qu'aucun test ne signale.
- Le journal de plusieurs capteurs montre un dégât qui ne se corrige pas après coup : des lignes entremêlées restent plausibles et rendent le fichier mensonger.
- En Python, le bloc
with verrou:garantit la libération à toute sortie, y compris par exception ; unreleasesauté laisse un verrou pris et un fil qui attend pour toujours.
Un verrou par donnée règle le cas d'une donnée à la fois. Le chapitre suivant prend le cas où un fil a besoin de deux verrous en même temps, et montre un programme parfaitement écrit qui s'arrête définitivement.