Aller au contenu principal

Séparer ou partager la mémoire

Ce que ce chapitre apporte

  • Distinguer un fil d'exécution d'un processus par la seule chose qui les sépare, l'espace mémoire.
  • Dire ce que deux fils voient d'une même variable, et ce que deux processus voient de la leur.
  • Choisir entre isoler et partager selon que la tâche calcule ou attend.
  • Citer le verrou global de l'interpréteur Python et énoncer ce qu'il interdit exactement.
  • Nommer le prix de chaque choix : des copies à recoller d'un côté, des données à protéger de l'autre.

Le chapitre sur l'illusion du multitâche a montré des tâches menées de front, chacune sur ses propres données. Celui-ci pose la question qui décide de tout le reste : ces tâches travaillent-elles dans la même mémoire, ou chacune dans la sienne ? Deux fils d'exécution partagent leurs variables et se parlent sans effort. Deux processus n'en partagent aucune et travaillent sur des copies. Ce n'est pas un détail d'implémentation : c'est le choix d'architecture dont dépendent la robustesse du système, le nombre de cœurs réellement occupés, et la totalité des défauts étudiés dans la suite du module.

Une même tâche, deux mémoires possibles

Un atelier veut compter les mesures remontées par ses sondes. Deux tâches font le même travail en parallèle, et le programme veut connaître le total à la fin. La question posée ici est la plus simple possible : les deux tâches écrivent-elles dans la même case mémoire, ou chacune dans la sienne ?

Définition

Un espace mémoire est l'ensemble des cases qu'une exécution peut lire et écrire. Un processus est une exécution munie de son propre espace mémoire, que nul autre ne peut atteindre. Un fil d'exécution est une suite d'instructions qui se déroule à l'intérieur d'un processus : tous les fils d'un même processus partagent le même espace mémoire, donc les mêmes variables.

La première figure montre deux fils du même processus. Une seule case, atteinte par les deux.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéemesures = 0

fil sonde_1

registre vide
  1. lire mesures
  2. ajouter +1
  3. écrire mesures

fil sonde_2

registre vide
  1. lire mesures
  2. ajouter +1
  3. écrire mesures

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.

Mener la sonde 1 jusqu'au bout avant de lancer la sonde 2, et relever le total. Reprendre ensuite en alternant strictement les clics, et relever le total une seconde fois.

Les deux fils atteignent la même case : voilà ce que partager la mémoire signifie, et rien d'autre. En les menant l'un après l'autre, le total vaut deux, ce qui est attendu. En alternant les clics, il vaut un, et une mesure a disparu.

Ce second résultat n'est pas un défaut de la figure. C'est le sujet entier du chapitre suivant, et il serait prématuré de le traiter ici : ce chapitre sert à établir quelles données sont exposées à ce phénomène, ce qui est la question à se poser avant toute autre.

Deux processus, deux copies

Le même travail, mené par deux processus, ne présente aucune case commune. Chacun dispose du sien, et la figure le rend littéral : deux variables distinctes, chacune touchée par un seul fil.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéemesures_1 = 0mesures_2 = 0

fil processus_1

registre vide
  1. lire mesures_1
  2. ajouter +1
  3. écrire mesures_1

fil processus_2

registre vide
  1. lire mesures_2
  2. ajouter +1
  3. écrire mesures_2

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.

Éprouver plusieurs ordres, puis compter les entrelacements. Vingt ordres, un seul état final : aucune case n'est visée deux fois.

Vingt entrelacements, un seul résultat. L'isolement supprime toute la difficulté, et il la remplace par une autre : à la fin, personne ne connaît le total. Les deux résultats existent dans deux mémoires séparées, et il faut les faire revenir puis les additionner.

main.py
Sortie
>_ Prêt à exécuter…

Ce recollement a un coût que le code ci-dessus cache : chaque relevé a dû être sérialisé, transmis par un tuyau entre processus, puis reconstruit. Tant que les résultats sont quatre petits dictionnaires, la dépense est négligeable. Elle cesse de l'être dès qu'un processus renvoie un tableau de plusieurs millions de valeurs à chaque tour.

À retenir

Deux fils se parlent en écrivant dans une variable. Deux processus se parlent en s'envoyant des messages. La première conversation est gratuite et dangereuse, la seconde est sûre et coûteuse.

Communiquer entre fils : immédiat, et fragile

L'avantage du partage se voit mieux sur une consigne que sur un compteur. Un calculateur détermine une nouvelle consigne de cadence, un afficheur la reprend pour la montrer à l'opérateur.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéeconsigne = 0

