Aller au contenu principal

Systèmes d'exploitation — fondations · L2 · Section 3/12

Synchronisation entre processus

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Synchronisation entre processus

Dès que deux processus partagent une ressource, ils peuvent se nuire. Un producteur qui écrit trop vite dans un tampon plein, un gestionnaire de journaux qui lit un fichier pendant qu'un autre le tronque: ces situations produisent des conditions de course. La synchronisation consiste à imposer un ordre compatible avec l'invariant que l'on veut préserver.

#0. La synchronisation telle que le cours la montre

#Par les tubes: une synchronisation implicite

Le cours présente le tube comme un mécanisme où « tous les processus sont exécutés simultanément et c'est le système qui s'occupe de gérer leur synchronisation ». L'exemple qu'il donne est le plus parlant qui soit: « un processus qui produirait des résultats plus vite que le processus suivant serait capable de les lire devra suspendre temporairement son activité ».

C'est de la synchronisation à l'état pur, et elle est invisible pour le programmeur: aucun verrou, aucun sémaphore, aucune variable partagée. Le noyau gère la file d'attente du tube, et c'est le remplissage du tampon qui bloque l'écrivain et le vidage qui réveille le lecteur. Un ls -l /usr/bin | more en est la démonstration quotidienne: ls produit bien plus vite que vous ne lisez, et pourtant rien n'est perdu et rien ne déborde.

La même logique s'observe sur une FIFO, où la synchronisation est encore plus visible puisque l'ouverture elle-même bloque: un lecteur qui ouvre une FIFO sans écrivain est suspendu jusqu'à ce qu'un écrivain se présente, et inversement. Le noyau garantit en outre qu'une écriture inférieure ou égale à PIPE_BUF octets reste atomique, c'est-à-dire non entrelacée avec celle d'un autre écrivain: c'est la seule garantie d'exclusion mutuelle que le cours mentionne, et elle est gratuite.

#Entre père et fils: la synchronisation explicite

Le cours décrit un mécanisme de synchronisation dont chaque script peut avoir besoin: quand un processus se termine, tous ses processus fils se terminent aussi, et le père peut attendre ses fils. C'est ce que le TP 6 fait manipuler dans un couple de scripts, où le père lance un fils puis l'attend.

bashbash

1#!/bin/sh2if [ "$MON_PID" = "$PPID" ] ; then3	# Processus FILS4	echo "FILS : mon PID est $$, celui de mon père est $PPID"5	compte "FILS"6	echo "FILS : je me termine"7else8	# Processus PÈRE9	export MON_PID=$$10	echo "PERE : mon PID est $$"11	$0 &12	echo "PERE : mon fils a le PID $!"13	compte "PERE"14	echo "PERE : j'attends la fin de mon fils"

Le mécanisme est instructif à plus d'un titre. Le script se relance lui-même ($0 &) et se comporte différemment selon qu'il est père ou fils, distingués par la comparaison de $$ et $PPID. La commande wait bloque le père jusqu'à la fin de son fils: c'est la synchronisation explicite, sans aucun objet partagé. Et $! fournit le PID du fils lancé en arrière-plan, ce qui permet de cibler l'attente.

La variante qui suit montre l'échange d'information à travers le code de retour:

bashbash

1if [ "$MON_PID" = "$PPID" ] ; then2	# Processus FILS3	echo "FILS : je me termine avec le code 14"4	exit 145else6	# Processus PÈRE7	...8	wait $!9	echo "PERE : mon fils s'est terminé avec le code $?"10fi

Ici la synchronisation et la communication se confondent: le fils transmet une valeur au père par son code de sortie, que wait rend disponible dans $?. C'est la forme la plus élémentaire de communication inter-processus, et la seule que le cours illustre côté shell.

#1. Détecter les sections critiques

Identifier la section critique revient à repérer la zone où une ressource partagée est lue ou modifiée. Un compteur global, un fichier, une structure en mémoire partagée: tous nécessitent un protocole. Sans cette analyse préalable, l'ajout d'un mutex ou d'un sémaphore ressemble à de la magie et échoue dès que le programme évolue.

