Séance 3 — Programmation parallèle avancée (synchronisation)

Programmation parallèle avancée (synchronisation)

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

Dès que plusieurs fils partagent une donnée, il faut organiser leurs accès. La séance montre comment une course critique perd des mises à jour, comment les verrous, les sémaphores et les barrières les ordonnent, ce que coûtent l’interblocage et le livelock, puis trois patrons (patterns) d’organisation (fork-join, pipeline, producteur-consommateur) et l’équilibrage de la charge.

Plan

  1. Les défis de la programmation parallèle
  2. Courses critiques et sections critiques
  3. Mécanismes de synchronisation
  4. Interblocage et livelock
  5. Patrons de parallélisation
  6. Ordonnancement et équilibrage de charge

Prérequis : Séance 1 (fils, processus, accélération) et Séance 2 (copies dans les caches, false sharing, ordre des écritures).

Lab associé : Lab 4 (synchroniser : verrous, sémaphores, files et interblocage).

Ensuite : Séance 4, architectures distribuées et NUMA.

Les défis de la programmation parallèle

Trois problèmes fondamentaux

Plusieurs fils (threads) qui s’exécutent en parallèle se partagent des ressources : variables, structures de données, fichiers, connexions. Il faut gérer l’accès concurrent à ces ressources et synchroniser les fils pour qu’ils collaborent. Lorsque des fils partagent des ressources, trois problèmes majeurs émergent :

  1. Courses critiques (race conditions) : deux fils accèdent à la même donnée en même temps, et le résultat dépend de leur ordre ;
  2. Interblocage (deadlock) : des fils s’attendent mutuellement indéfiniment ;
  3. Livelock : des fils restent actifs mais ne progressent pas.

Les fils de ce cours

Les exemples sont en Python, avec le module threading : tous les fils d’un programme partagent sa mémoire. Les attentes (time.sleep) y simulent une lecture de fichier ou une requête ; c’est le cas où les fils de CPython avancent vraiment ensemble (Lab 4). Les sorties et les durées montrées sont celles d’exécutions sous CPython 3.13.

Courses critiques et sections critiques

Une ligne qui n’est pas atomique

from threading import Thread
compteur = 0

def incrementer():
    global compteur
    for i in range(1000000):
        # PAS atomique : lire, ajouter 1, écrire
        compteur = compteur + 1

thread1 = Thread(target=incrementer)
thread2 = Thread(target=incrementer)
thread1.start(); thread2.start()
thread1.join(); thread2.join()
print(f"Valeur finale : {compteur}")

Résultat \(\rightarrow\)

Valeur finale : 2000000

Course critique

Race condition : le résultat d’un programme dépend de l’ordre d’exécution, non déterministe, de plusieurs fils qui accèdent à des ressources partagées.

Un résultat juste ne prouve rien

Obtenu avec CPython 3.13 : \(2\,000\,000\) ; il ne change de fil qu’aux sauts en arrière (fin de tour) et aux appels. Rien ne le garantit : CPython 3.9 donne \(1{,}42\) à \(1{,}55\) million, 3.13t sans GIL \(1{,}03\) à \(1{,}36\) million.

Une course sur un compteur, pas à pas

Lecture. Le fil 1 lit compteur : son registre local \(r\) reçoit \(0\) (la mémoire est lue, cadre orange). Rien n’est encore écrit.

Lecture. Le fil 2 lit compteur avant que le fil 1 ait écrit : il lit aussi \(0\). Les deux fils travaillent maintenant sur la même valeur.

Lecture. Le fil 1 ajoute \(1\) à son registre : \(r=1\). Ce calcul est local : compteur vaut toujours \(0\).

Lecture. Le fil 1 écrit \(r\) : compteur passe à \(1\) (cadre rouge). Le registre du fil 2 contient encore l’ancienne valeur \(0\).

Lecture. Le fil 2 ajoute \(1\) à son registre : \(r=0+1=1\). Il ignore l’écriture du fil 1.

Lecture. Le fil 2 écrit \(1\) : il écrase le \(1\) du fil 1. Deux incréments, compteur \(=1\) au lieu de \(2\) : un incrément est perdu.

Toutes les valeurs possibles

Lecture. Deux fils, \(n\) incréments chacun : un programme énumère tous les ordres des \(6n\) opérations (hauteurs en échelle logarithmique). La bonne valeur \(2n\) (en vert) est rare ; dès \(n=2\), la plus petite est \(2\), et non \(n\) : avec un million d’incréments par fil, le résultat peut aller de \(2\) à \(2\,000\,000\).