fil calculateur

registre vide
  1. lire consigne
  2. ajouter +12
  3. écrire consigne
  4. afficher « nouvelle consigne écrite »

fil afficheur

registre vide
  1. afficher « lecture de la consigne »
  2. lire consigne
  3. afficher « consigne affichée »

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.

Mener le calculateur jusqu'à « nouvelle consigne écrite », puis faire lire l'afficheur, et regarder le registre de l'afficheur. Reprendre en faisant lire l'afficheur en premier, et regarder le même registre.

Dans le premier ordre, le registre de l'afficheur vaut douze : la consigne est passée d'un fil à l'autre sans qu'aucun message ait été envoyé, sans copie, sans délai. C'est ce que le partage offre, et c'est considérable.

Dans le second ordre, le registre vaut zéro. L'afficheur a lu une consigne périmée, et rien dans son code ne le lui signale. Le partage transmet les données instantanément, mais il ne transmet aucune information sur le moment où elles sont valides.

Communiquer entre processus : rien ne passe tout seul

La même scène, entre deux processus, ne montre plus une consigne périmée : elle ne montre plus rien du tout.

2 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéeconsigne_atelier = 0consigne_copie = 0

fil calculateur

registre vide
  1. lire consigne_atelier
  2. ajouter +12
  3. écrire consigne_atelier
  4. afficher « nouvelle consigne écrite »

fil afficheur

registre vide
  1. afficher « lecture de la consigne »
  2. lire consigne_copie
  3. afficher « consigne relue »

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.

Essayer tous les ordres voulus, et chercher celui qui amène douze dans le registre de l'afficheur. Constater qu'aucun n'y parvient.

Aucun entrelacement ne fait apparaître douze dans le registre de l'afficheur. Le calculateur a bien écrit sa consigne, mais dans sa propre mémoire, et l'afficheur lit dans la sienne, qui n'a pas bougé. Le transfert n'arrivera que si le programme l'organise explicitement : un tuyau, une file de messages, un fichier, une base de données.

Ce que la figure ne peut pas montrer

Le moteur d'entrelacement n'a qu'une mémoire. L'isolement y est simulé par des variables distinctes, ce qui en rend le comportement exact mais pas la cause. Dans un vrai système, ce ne sont pas deux noms différents qui séparent les processus : c'est le système d'exploitation, qui refuse à chacun l'accès aux pages mémoire de l'autre, et qui arrête le programme fautif s'il essaie.

Quatre simulations lourdes : isoler

Le bureau d'études doit mener quatre simulations d'écoulement, quarante-cinq secondes chacune, sur une machine à quatre cœurs. C'est du calcul pur : aucune de ces tâches n'attend quoi que ce soit.

main.py
Sortie
>_ Prêt à exécuter…

Trois minutes contre quarante-cinq secondes. Ce gain n'existe que si les quatre simulations occupent quatre cœurs réellement distincts, ce qui demande quatre processus. Les quatre tâches n'ont d'ailleurs rien à se dire : chacune part de ses propres paramètres et produit son propre résultat.

4 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéesim_1 = 0sim_2 = 0sim_3 = 0sim_4 = 0

fil processus_1

registre vide
  1. ajouter +45
  2. écrire sim_1

fil processus_2

registre vide
  1. ajouter +45
  2. écrire sim_2

fil processus_3

registre vide
  1. ajouter +45
  2. écrire sim_3

fil processus_4

registre vide
  1. ajouter +45
  2. écrire sim_4

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.

Entrelacer les quatre processus dans n'importe quel ordre, puis compter. Deux mille cinq cent vingt ordres, un seul état final, et aucun registre n'a eu besoin de lire quoi que ce soit.

Chaque processus commence avec un registre vide et n'en sort jamais : il calcule, il écrit dans sa case, il s'arrête. Aucune lecture de la mémoire commune n'apparaît dans la figure, parce qu'il n'y a rien à y lire. C'est la signature d'une tâche qui gagne à être isolée.

Quatre machines à surveiller : partager

Le service maintenance veut l'état de quatre machines sur le réseau. Chaque interrogation met trois secondes, dont presque tout en attente de la réponse.

main.py
Sortie
>_ Prêt à exécuter…

Douze secondes contre trois, et le gain n'a rien demandé à un second cœur : les quatre attentes se recouvrent sur le même processeur. Ici, des processus seraient une dépense sans contrepartie, alors que les fils conviennent exactement. Et comme les quatre sondes partagent la mémoire, chacune dépose son verdict dans le même tableau de résultats, qui est complet dès le dernier retour.