Le bon réflexe consiste à écrire l'invariant attendu (par exemple « le tampon contient un nombre d'éléments compris entre 0 et N ») et à vérifier que chaque parcours du code le respecte.

cc

1#include <pthread.h>2 3#define N 84static int buffer[N];5static int count = 0;                 /* invariant: 0 <= count <= N */6static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;7 8int deposit(int item) {9    pthread_mutex_lock(&lock);10    if (count == N) {                 /* l'invariant serait violé: refuser */11        pthread_mutex_unlock(&lock);12        return -1;13    }14    buffer[count++] = item;           /* section critique: courte */

Un test maison de mise en évidence: lancez deux processus qui incrémentent chacun cent mille fois le même compteur en mémoire partagée, sans verrou. Résultat typique: le total final est inférieur à deux cent mille, parce que deux lectures concurrentes de la même valeur produisent deux écritures identiques. C'est la condition de course observée, pas une anomalie matérielle.

#2. Primitives de synchronisation

Les sémaphores, hérités de Dijkstra, associent un compteur et une file d'attente. sem_wait décrémente; si la ressource est indisponible, le processus est bloqué et le noyau l'endort. sem_post incrémente et réveille un dormeur éventuel. Les mutex se concentrent sur l'exclusion mutuelle: un seul détenteur à la fois, sous peine d'attente active ou de blocage. Les moniteurs encapsulent ces verrous dans une abstraction plus riche comprenant des variables de condition. Sous Unix, on les retrouve via pthread_mutex et pthread_cond pour les threads, mais aussi sem_open pour synchroniser des processus n'appartenant pas au même espace d'adressage: le sémateur vit alors sous un nom dans /dev/shm, et les mutex ordinaires ne suffisent plus.

Le producteur-consommateur combine les deux familles:

cc

1#include <semaphore.h>2 3sem_wait(&empty_slots);               /* attendre une place libre */4pthread_mutex_lock(&lock);5put_item(buffer, item);6pthread_mutex_unlock(&lock);7sem_post(&filled_slots);              /* annoncer un élément disponible */

Le sémaphore régule le nombre d'éléments, le mutex protège l'accès réel à la structure. Inverser l'ordre (verrouiller puis attendre un sémaphore) fabrique un interblocage: le verrou est détenu pendant un sommeil indéfini.

#3. Prévenir l'interblocage

Un interblocage apparaît quand une attente circulaire se forme: A attend un verrou détenu par B, B attend un verrou détenu par C, et ainsi de suite jusqu'à revenir à A. Les quatre conditions de Coffman (exclusion mutuelle, détention et attente, non-préemption, attente circulaire) doivent toutes être réunies: casser une seule suffit.

Trois stratégies, par ordre de fiabilité:

  • prévention: imposer un ordre global de prise de verrous (toujours acquérir lock_a avant lock_b), ce qui rend l'attente circulaire impossible;
  • rattrapage: utiliser des verrous temporisés (pthread_mutex_timedlock) pour abandonner au-delà d'un délai, en libérant ce qui était détenu;
  • détection: construire périodiquement le graphe d'attente et chercher les cycles, comme le fait le gestionnaire de bases de données.

Dans la pratique, la stratégie la plus fiable reste de limiter la durée des sections critiques et de documenter le protocole: un verrou pris puis relâché dans la même fonction est presque impossible à mal ordonnancer.

#Atelier: une course, puis sa cure

Créez deux processus qui écrivent chacun alternativement dans un même fichier via une zone de mémoire partagée et un sémaphore.

  1. Commencez sans synchronisation et observez les lignes entremêlées: le fichier contient des enregistrements tronqués ou fusionnés.
  2. Ajoutez les primitives nécessaires (un sémaphore nommé via sem_open) pour rétablir l'ordre.
  3. Terminez en oubliant volontairement un sem_post et constatez que le blocage ne disparaît pas tout seul: il faut un plan de prévention.