Exercice : sections critiques d’un retrait

Énoncé. Identifier les sections critiques du code suivant.

solde_bancaire = 1000
historique = []
def retrait(montant):
    global solde_bancaire
    # Ligne A : Vérification
    if solde_bancaire >= montant:
        # Ligne B : Calcul local
        nouveau_solde = solde_bancaire - montant
        time.sleep(0.001)  # Simule un délai
        # Ligne C : Mise à jour
        solde_bancaire = nouveau_solde
        # Ligne D : Logging
        historique.append(f"Retrait de {montant}")
        return True
    return False
  1. Quelles lignes forment une section critique ? (b) Deux fils appellent retrait(800) en même temps, solde \(1000\) F CFA : que se passe-t-il ?

Correction

  1. A, B et C : elles lisent, modifient et écrivent solde_bancaire ; D aussi : elle modifie historique, partagée.
  2. Les deux fils vérifient \(1000\geq800\), calculent \(200\), écrivent \(200\). \(1600\) F versés pour un compte de \(1000\) F ; le solde affiche \(200\) F : un débit de \(800\) F est perdu. À l’exécution : [True, True], solde \(200\).

Verrou autour de A à D : [True, False].

Piège : sans global, l’appel lève UnboundLocalError.

Section critique et exclusion mutuelle

Section critique

Portion de code qui accède à une ressource partagée et qui ne doit pas être exécutée par plusieurs fils simultanément.

Pour résoudre les courses critiques, il faut garantir l’exclusion mutuelle : un seul fil à la fois peut exécuter la section critique.

Vérifier puis agir

Dans retrait, la vérification (A) et l’écriture (C) doivent être dans la même section critique : protéger seulement l’écriture laisse deux fils passer la vérification avec le même solde (Lab 4, guichet_faux).

Mécanismes de synchronisation

Le verrou (mutex)

from threading import Thread, Lock
mutex = Lock()  # Création du mutex

def incrementer_securise():
    global compteur
    for i in range(1000000):
        mutex.acquire()  # Acquérir le verrou
        try:
            compteur = compteur + 1
        finally:
            mutex.release()  # TOUJOURS libérer

def incrementer_propre():
    global compteur
    for i in range(1000000):
        with mutex:  # acquire puis release
            compteur = compteur + 1

Verrou

Mutex, verrou d’exclusion mutuelle : mécanisme qui protège une section critique en garantissant qu’un seul fil peut l’exécuter à la fois.

lock() (en Python acquire()) : acquérir le verrou, en attendant s’il est déjà pris ;

unlock() (release()) : libérer le verrou.

Résultat \(\rightarrow\)

incrementer_securise : 2000000
incrementer_propre : 2000000

Lecture. Compte exact dans les deux versions. Le with libère le verrou même en cas d’exception.

Le verrou et sa file d’attente, pas à pas

Lecture. F1 exécute with verrou: : le verrou est libre, F1 le prend (cadre rouge) et entre dans la section critique.

Lecture. F2 demande le verrou : il est pris par F1. F2 est bloqué : il ne consomme pas de calcul, il attend dans la file du verrou.

Lecture. F3 demande à son tour : bloqué derrière F2. Un seul fil est dans la section critique : c’est l’exclusion mutuelle.

Lecture. F1 exécute compteur = compteur + 1 : il lit \(0\) et écrit \(1\). Aucun autre fil ne peut lire compteur entre-temps.

Lecture. F1 sort du bloc : le verrou est rendu (release) et un fil en attente le reçoit, ici F2. F2 entre dans la section critique.

Lecture. F2 a écrit \(2\) et rendu le verrou ; F3 l’a reçu et vient d’écrire \(3\). Le compte est exact. L’ordre de réveil n’est pas garanti par Python : la figure suit l’ordre d’arrivée.

Erreurs fréquentes avec les verrous

1. Oublier de libérer

mutex.acquire()
compteur += 1
# une exception ici :
# mutex jamais libéré
mutex.release()

Tous les autres fils restent bloqués. Solution : try-finally ou with.

2. Verrouiller trop longtemps

with mutex:
    compteur += 1
    calcul_long()
    ecrire_fichier()

Les autres fils attendent un calcul et une entrée-sortie qui ne touchent pas la ressource. Solution : ne protéger que l’accès partagé.

3. Verrouillage multiple