4 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéepresse_1 = 0presse_2 = 0presse_3 = 0presse_4 = 0

fil sonde_1

registre vide
  1. ajouter +1
  2. écrire presse_1

fil sonde_2

registre vide
  1. ajouter +1
  2. écrire presse_2

fil sonde_3

registre vide
  1. ajouter +1
  2. écrire presse_3

fil sonde_4

registre vide
  1. ajouter +1
  2. écrire presse_4

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.

Mener les quatre sondes dans l'ordre voulu, et lire les quatre cases à la fin. Comparer cette figure à celle des quatre simulations : le déroulement est identique, et pourtant une seule des deux se lit sans recollement.

Les deux figures se ressemblent, et c'est la leçon. Le déroulement des instructions ne dit pas si la mémoire est partagée ou séparée : cela ne se voit qu'au moment où le programme principal veut lire les résultats. D'un côté, les quatre cases sont là, immédiatement. De l'autre, elles sont dans quatre mémoires inaccessibles et il faut aller les chercher.

Le verrou global de l'interpréteur

L'interpréteur Python de référence ne laisse qu'un seul fil exécuter du code Python à la fois, par un mécanisme appelé verrou global de l'interpréteur. La conséquence tient en une phrase : en Python, des fils n'accélèrent pas un calcul, quel que soit le nombre de cœurs, mais ils accélèrent bien une attente, parce que le verrou est relâché pendant les entrées-sorties. Pour paralléliser du calcul en Python, il faut des processus.

Ce que chaque choix coûte

Fils d'exécutionProcessus
Mémoirela même pour tousune copie par processus
Communicationimmédiate, par variablepar message, à sérialiser et recoller
Démarragerapide, quelques dizaines de microsecondescoûteux, souvent des millisecondes
Calcul en Pythonun seul cœur occupé, quel que soit le nombre de filsautant de cœurs que de processus
Attente réseau ou disquepleinement recouverterecouverte aussi, mais pour plus cher
Défaillance d'une tâcheemporte tout le processus, donc tous les filsreste confinée au processus fautif
Risque sur les donnéestoute donnée commune peut être corrompueaucune donnée n'est commune

La dernière ligne est la raison d'être de la suite du module. Le partage n'introduit pas un risque parmi d'autres : il introduit le seul risque qui reste invisible en relecture, ne se reproduit pas à volonté, et dépend d'un ordre que personne ne contrôle.

À retenir

La règle de décision tient en deux lignes. Une tâche qui calcule et n'a rien à partager : un processus. Une tâche qui attend et doit remonter ses résultats dans une structure commune : un fil.

Exercices type

Une application de supervision effectue trois traitements : interroger quarante automates sur le réseau, recalculer un indicateur de performance sur deux ans d'historique, et écrire un rapport sur le disque. Pour chacun, dire s'il relève d'un fil ou d'un processus.

Interroger quarante automates : des fils. Chaque interrogation est presque entièrement de l'attente réseau, et quarante attentes se recouvrent sans occuper quarante cœurs. Les fils partagent en plus le tableau des réponses, qui est exactement ce que la suite du traitement veut lire.

Recalculer l'indicateur sur deux ans d'historique : un processus, ou plusieurs si le calcul se découpe. C'est du calcul pur, et en Python le verrou global de l'interpréteur interdit à des fils d'y gagner quoi que ce soit. Le découpage ne vaut la peine que si le recollement des résultats partiels reste petit devant le calcul lui-même.

Écrire le rapport sur le disque : un fil. L'écriture disque est de l'attente, et le rapport doit être composé à partir de données que le reste de l'application détient déjà en mémoire. L'isoler dans un processus obligerait à lui transmettre tout ce contenu.

Dans la figure ci-dessous, trois fils écrivent. Lequel ne peut en aucun cas être gêné par les deux autres, et à quoi cela se voit-il ?
3 fils d'exécutionavancer l'un ou l'autre, dans l'ordre voulu
mémoire partagéejournal = 0rebuts = 0

fil capteur_1

registre vide
  1. lire journal
  2. ajouter +1
  3. écrire journal

fil capteur_2

registre vide
  1. lire journal
  2. ajouter +1
  3. écrire journal

fil qualite

registre vide
  1. lire rebuts
  2. ajouter +1
  3. écrire 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.

Chercher un entrelacement qui fasse varier « rebuts », puis un qui fasse varier « journal ». Terminer les trois fils et comparer les deux dénombrements.

