8. Sûreté concurrente et conditions de concurrence
8.1 Fonction thread-safe et fonction réentrante
Une fonction est thread-safe si plusieurs threads peuvent l’utiliser concurremment dans les conditions documentées sans course ni corruption. La garantie peut autoriser plusieurs lecteurs mais interdire des écritures simultanées.
Deux familles de stratégies existent :
- éviter l’état mutable partagé : variables locales, objets immuables, tampon fourni par l’appelant, stockage local au thread;
- contrôler cet état : mutex, opérations atomiques et autres primitives de synchronisation.
La réentrance est liée, mais différente. Une fonction protégée par un mutex peut être sûre entre threads sans être réentrante dans un gestionnaire de signal ou d’interruption. Inversement, une fonction sans variable globale facilite la réentrance, mais son appelant peut encore partager incorrectement un objet externe.
Une variable locale static ne vit pas dans la pile propre au thread. Elle est partagée entre appels et peut être écrasée par un second participant. Il faut employer une variante réentrante, un tampon par appelant, du stockage local au thread ou une protection explicite selon le contrat.
8.2 Condition de concurrence
Une instruction de haut niveau comme compteur++ peut devenir une séquence :
- lire la mémoire;
- calculer une nouvelle valeur dans un registre;
- réécrire la mémoire.
Une interruption peut survenir entre ces étapes. Si un autre thread manipule la même donnée, le résultat dépend de l’entrelacement. Une condition de concurrence existe lorsque le résultat observable dépend de cet ordre imprévisible.
Une section critique est une portion de code qui accède à une ressource commune et qui ne doit pas être exécutée simultanément par plusieurs participants. Une solution d’exclusion comporte conceptuellement une entrée, la section critique, une sortie et le reste du code non critique.
8.3 Exemple du spouleur
Une variable partagée N désigne la prochaine case libre du tableau d’impression.
- A lit
N = 7, puis est interrompu. - B lit aussi 7, écrit son travail dans la case 7 et incrémente
N. - A reprend avec sa copie locale 7 et écrase la même case.
Le document de B disparaît sans erreur nécessairement visible. La section critique doit couvrir ensemble la lecture de N, l’écriture de la case et la mise à jour de N; protéger seulement une étape ne préserve pas l’invariant.
8.4 Exemple du compteur
Deux threads exécutent des incréments sur une globale. Au maximum, aucune mise à jour n’est perdue et le résultat vaut deux fois le nombre d’itérations. Dans un entrelacement défavorable, plusieurs lectures utilisent la même ancienne valeur et une écriture en écrase une autre; le résultat se rapproche d’un seul lot d’incréments. De nombreuses valeurs intermédiaires sont possibles.
Un pthread_join après les créations attend la fin des deux threads, mais ne protège aucune opération pendant leur exécution. Un join immédiat supprimerait la course en sérialisant tout, au prix du parallélisme. Une exclusion fine — développée dans le module Concurrence — doit protéger uniquement la mise à jour partagée.
8.5 Communication entre processus et entre threads
Deux processus isolés établissent un canal explicite : mémoire partagée, tube, fichier ou réseau. Un pipe relie typiquement la sortie d’un programme à l’entrée d’un autre. Les threads possèdent déjà un canal par le tas et les globales, mais cette commodité rend les conflits plus faciles.
Le problème doit être décomposé :
- établir le partage ou le canal;
- exclure les accès incompatibles;
- synchroniser les dépendances, par exemple un consommateur qui attend une donnée du producteur.