# Fil 1
with mutex_A:
    with mutex_B:
        ...
# Fil 2
with mutex_B:
    with mutex_A:
        ...

Chacun peut attendre l’autre : interblocage (section 4).

Le sémaphore

semaphore_bd = Semaphore(3)  # 3 connexions max

def requete_base_donnees(i):  # i : id_thread
    print(f"Thread {i} : En attente de connexion...")
    with semaphore_bd:  # acquire
        print(f"Thread {i} : Connecté à la BD")
        time.sleep(2)  # Simulation requête
        print(f"Thread {i} : Requête terminée")
    print(f"Thread {i} : Connexion fermée")

for i in range(10):  # Lancer 10 threads
    Thread(target=requete_base_donnees,
           args=(i,)).start()

Résultat \(\rightarrow\)

Thread 1 : En attente de connexion...Thread 0 : Connecté à la BD

Sémaphore

Compteur qui limite le nombre de fils qui accèdent en même temps à une ressource.

acquire() : décrémenter (attendre si \(0\)) ;

release() : incrémenter (réveiller un fil qui attend).

Binaire (\(0\) ou \(1\)) : comme un verrou, sans propriétaire ; compteur (\(>1\)) : limite les accès.

Lecture. ↪ : suite de la même ligne ; print écrit le texte, puis le saut de ligne, et un autre fil passe entre les deux.

Le sémaphore compté, pas à pas

Lecture. \(t=0\) : les dix fils démarrent ensemble. Les fils \(0\), \(1\), \(2\) font acquire : le compteur passe de \(3\) à \(2\), \(1\), puis \(0\). Ils sont connectés.

Lecture. \(t=0\) : les fils \(3\) à \(9\) font acquire avec un compteur à \(0\) : ils sont bloqués. Jamais plus de \(3\) connexions à la fois.

Lecture. \(t=2\) s : les fils \(0\), \(1\), \(2\) sortent (release). Chaque release réveille un fil en attente, qui prend la place : \(3\), \(4\), \(5\) entrent, le compteur reste à \(0\).

Lecture. \(t=4\) s : même relève : \(6\), \(7\), \(8\) entrent. Il ne reste que le fil \(9\) en attente.

Lecture. \(t=6\) s : trois releases, un seul fil en attente : \(9\) entre et le compteur remonte à \(2\) (deux places libres).

Lecture. \(t=8\) s : le fil \(9\) sort, le compteur revient à \(3\). Durée : \(\lceil 10/3\rceil\) vagues de \(2\) s, soit \(8\) s (mesuré : 8,00 s).

La barrière

barriere = Barrier(4)  # 4 threads

def travailleur(i):  # i : id_thread
    print(f"Thread {i} : Phase 1 - "
          "Chargement des données")
    time.sleep(random.uniform(0.5, 2))
    print(f"Thread {i} : Données chargées")
    barriere.wait()  # attendre les 4 threads
    print(f"Thread {i} : Phase 2 - Traitement")
    time.sleep(random.uniform(0.5, 2))
    print(f"Thread {i} : Traitement terminé")
    barriere.wait()  # deuxième barrière
    print(f"Thread {i} : Phase 3 - Finalisation")

Barrière

Point de synchronisation où chaque fil attend que tous les autres l’aient atteint avant de continuer.

Tous les fils terminent la phase 1 avant que l’un d’eux commence la phase 2 ; l’objet Barrier se réarme et sert une seconde fois. Résultat \(\rightarrow\)

Thread 3 : Données chargées
Thread 0 : Données chargées
Thread 1 : Données chargées
Thread 2 : Données chargées

Lecture. Ordre d’arrivée observé : \(3, 0, 1, 2\) ; le pas à pas reprend ces durées.

La barrière, pas à pas

Lecture. \(t=0{,}52\) s : le fil 3, le plus rapide, finit sa phase 1 et appelle wait() : \(1\) fil sur \(4\) à la barrière. Il est bloqué (hachures).

Lecture. \(t=0{,}86\) s : le fil 0 arrive : \(2\) sur \(4\). Les fils arrivés attendent sans rien faire.

Lecture. \(t=1{,}05\) s : le fil 1 arrive : \(3\) sur \(4\). La barrière reste fermée tant qu’un fil manque.

Lecture. \(t=1{,}44\) s : le fil 2, le plus lent, arrive : \(4\) sur \(4\), la barrière s’ouvre et les quatre fils commencent la phase 2 ensemble.