Le fil qualite ne peut pas être gêné, et cela se voit à une seule chose : aucun autre fil ne nomme la variable rebuts. Le critère n'est ni la durée du fil, ni sa position, ni le nombre de ses instructions. C'est la liste des noms qu'il partage avec d'autres.

Le dénombrement le confirme sur les mille six cent quatre-vingts entrelacements : rebuts vaut un dans tous les cas, alors que journal vaut tantôt un, tantôt deux.

Le geste à retenir est celui du relevé : pour chaque variable, la liste des fils qui la nomment. Une variable nommée par un seul fil est hors de danger, définitivement. Une variable nommée par deux fils ou plus est à traiter, et ce relevé se fait sur le texte du programme, sans rien exécuter.

Un collègue propose de remplacer les huit fils d'un collecteur de données par huit processus, « pour la robustesse ». Le collecteur interroge huit capteurs sur un bus série et remplit un tableau commun de mille mesures par seconde. Que faut-il lui répondre ?

L'argument de robustesse est réel : un processus qui tombe n'emporte pas les sept autres, alors qu'un fil qui provoque une erreur fatale emporte tout le processus, donc les huit collecteurs.

Mais le prix est ici disproportionné. Les huit tâches ne calculent pas, elles attendent un bus série : passer aux processus ne libère aucun cœur, puisque aucun cœur n'était saturé. En revanche, les mille mesures par seconde qui alimentaient un tableau commun devront désormais être sérialisées et transmises une par une entre processus, alors qu'elles ne coûtaient rien du tout.

La réponse mesurée consiste à séparer les deux objectifs. La robustesse s'obtient en isolant ce qui peut réellement faire tomber le programme, par exemple le décodage d'une trame malformée, et non en isolant huit tâches d'attente qui n'ont jamais fait tomber personne. Le tableau commun, lui, reste partagé, avec la protection que le chapitre suivant rendra nécessaire.

Vérification

Vérification rapideon peut se reprendre

1.Qu'est-ce qui sépare exactement un fil d'exécution d'un processus ?

2.Deux processus exécutent le même programme et modifient chacun une variable du même nom. Que voit le second des modifications du premier ?

3.En Python, quatre fils qui mènent quatre calculs lourds sur une machine à quatre cœurs : quel gain ?

4.Quatre machines à interroger sur le réseau, trois secondes d'attente chacune : quelle architecture ?

5.Quel est le coût principal de l'isolement en processus ?

6.Comment repère-t-on, sur le texte d'un programme, les données exposées à un défaut de concurrence ?

7.Un fil provoque une erreur fatale. Qu'advient-il des autres fils du même processus ?

La méthode

  1. Lister les données du programme, et pour chacune noter quels fils la lisent et quels fils l'écrivent : ce relevé précède toute décision.
  2. Classer chaque tâche en tâche de calcul ou tâche d'attente, comme au chapitre précédent, puisque c'est ce classement qui décide si plusieurs cœurs serviront à quelque chose.
  3. Choisir des processus pour les tâches de calcul indépendantes, en vérifiant d'abord que le volume de résultats à rapatrier reste petit devant le calcul lui-même.
  4. Choisir des fils pour les tâches d'attente, et pour toutes celles qui doivent alimenter une structure commune sans la recopier.
  5. Isoler en processus ce qui peut faire tomber le programme, et rien de plus : la robustesse s'achète sur le composant fragile, pas sur l'ensemble.
  6. Marquer chaque donnée nommée par plus d'un fil comme à protéger, et la laisser en attente du traitement que le chapitre suivant rend indispensable.

Synthèse

  • Un processus possède son espace mémoire ; un fil d'exécution se déroule à l'intérieur d'un processus et partage la mémoire de tous les autres fils de ce processus.
  • Deux fils communiquent en écrivant dans une variable, immédiatement et sans copie ; deux processus communiquent par messages, qu'il faut sérialiser, transmettre et recoller.
  • Isoler apporte la robustesse et l'usage de plusieurs cœurs, au prix du rapatriement des résultats.
  • Partager apporte la communication gratuite et le recouvrement des attentes, au prix d'un risque sur toute donnée commune.
  • Le verrou global de l'interpréteur Python interdit à des fils d'accélérer un calcul, mais pas d'accélérer une attente : paralléliser du calcul en Python demande des processus.
  • Les données exposées se repèrent sur le texte du programme, en relevant pour chaque variable la liste des fils qui la nomment.

Ce relevé désigne des variables à protéger, sans dire encore de quoi. Le chapitre sur la condition de course montre ce qui leur arrive exactement, sur un stock de pièces détachées et deux retraits simultanés.