Exercice de vérification: comptez les lignes complètes du fichier final avec wc -l. Sans verrou, vous observez moins de lignes valides que d'écritures émises; avec le sémaphore, le compte est exact et reproductible. Si votre version verrouillée bloque, utilisez strace -p sur un des processus pour voir l'appel en attente: c'est votre première autopsie d'interblocage.

#Exercices

1. En quoi un tube synchronise-t-il deux processus? Que se passe-t-il si le producteur écrit plus vite que le consommateur ne lit?

2. Pourquoi la synchronisation par tube ne demande-t-elle aucun verrou au programmeur?

3. Qu'est-ce qu'une FIFO apporte de plus qu'un tube anonyme, du point de vue de la synchronisation?

4. Écrire un couple père/fils en shell où le père attend la fin de son fils et affiche le code de retour de celui-ci. Quelles variables faut-il connaître?

5. Pourquoi dit-on qu'Unix fait communiquer ses processus « par le noyau » plutôt que par la mémoire?

Corrections détaillées

1. Le tube est un tampon géré par le noyau, placé entre la sortie standard d'un processus et l'entrée standard du suivant. Si le producteur écrit plus vite que le consommateur ne lit, le tampon se remplit et le noyau suspend le producteur: le cours le formule exactement ainsi — « un processus qui produirait des résultats plus vite que le processus suivant serait capable de les lire devra suspendre temporairement son activité ». Quand le consommateur retire des données, le producteur est réveillé. C'est un mécanisme de contrôle de flux, et il est entièrement automatique.

2. Parce que la synchronisation est prise en charge par le système, et non par les programmes: le cours précise que « tous les processus sont exécutés simultanément et c'est le système qui s'occupe de gérer leur synchronisation ». Le programmeur se contente de lire son entrée standard et d'écrire sa sortie standard; c'est ce contrat minimal, qui définit un filtre, qui rend le mécanisme transparent.

3. Une FIFO est un objet du système de fichiers: elle a un nom, des permissions et un type propre, et elle est donc accessible à des processus sans lien de parenté, alors qu'un tube anonyme ne relie que des processus apparentés. Sa synchronisation est en outre plus visible, car l'ouverture elle-même bloque: un lecteur qui ouvre une FIFO sans écrivain est suspendu jusqu'à l'arrivée d'un écrivain, et inversement. Le noyau garantit enfin qu'une écriture d'au plus PIPE_BUF octets reste atomique, ce qui donne une exclusion mutuelle gratuite entre écrivains.

4.

bashbash

1#!/bin/sh2if [ "$MON_PID" = "$PPID" ] ; then3	# FILS4	echo "FILS : mon PID est $$, celui de mon père est $PPID"5	exit 146else7	# PÈRE8	export MON_PID=$$9	echo "PERE : mon PID est $$"10	$0 &11	echo "PERE : mon fils a le PID $!"12	wait $!13	echo "PERE : mon fils s'est terminé avec le code $?"14fi

Les variables à connaître sont $$ (PID du processus courant), $PPID (PID du père, qui distingue les deux rôles), $! (PID du dernier processus lancé en arrière-plan) et $? (code de retour, rendu disponible par wait). Le père affiche 14, transmis par le fils par son exit. C'est la forme la plus élémentaire de communication inter-processus.

5. Parce que chaque processus a son propre espace d'adressage: deux processus ne peuvent pas voir la mémoire l'un de l'autre, ce qui est précisément la propriété d'isolation que le noyau assure. La communication passe donc par des objets que le noyau contrôle — des fichiers et leurs dérivés (tubes, FIFO, sockets) — ou par des événements qu'il gère, comme la terminaison d'un processus ou l'envoi d'un signal. Les sémaphores de la section 2 constituent la troisième voie: une zone de mémoire explicitement partagée, protégée par une primitive du noyau.