Lecture. \(t=3{,}20\) s : même règle à la seconde barrière, réutilisée. Durée : la somme des phases les plus lentes, \(1{,}44+1{,}76=3{,}20\) s (mesuré : 3,20 s).

Exercice : une simulation en phases

Énoncé

Une simulation a \(3\) fils qui doivent :

  1. initialiser leurs données (temps variable) ;
  2. se synchroniser ;
  3. calculer en parallèle (\(100\) itérations) ;
  4. se synchroniser après chaque itération ;
  5. afficher les résultats finaux.

Implémenter ce programme avec des barrières. Combien de barrières sont nécessaires ?

Correction

barriere_init = Barrier(N_THREADS)
barriere_iteration = Barrier(N_THREADS)
def simulation(id_thread):
    ...  # Initialisation (temps variable)
    barriere_init.wait()
    for iteration in range(N_ITERATIONS):
        resultats[id_thread] += random.random()
        barriere_iteration.wait()
  • 1 barrière pour l’initialisation : tous les fils sont prêts ;
  • 1 barrière dans la boucle, appelée \(100\) fois ;
  • total : 2 objets Barrier, et \(1+100=101\) appels à wait() par fil (compté à l’exécution). Un seul objet pourrait servir aux deux, puisqu’il se réarme.

Interblocage et livelock

Interblocage (deadlock)

Interblocage

Deux fils ou plus sont bloqués indéfiniment, chacun attendant qu’un autre libère une ressource.

Quatre conditions nécessaires (Coffman) :

  1. Exclusion mutuelle : ressources non partageables ;
  2. Hold and wait : un fil garde une ressource et en attend une autre ;
  3. Pas de préemption : on ne peut pas forcer un fil à libérer une ressource ;
  4. Attente circulaire : T1 attend T2, …, Tn attend T1.
def thread_1():
    with mutex_A:
        time.sleep(0.1)  # T2 prend mutex_B
        with mutex_B:    # bloqué
            print("Thread 1 : Travail")

def thread_2():
    with mutex_B:
        time.sleep(0.1)  # T1 prend mutex_A
        with mutex_A:    # bloqué
            print("Thread 2 : Travail")

Lecture. À l’exécution : après \(3\) s, les deux fils sont toujours vivants, mutex_A et mutex_B restent pris, aucun « Travail » affiché. t1.join() attendrait indéfiniment.

L’interblocage et le graphe d’attente, pas à pas

Lecture. T1 prend mutex_A : flèche A\(\to\)T1 (A est détenu par T1). Une ressource à usage exclusif : première condition de Coffman.

Lecture. T2 prend mutex_B : flèche B\(\to\)T2. Chaque fil tient un verrou ; aucun n’attend encore.

Lecture. T1 demande B, détenu par T2 : flèche pointillée T1\(\to\)B. T1 garde A en attendant (hold and wait). Graphe d’attente : T1\(\to\)T2, pas de cycle.

Lecture. T2 demande A, détenu par T1 : T2\(\to\)T1. Le graphe d’attente contient le cycle T1\(\to\)T2\(\to\)T1 : les quatre conditions sont réunies, interblocage.

Lecture. Correction : les deux fils prennent A, puis B (ordre global). Ici T1 tient A ; T2 attend A sans rien tenir. Pas de cycle : T1 prendra B, finira et libérera A.

Le dîner des philosophes

Lecture. Analogie : il faut ses deux fourchettes pour manger ; si tous prennent leur gauche, personne ne mange. Flèche pleine : tenue ; pointillés : demandée. À l’exécution, à droite, chacun mange.

Prévention : ordre global d’acquisition

# Toujours acquérir les verrous
# dans le même ordre
def thread_1_securise():
    with mutex_A:  # Toujours A en premier
        with mutex_B:  # Puis B
            print("Thread 1 : Travail")

def thread_2_securise():
    with mutex_A:  # même ordre
        with mutex_B:  # Puis B
            print("Thread 2 : Travail")

Résultat \(\rightarrow\)

Thread 1 : Travail
Thread 2 : Travail

Ce que l’ordre supprime

Les quatre conditions sont nécessaires : en empêcher une seule rend l’interblocage impossible. Si T1 a mutex_A, T2 attend mutex_A sans rien tenir : aucun cycle ne peut se former. L’ordre global supprime l’attente circulaire.

Plusieurs verrous : un ordre total (numéro, nom), le même pour tous les fils (Lab 4 : comptes triés par nom).

Lecture. À l’exécution, avec une attente de \(0{,}1\) s entre les deux prises : les deux fils travaillent, en \(0{,}20\) s.

Prévention : timeout

def thread_avec_timeout():
    if mutex_A.acquire(timeout=1.0):  # 1 s max
        try:
            if mutex_B.acquire(timeout=1.0):
                try:
                    print("Travail effectué")
                finally:
                    mutex_B.release()
            else:
                print("Timeout mutex_B, abandon")
        finally:
            mutex_A.release()
    else:
        print("Timeout mutex_A, abandon")

Ce que le délai supprime

Après \(1\) s, le fil renonce et rend ce qu’il tient : le délai attaque la condition de non-préemption (ou de hold and wait, selon les auteurs) ; il borne l’attente.

Un abandon au moins

Ordres opposés, même délai : le délai du premier fil expire, il rend son verrou ; l’autre l’obtient juste avant son propre délai et travaille (80 exécutions sur 80). Si les deux expirent ensemble, les deux abandonnent : réessayer, pas au même rythme.

Prévention : try-lock et backoff

def thread_avec_backoff():
    while True:
        if mutex_A.acquire(blocking=False):
            try:
                if mutex_B.acquire(blocking=False):
                    try:
                        print("Travail effectué")
                        return  # Succès
                    finally:
                        mutex_B.release()
                # mutex_B pris : libérer A, réessayer
            finally:
                mutex_A.release()
        # Attendre un peu avant de réessayer
        time.sleep(random.uniform(0.001, 0.01))

acquire(blocking=False) rend False tout de suite si le verrou est pris : le fil n’attend jamais en tenant un verrou. L’attente aléatoire évite que les deux fils recommencent au même instant.

Lecture. À l’exécution, deux fils en ordres opposés : les deux réussissent, en \(1\) et \(2\) essais.

Livelock

Livelock

Des fils restent actifs mais ne progressent pas, car ils réagissent continuellement aux actions des autres sans avancer.

Analogie

Deux personnes se croisent dans un couloir étroit : A se déplace à gauche, B aussi pour éviter A ; A voit B et passe à droite, B aussi ; A retourne à gauche… Les deux bougent sans cesse, aucune n’avance.

Contrairement à l’interblocage, où tout est figé, dans un livelock les fils sont actifs mais improductifs.

Solution : un délai aléatoire (backoff exponentiel) pour briser la symétrie.

Mesure

Code de la séance, \(20\) exécutions : aucune reprise, le fil A finit avant que B démarre. Départ simultané et \(10\) ms de travail après la prise du premier verrou : un fil recommence une fois, puis passe (\(20\) fois sur \(20\)). Les écarts de temps entre fils brisent vite la symétrie ; rien ne garantit qu’ils le fassent.

Le livelock, pas à pas

Lecture. Tour 1 : A et B démarrent en même temps et avancent au même rythme. A prend mutex_A, B prend mutex_B (le fil B suit le même code, verrous échangés).

Lecture. Chacun teste l’autre verrou : A voit mutex_B pris, B voit mutex_A pris (cadres orange).

Lecture. Par « politesse », chacun rend son verrou et attend \(1\) ms. Les deux verrous sont libres : aucun fil n’est bloqué, mais aucun travail n’est fait.

Lecture. Tour 2 : même attente, même reprise : A reprend mutex_A, B reprend mutex_B, exactement l’état du tour 1. Tant que les pas restent égaux, la boucle ne finit jamais : c’est le livelock.

Lecture. Backoff aléatoire : A attend moins longtemps que B, reprend mutex_A et trouve mutex_B libre : travail fait. B passe ensuite. En pratique, les écarts de temps entre fils brisent vite la symétrie.

Patrons de parallélisation

Patron fork-join

Fork-join

  1. Fork : diviser une tâche en sous-tâches parallèles ;
  2. Exécution : traiter les sous-tâches en parallèle ;
  3. Join : attendre que toutes les sous-tâches se terminent ;
  4. Fusion : combiner les résultats.

Un patron (pattern) est une organisation type du travail entre fils. Un pool de fils (ThreadPoolExecutor) garde ses workers d’une tâche à l’autre : il évite de créer un fil par sous-tâche.

def sous_tache(n):
    print(f"Début sous-tâche {n}")
    time.sleep(1)
    resultat = n * n
    print(f"Fin sous-tâche {n}, "
          f"résultat = {resultat}")
    return resultat

Résultat \(\rightarrow\)

Début sous-tâche 1Début sous-tâche 2

Début sous-tâche 3
Début sous-tâche 4
Fin sous-tâche 2, résultat = 4
Début sous-tâche 5

Lecture. Lignes mêlées, comme au sémaphore : deux print simultanés.

Fork-join, pas à pas

Lecture. Fork : les \(8\) appels submit déposent les sous-tâches dans la file du pool et lui font créer jusqu’à \(4\) workers ; chaque appel rend la main tout de suite (un Future par tâche).

Lecture. \(t=0\) à \(1\) s : chaque worker prend une sous-tâche : \(1\), \(2\), \(3\), \(4\) s’exécutent en parallèle. Les tâches \(5\) à \(8\) attendent dans la file.

Lecture. \(t=1\) à \(2\) s : dès qu’un worker finit, il prend la tâche suivante : \(5\), \(6\), \(7\), \(8\). Le programme principal attend dans future.result().

Lecture. Join, puis fusion : les \(8\) résultats sont là (\(1, 4, \dots, 64\)), dans l’ordre des futures ; sum donne \(204\). Durée : \(2\) vagues de \(1\) s, \(2\) s (mesuré : 2,00 s) au lieu de \(8\) s.

Patron pipeline

Pipeline

Le traitement est divisé en étapes successives ; chaque étape a son fil, et les étapes travaillent en même temps, chacune sur un élément différent. Des files relient une étape à la suivante.

Durée

\(n\) éléments, étapes de durées \(d_1,\dots,d_k\) : \[T=(d_1+\dots+d_k)+(n-1)\times\max_i d_i\] Après la mise en route, l’étape la plus lente fixe le rythme.

def etape_traitement():
    while True:
        image = queue_chargement.get()
        if image is None:  # signal de fin
            queue_traitement.put(None)
            break
        image_traitee = appliquer_filtre(image)
        queue_traitement.put(image_traitee)

Trois fils : chargement, traitement, sauvegarde. Les fonctions charger_image, appliquer_filtre, sauvegarder_image sont hypothétiques ; l’exécution les remplace par des attentes de \(0{,}1\), \(0{,}3\) et \(0{,}2\) s.

Le pipeline, pas à pas

Lecture. \(t=0{,}1\) s : l’étape 1 a chargé img1 et l’a déposée dans queue_chargement ; l’étape 2 la prend aussitôt. L’étape 1 enchaîne avec img2.

Lecture. \(t=0{,}3\) s : l’étape 1 a fini les trois chargements. img2 et img3 attendent dans la file : l’étape 2, la plus lente (\(0{,}3\) s), fixe le rythme.

Lecture. \(t=0{,}4\) s : img1 est traitée et passe à l’étape 3 ; l’étape 2 prend img2. Les trois étapes travaillent en même temps, chacune sur une image différente.

Lecture. \(t=0{,}7\) s : img2 est traitée ; l’étape 2 prend img3, l’étape 3 sauvegarde img2. Une image sort toutes les \(0{,}3\) s.

Lecture. \(t=1{,}2\) s : tout est sauvegardé. \(T=(0{,}1+0{,}3+0{,}2)+(3-1)\times0{,}3=1{,}2\) s, contre \(3\times0{,}6=1{,}8\) s à la suite (mesuré : 1,20 s).

Patron producteur-consommateur

Producteur-consommateur

Une file partagée où les producteurs ajoutent des éléments et les consommateurs les retirent et les traitent ; la file gère elle-même la synchronisation.

Queue(maxsize=5) : put attend une place libre, get attend un élément ; task_done signale une tâche finie, join attend qu’elles le soient toutes.

Lecture. Une exécution (\(2\) producteurs de \(5\) tâches, \(3\) consommateurs) : \(10\) tâches traitées, réparties \(3\), \(4\), \(3\) ; la file n’a jamais dépassé \(2\) éléments. Les consommateurs s’arrêtent \(2\) s après la dernière tâche.

file_taches = Queue(maxsize=5)  # limitée

def consommateur(id_cons):
    while True:
        try:
            tache = file_taches.get(timeout=2)
            time.sleep(random.uniform(0.2, 0.8))
            file_taches.task_done()
        except Empty:  # Plus de tâches
            break

Arrêt par délai

Un producteur qui reste plus de \(2\) s sans déposer arrête les consommateurs trop tôt. Un signal de fin (None), un par consommateur, ne dépend d’aucune durée (Lab 4).

Producteurs et consommateurs, pas à pas

Lecture. Les trois consommateurs appellent get() sur une file vide : ils sont bloqués. Aucune tâche n’existe encore.

Lecture. Les producteurs déposent 0-0, 1-0, 0-1 : chaque dépôt réveille un consommateur, qui prend la tâche. File vide, \(3\) tâches non terminées.

Lecture. Les consommateurs sont occupés ; les producteurs déposent \(4\) tâches de plus. La file les garde dans l’ordre d’arrivée : \(4\) places sur \(5\).

Lecture. P1 dépose 1-3 : la file est pleine (\(5/5\)). \(8\) tâches déposées, aucune terminée.

Lecture. P0 appelle put(0-4) : file pleine, P0 est bloqué jusqu’à ce qu’une place se libère. La taille de la file ne dépasse jamais \(5\).

Lecture. C1 finit 1-0 (task_done : \(7\) non terminées), puis get() rend la plus ancienne, 1-1 : une place se libère, P0 dépose 0-4 : \(8\) non terminées.

Ordonnancement et équilibrage de charge

Équilibrage statique ou dynamique

Équilibrage de charge

Répartir le travail entre les fils ou les processus pour utiliser au mieux les ressources. Statique : répartition fixée à l’avance ; dynamique : répartition adaptée pendant l’exécution.

Équilibrage statique

  • avantages : peu de surcoût, prédictible ;
  • inconvénient : inefficace si les tâches ont des durées variables ;
  • usage : tâches homogènes, charge prévisible.

Équilibrage dynamique

  • avantages : s’adapte aux tâches hétérogènes, meilleure utilisation ;
  • inconvénient : surcoût de coordination (une file partagée) ;
  • usage : tâches hétérogènes, charge imprévisible.

Équilibrage statique et dynamique, pas à pas

Lecture. Douze tâches de durées inégales (en secondes) pour \(4\) fils : \(5, 4, 3, 1, 1, 1, 1, 1, 1, 1, 2, 1\), soit \(22\) s de travail. Au mieux, \(22/4=5{,}5\) s.

Lecture. Statique : blocs fixés à l’avance, \(3\) tâches consécutives par fil. Le fil 0 reçoit \(5+4+3=12\) s, les autres \(3\) ou \(4\) s : fin à \(12\) s, trois fils inactifs les deux tiers du temps.

Lecture. Dynamique, \(t=2\) s : une file de tâches ; chaque fil libre prend la suivante. À \(t=0\), les fils prennent \(5\), \(4\), \(3\) et \(1\) ; le fil 3, libéré à \(t=1\), prend la tâche suivante.

Lecture. Dynamique, fin : les petites tâches remplissent les trous. Fin à \(6\) s au lieu de \(12\), proche de la borne \(5{,}5\) s. Le prix : un accès à la file partagée par tâche.

Exercice : 100 fichiers sur 4 processeurs

Énoncé

\(100\) fichiers dont la taille varie de \(1\) Ko à \(1\) Go, \(4\) processeurs.

  1. Avec un équilibrage statique (\(25\) fichiers par processeur), quel est le problème probable ?
  2. Proposer une stratégie d’équilibrage dynamique.
  3. L’implémenter en Python avec ThreadPoolExecutor.

Correction

  1. Un processeur peut recevoir les gros fichiers et finir très tard pendant que les autres, leurs petits fichiers traités, restent inactifs : mauvaise efficacité.
  2. File de tâches (chaque processeur prend le fichier suivant) ; tri par taille décroissante, les gros d’abord ; work stealing (un processeur inactif « vole » des tâches à un processeur occupé).
  3. executor.submit(traiter_fichier, f) pour chaque fichier, puis as_completed : les \(4\) workers prennent les fichiers au fur et à mesure, aucun n’est inactif tant qu’il en reste.

Mesure (\(100\) fichiers fictifs, tailles log-uniformes de \(1\) Ko à \(1\) Go ; \(1\) Go \(\to0{,}5\) s) : statique 1,03 s, dynamique 0,93 s, borne \(0{,}85\) s.

Pièges : fichiers absents (FileNotFoundError) ; pour un calcul, les fils de CPython standard ne s’exécutent pas en parallèle (ProcessPoolExecutor ; 3.13t sans GIL, si).

Checklist des problèmes de concurrence

Avant d’écrire le code

  1. Identifier les ressources partagées : variables globales, structures de données partagées, fichiers, bases de données.
  2. Protéger les sections critiques : verrous pour l’exclusion mutuelle, sections critiques courtes, verrous toujours libérés (with).
  3. Éviter les interblocages : ordre global d’acquisition, délais, éviter de prendre plusieurs verrous à la fois.

Organiser le travail

  1. Choisir le bon patron : fork-join pour des tâches indépendantes, pipeline pour un traitement en étapes, producteur-consommateur pour un flux de données.
  2. Équilibrer la charge : dynamique pour des tâches hétérogènes, statique pour des tâches homogènes ; pools de fils.

Comparaison des primitives de synchronisation

Primitive Usage Surcoût Quand l’utiliser ?
Verrou (Lock) exclusion mutuelle faible protéger une ressource partagée
Sémaphore limiter les accès faible pool de ressources limité
Barrière synchronisation moyen phases parallèles
Queue communication moyen producteur-consommateur
Event signal binaire faible notification

Règles d’or

  1. Minimiser le partage : données locales.
  2. Granularité adaptée : ni trop fine (surcoût), ni trop grosse (déséquilibre).
  3. Éviter l’attente active : primitives de synchronisation.
  1. Tester exhaustivement : les bugs de concurrence sont non déterministes.
  2. Documenter quel verrou protège quelle ressource.
  3. Mesurer : le parallélisme peut ralentir.

Exercice de synthèse : serveur web

Énoncé

Un serveur web :

  1. requêtes concurrentes ;
  2. pool de \(10\) workers au plus ;
  3. limite les connexions actives par un sémaphore ;
  4. tient un compteur de requêtes partagé, exact ;
  5. s’arrête proprement, avec une barrière.

Avec Thread, Lock, Semaphore, Barrier ; tous les fils se terminent.

Correction

def worker(self):
    while (requete := self.file.get()) is not None:
        self.traiter_requete(requete)
    self.fin.wait()  # Barrier(max_workers + 1)
def traiter_requete(self, requete):
    with self.semaphore:  # Semaphore(5)
        time.sleep(0.05)  # réponse simulée
        with self.mutex_stats:
            self.compteur_requetes += 1

Arrêt : un None par worker, puis fin.wait() dans le fil principal, qui attend les \(10\) workers. \(40\) requêtes de \(0{,}05\) s : compteur \(40\), au plus \(5\) actives, 0,40 s \(=\lceil 40/5\rceil\times0{,}05\).

Ce qu’il faut retenir

À retenir (1/2)

  1. Course critique : accès concurrent non synchronisé à une ressource partagée ; lire-modifier-écrire peut perdre des mises à jour.
  2. Section critique : code qui accède à une ressource partagée ; vérifier et agir dans le même bloc.
  3. Verrou : un seul fil à la fois.
  4. Sémaphore : au plus \(k\) fils à la fois (compteur).
  5. Barrière : tous les fils attendent le dernier.
  6. Interblocage : attente circulaire, les quatre conditions de Coffman.

À retenir (2/2)

  1. Livelock : fils actifs qui ne progressent pas ; backoff aléatoire.
  2. Fork-join : diviser, traiter en parallèle, attendre, fusionner.
  3. Pipeline : étapes en parallèle ; \(T=\sum d_i+(n-1)\max d_i\).
  4. Producteur-consommateur : file partagée, bornée.
  5. Équilibrage statique : répartition fixe (tâches homogènes).
  6. Équilibrage dynamique : répartition adaptative (tâches hétérogènes).

Lab 4 et prochaine séance

Lab 4 : synchroniser

  • une course qui perd des incréments, le verrou qui la répare ;
  • vérifier puis agir : la banque de Fatou et Amadou ;
  • sémaphore, file d’une boulangerie, pipeline ;
  • un interblocage détecté par délai, puis supprimé par un ordre global ;
  • mini-défi : \(5\) accès au plus, compte exact.

Prochaine séance

Séance 4 : architectures distribuées et NUMA. Mémoire locale et distante, répertoire, calcul distribué.

Références : A. Silberschatz et al., Operating System Concepts, chap. 6 à 8 ; E. W. Dijkstra, Cooperating Sequential Processes, EWD 123, 1965 (sémaphores, philosophes) ; C. A. R. Hoare, Communicating Sequential Processes, 1985 (les philosophes) ; E. G. Coffman, M. J. Elphick, A. Shoshani, « System Deadlocks », ACM Computing Surveys 3(2), 1971 ; M. McCool et al., Structured Parallel Programming, 2012 (fork-join, pipeline).