Cadre de travail

Vous utiliserez votre dépôt Git existant OS-Nom-Prenom pour organiser ce TD. L’objectif est de regrouper tous vos travaux pratiques de système dans une structure cohérente.

Organisation attendue :

C-Unix-Thread-Nom-Prenom /
├─ .gitignore
├─ README.md
├─ Makefile               # pour compiler tous les programmes
├─ calcul_sequentiel.c
├─ calcul_parallele.c
├─ compteur_dangereux.c
├─ compteur_securise.c
├─ producteur_consommateur.c
├─ test_mutex.c
├─ test_semaphore.c
├─ cache_rwlock.c
└─ test_rwlock.c

Makefile de base :

CC = gcc
CFLAGS = -Wall -Wextra -pthread -g
LDFLAGS = -pthread

ALL = calcul_sequentiel calcul_parallele compteur_dangereux compteur_securise \
      test_mutex producteur_consommateur test_semaphore cache_rwlock test_rwlock

all: $(ALL)

%: %.c
    $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)

clean:
    rm -f $(ALL)

Parallélisation d’un calcul

Dans cet exercice, vous allez transformer un algorithme séquentiel en version parallèle pour comprendre les bénéfices de la concurrence sur les opérations en lecture seule. L’idée fondamentale est que lorsque plusieurs threads ne font que lire des données partagées sans les modifier, il n’y a aucun problème de concurrence. C’est le scénario idéal du multithreading : diviser le travail sans conflits : maximum de performances !

Le code suivant simule un traitement “intensif” sur un tableau de données. Chaque élément nécessite un calcul “coûteux” en CPU. Dans la version séquentielle, ces calculs se font un par un.

Créez calcul_sequentiel.c :

#include <stdio.h>
#include <stdint.h>
#include <time.h>

#define TAILLE_DONNEES 10
#define MULTIPLICATEUR 1000

static const int DONNEES[TAILLE_DONNEES] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

int main(void) {
    printf("=== Calcul Séquentiel ===\n");

    struct timespec debut, fin;
    clock_gettime(CLOCK_MONOTONIC, &debut);

    int64_t somme_total = 0;

    // TODO: Ce code sera remplacé par une version parallèle
    for (int i = 0; i < TAILLE_DONNEES; i++) {
        int valeur = DONNEES[i];

        // Calcul intensif simulé par des opérations mathématiques
        int64_t resultat = 0;
        for (int j = 0; j < MULTIPLICATEUR; j++) {
            resultat += valeur * valeur + valeur;
            usleep(20);
        }

        somme_total += resultat;
        printf("Traitement de %d terminé : %ld\n", valeur, resultat);
    }

    clock_gettime(CLOCK_MONOTONIC, &fin);
    long duree = (fin.tv_sec - debut.tv_sec) * 1000 + 
                 (fin.tv_nsec - debut.tv_nsec) / 1000000;

    printf("Résultat total : %ld\n", somme_total);
    printf("Durée : %ld ms\n", duree);

    return 0;
}

Observations importantes :

  • Le tableau DONNEES est déclaré const : il ne change jamais
  • Chaque calcul est indépendant : le traitement de l’élément i n’affecte pas l’élément j
  • La variable somme_total accumule les résultats : a faire en dehors des threads, a la fin

Compilation et exécution : make calcul_sequentiel && ./calcul_sequentiel

Structure d’un thread

En C, on utilise, soit les fonctions systeme directement (windows, linux), soit la bibliothèque POSIX threads (pthreads) qui encapsule les appels systèmes pour l’exploitation des ces méchanismes sous-jacent de façon portable. Mais globalement le concept est strictemet identique (modulo une petite simplification de la gestion de la stack).

La création de thread ce fait via :

  • pthread_t : type représentant un thread
  • pthread_create() : pour créer et démarrer un thread (doc), les paramètres sont les suivants :
    • adresse où stocker l’identifiant du thread créé
    • attributs par défaut (priorité, taille de pile, etc.) (souvent NULL)
    • fonction à exécuter de type void* (*)(void*)
    • argument passé à la fonction (notre structure) converti en void*
  • pthread_join() : pour attendre la fin d’un thread (doc)
    • variable d’identifiant du thread a attendre
    • adresse où stocker le pointeur de retour (type void**)
  • Un pointeur de fonction de type void* (*)(void*) : le “programme” du thread

Le concept pour parralléliser ce programme est le suivant :

graph LR A[Thread Principal] -->|pthread_create| B[Thread 1] A -->|pthread_create| C[Thread 2] A -->|pthread_create| D[Thread 3] B -->|calcul| B1[Résultat 1] C -->|calcul| C1[Résultat 2] D -->|calcul| D1[Résultat 3] B1 -->|pthread_join| E[Agrégation] C1 -->|pthread_join| E D1 -->|pthread_join| E E --> F[Résultat final]

Structure de données pour passer les paramètres :

En C, pthread_create() ne peut passer qu’un seul pointeur void* à la fonction thread. Pour passer plusieurs paramètres, on utilise une structure. Vous pouvez décalarer un tableau qui utilise cette structure et passé l’adresse de l’élément i aux thread créé. Voici un exemple :

typedef struct {
    int valeur;
    int multiplicateur;
    int index;
} args_t;

Fonction thread :

La fonction exécutée par le thread doit avoir la signature : void* fonction(void* arg)

void* calcul_thread(void* arg) {
        // cette fonction est appelé via pthread_create
        // vous savez ce que représente @arg
        // vous notifié au compilateur "cette" address dois être considéré comme args_t
    args_t* args = (args_t*)arg;
        // vous pouvez accéder (read/write) a toutes les variables via `args->`
    return NULL;
}

Pourquoi void* partout ? Le type void* est un pointeur générique en C. C’est le mécanisme pour avoir du “polymorphisme” : la fonction peut accepter n’importe quel type de pointeur. À l’intérieur, on fait un cast vers le vrai type. C’est au programmeur de vérifier et de savoir quelle données correspond a ce pointeur !!! Important : Le thread doit pouvoir accéder aux données pendant toute sa durée de vie. Ne passez JAMAIS de pointeur vers une variable locale de la pile qui va disparaître !

Version parallèle

copier calcule_sequentiel.c en calcul_parallele.c

Maintenant vous allez orchestrer plusieurs threads pour diviser le travail. Le principe : au lieu de traiter les 10 éléments en séquence, créer 10 threads qui traitent chacun un élément en parallèle.

Étape 1 : Incluez les headers nécessaires

#include <pthread.h>

Étape 2 : Déclarez la structure et la fonction d’éxécution des threads

Étape 3 : Parallélisé le main() parallèle

int main(void) {
    printf("=== Calcul Parallèle ===\n");

    struct timespec debut, fin;
    clock_gettime(CLOCK_MONOTONIC, &debut);

    // TODO: Créer un tableau de pthread_t
    // TODO: Créer un tableau de structures d'arguments
    // TODO: Créer et démarrer tous les threads
        // TODO : Créer tous les threads
    // TODO: Attendre tous les threads
    // TODO: Agréger les résultats

    clock_gettime(CLOCK_MONOTONIC, &fin);
    long duree = (fin.tv_sec - debut.tv_sec) * 1000 + (fin.tv_nsec - debut.tv_nsec) / 1000000;

    printf("Résultat total : %ld\n", somme_total);
    printf("Durée : %ld ms\n", duree);

    return 0;
}

Questions d’analyse

Après implémentation, répondez à ces questions :

  1. Performance : Quelle version est la plus rapide ? Pourquoi ?
  2. Mesurez : Différentes valeurs pour MULTIPLICATEUR, jusqu’a à 100000000 et observez la différence
  3. Résultats : Les résultats sont-ils identiques entre les versions ? Pourquoi n’y a-t-il pas de race condition ?
  4. Scalabilité : Testez avec différents nombres de threads. Que se passe-t-il si vous créez 100 threads pour 10 valeurs ?

Méthodologie : En principe ce style de benchmark dois être effectué plusieurs fois en prennant quelques statistiques (médiane, moyenne, std). Tous les couples thread_count / multiplicateur devraient être testé pour vérifier l’influence de chaque facteurs. Puis affiché avec un plot ou une heatmap afin de choisir le meilleur compromis pour un matériel cible (nombre de coeur). Il existe toute fois des rêgles basic, on consière globalement que le nombre de thread optimal est entre 2 et 8 threads par coeur physique. Le facteur change en fonction de la vitesse d’éxécution de la fonction à éxécuter ou encore du nombre d’I/O effectué (qui mettent le thread en pause).


Exemple d’un calcule sur un système comportant deux coeurs.

Race conditions

Ici le but est de démontrer que les écritures concurrentes sur des variables partagées créent des comportements imprévisibles appelés race conditions. Contrairement à l’exercice précédent où chaque thread avait ses propres données, ici tous les threads modifient la même variable. C’est le scénario classique qui génère des bugs de concurrence.

Créez compteur_dangereux.c : C’est le code de base problématique

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define INCREMENT 11111111
#define THREAD_COUNT 99

static long long compteur_global = 0;  // Variable partagée MODIFIABLE et MODIFIÉ

void* incrementeur_thread(void* arg) {
    for (int i = 0; i < INCREMENT; i++) {
        // TODO: Expliquer pourquoi, dans le détail, cette ligne qui pose problème
        compteur_global++;  // RACE CONDITION ICI !
    }
    return NULL;
}

int main(void) {
    printf("=== Compteur Dangereux ===\n");
        long long expected = INCREMENT*THREAD_COUNT;

    pthread_t threads[THREAD_COUNT];

    for (int i = 0; i < THREAD_COUNT; i++)
        pthread_create(&threads[i], NULL, incrementeur_thread, NULL);

    for (int i = 0; i < THREAD_COUNT; i++)
        pthread_join(threads[i], NULL);

    printf("Valeur attendue : %ld\n", expected);
    printf("Résultat final : %ld\n", compteur_global);
    printf("Différence : %ld\n", expected - compteur_global);

    return 0;
}

Le but est de tester ce code plusieurs fois, qui devrait révéler le non-déterminisme des race conditions. Plus vous créez de contention (threads nombreux + opérations nombreuses), plus vous avez de coeurs utilisé (peut-être pas sur les vm), plus les erreurs apparaissent.

Étape 1 : Créez une fonction pour réinitialiser et tester. Il faudra également passé en allocation dynamique et utiliser un structure pour passé les arguments aux threads.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

static int compteur_global = 0;

void reset_compteur(void) {
    compteur_global = 0;
}

int get_compteur(void) {
    return compteur_global;
}

// ... (même fonction incrementeur_thread que compteur_dangereux.c)

int executer_test(int nb_threads, int nb_increments) {
    reset_compteur();

    pthread_t* threads = malloc(nb_threads * sizeof(pthread_t));
        // Instancier un tableau de structure pour les arguments.

    // Création des threads
    for (int i = 0; i < nb_threads; i++) 
        pthread_create(&threads[i], NULL, incrementeur_thread, NULL);

    for (int i = 0; i < nb_threads; i++)
        pthread_join(threads[i], NULL);

    int resultat = get_compteur();

    // Qu'est ce qui a été oublié ici ?

    return resultat;
}

Étape 2 : Boucle de test avec statistiques

int main(void) {
    const int NB_TESTS = 50;
    const int NB_THREADS = 9;
    const int NB_INCREMENTS = 1111111;
    const int ATTENDU = NB_THREADS * NB_INCREMENTS;

    printf("=== Test Race Condition ===\n");
    printf("Configuration : %d threads × %d incréments\n", NB_THREADS, NB_INCREMENTS);
    printf("Valeur attendue : %d\n\n", ATTENDU);

    int nb_corrects = 0;
    int min = ATTENDU;
    int max = 0;

    for (int test = 0; test < NB_TESTS; test++) {
        int resultat = executer_test(NB_THREADS, NB_INCREMENTS);

        if (resultat == ATTENDU) {
            nb_corrects++;
            printf("Test %2d : %d v \n", test + 1, resultat);
        } else {
            printf("Test %2d : %d x (différence: %d)\n", test + 1, resultat, ATTENDU - resultat);
        }

        if (resultat < min) min = resultat;
        if (resultat > max) max = resultat;
    }

    printf("\n=== Statistiques ===\n");
    printf("Tests corrects : %d/%d (%.1f%%)\n", 
           nb_corrects, NB_TESTS, (100.0 * nb_corrects) / NB_TESTS);
    printf("Valeur min : %d (perte de %d)\n", min, ATTENDU - min);
    printf("Valeur max : %d\n", max);

    return 0;
}

Observation typique : Vous devriez voir des résultats variables, souvent inférieurs à 9999999. Plus il y a de threads, plus les “pertes” sont fréquentes.

Expérimentations suggérées :

  1. Changez le nombre de threads (1, 2, 5, 10, 20, 40, 60, 100)
    - Impact sur la fréquence des race conditions ?
    - Avec 1 thread, devrait toujours être correct
  2. Changez le nombre d’incréments par thread
    - Plus d’opérations = plus de problèmes ?
  3. Compilez avec optimisations : gcc -O2 -pthread test_compteur.c
    - Les optimisations peuvent aggraver ou masquer le problème !

Protection avec Mutex

Un mutex (mutual exclusion) garantit qu’un seul thread peut exécuter une section critique à la fois. En C avec pthreads, on utilise pthread_mutex_t. Avant d’entrer dans une section critique, un thread doit acquérir le verrou avec pthread_mutex_lock(). Si ce verrou est déjà détenu par un autre thread, le thread courant est bloqué jusqu’à ce que le verrou soit libéré. Lorsque le thread propriétaire quitte la section critique, il libère le verrou avec pthread_mutex_unlock(), autorisant ainsi un autre thread en attente à acquérir le mutex.

graph TD A[Thread arrive] --> B{Mutex libre ?} B -->|Oui| C[Acquiert le mutex] B -->|Non| D[Bloque et attend] D --> B C --> E[Section critique] E --> F[Libère le mutex] F --> G[Continue] D -.->|Un autre thread libère| B

TODO : Créez compteur_securise.c :

Étape 1 : Reprenez le code de compteur_dangereux.c comme base

Étape 2 : Ajoutez un mutex pour protéger le compteur. Il y a deux façons d’initialiser un mutex :

  1. Statique (pour variables globales) :
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  1. Dynamique (pour malloc ou attributs personnalisés) :
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
// ... utilisation ...
pthread_mutex_destroy(&mutex);

Étape 3 : Protégez les sections critiques

Même les lectures doivent être protégées ! Sur certaines architectures (32-bit lisant un int64_t, par exemple), une lecture peut ne pas être atomique. Le thread pourrait lire une valeur “à moitié écrite” par un autre thread. Règle d’or : Si une variable est écrite par plusieurs threads, toute lecture ET écriture doit être protégée par le même mutex. Au risque de lire des données inconsistantes.

Étape 4 : Créez test_mutex.c en reprenant exactement la même logique que test_compteur.c

Observation attendue : Vous devriez maintenant toujours obtenir le résultat attendu (9999999). Plus de variabilité, plus de “pertes”. La version synchronisée sera plus lente car les threads doivent attendre leur tour au lieu de travailler en parallèle. C’est le compromis sécurité/performance.

Bonnus proposé un benchmark avec une solution reposant sur fork

Read-Write Lock (pthread_rwlock_t)

Les mutex sont trop restrictifs pour certains cas d’usage. Imaginez un cache de données :

  • Lectures : Très fréquentes (90% des accès), peuvent se faire en parallèle sans danger
  • Écritures : Rares (10% des accès), nécessitent exclusivité

Avec un mutex classique, même les lectures se bloquent mutuellement alors qu’elles pourraient être parallèles !

Solution : Les read-write locks (verrous lecteur-écrivain) permettent :

  • Plusieurs lecteurs simultanés : Tant qu’aucun écrivain n’est actif
  • Un seul écrivain exclusif : Bloque tous les lecteurs et autres écrivains
  • Des méchanismes internes permettent d’assurer que tous les lectures et écritures sont effectué.

Pour la doc -> pthread_rw_lockl

stateDiagram-v2 [*] --> Libre Libre --> Lecteurs : read_lock() Libre --> Écrivain : write_lock() Lecteurs --> Lecteurs : read_lock()<br/>(plusieurs OK) Lecteurs --> Libre : tous unlock() Lecteurs --> Bloqué : write_lock() attend Écrivain --> Libre : write_unlock() Écrivain --> Bloqué : read_lock() ou write_lock() Bloqué --> Lecteurs : écrivain termine Bloqué --> Écrivain : lecteurs terminent

TODO : Créez cache_rwlock.c pour simuler un cache avec accès lecture/écriture

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <time.h>

#define TAILLE_CACHE 100
static int cache[TAILLE_CACHE];

// Initialiser le cache
void init_cache(void) {
    for (int i = 0; i < TAILLE_CACHE; i++) {
        cache[i] = i * 10;
    }
}

// Lecture du cache (plusieurs lecteurs possibles en parallèle)
int lire_cache(int index) {
    if (index < 0 || index >= TAILLE_CACHE) return -1;

        // Protégez cett esection
    int valeur = cache[index];
    usleep(100); 

    return valeur;
}

// Écriture du cache (exclusif, bloque tout)
void ecrire_cache(int index, int valeur) {
    if (index < 0 || index >= TAILLE_CACHE) return;

        // Protégez cett esection
    cache[index] = valeur;
    usleep(500);  // 0.5 ms
}

// Thread lecteur (90% du traffic)
void* thread_lecteur(void* arg) {
    int id = *(int*)arg;

    for (int i = 0; i < 100; i++) {
        int index = rand() % TAILLE_CACHE;
        int valeur = lire_cache(index);

        if (i % 20 == 0) {  // Affichage occasionnel
            printf("[Lecteur %d] cache[%d] = %d\n", id, index, valeur);
        }
    }

    printf("[Lecteur %d] terminé\n", id);
    return NULL;
}

// Thread écrivain (10% du traffic)
void* thread_ecrivain(void* arg) {
    int id = *(int*)arg;

    for (int i = 0; i < 10; i++) {
        int index = rand() % TAILLE_CACHE;
        int valeur = rand() % 1000;
        ecrire_cache(index, valeur);

        printf("[Écrivain %d] cache[%d] = %d (mise à jour)\n", id, index, valeur);
        usleep(1000);  // Les écritures sont moins fréquentes
    }

    printf("[Écrivain %d] terminé\n", id);
    return NULL;
}

int main(void) {
    srand(time(NULL));
    init_cache();

    printf("=== Test Read-Write Lock ===\n\n");

    struct timespec debut, fin;
    clock_gettime(CLOCK_MONOTONIC, &debut);

    // Créer 8 lecteurs et 2 écrivains
    pthread_t lecteurs[8];
    pthread_t ecrivains[2];
    int ids_lecteurs[8];
    int ids_ecrivains[2];

    // Démarrer les lecteurs
    for (int i = 0; i < 8; i++) {
        ids_lecteurs[i] = i + 1;
        pthread_create(&lecteurs[i], NULL, thread_lecteur, &ids_lecteurs[i]);
    }

    // Démarrer les écrivains
    for (int i = 0; i < 2; i++) {
        ids_ecrivains[i] = i + 1;
        pthread_create(&ecrivains[i], NULL, thread_ecrivain, &ids_ecrivains[i]);
    }

    // Attendre tout le monde
    for (int i = 0; i < 8; i++) {
        pthread_join(lecteurs[i], NULL);
    }
    for (int i = 0; i < 2; i++) {
        pthread_join(ecrivains[i], NULL);
    }

    clock_gettime(CLOCK_MONOTONIC, &fin);
    long duree = (fin.tv_sec - debut.tv_sec) * 1000 + (fin.tv_nsec - debut.tv_nsec) / 1000000;
    printf("\nDurée totale : %ld ms\n", duree);

    pthread_rwlock_destroy(&rwlock_cache);
    return 0;
}

Créez test_rwlock.c et comparer mutex vs rwlock :

// TODO: Créer deux versions du test ci-dessus
// 1. Avec pthread_mutex_t (toutes les lectures se bloquent)
// 2. Avec pthread_rwlock_t (lectures parallèles)
// Vous pouvez utiliser la compilation conditionel
// Comparer les performances

MPMC

Les sémaphores généralisent les mutex : au lieu de permettre 1 seul accès simultané, ils permettent N accès simultanés. Cette exercice est un pattern classique de la programmation concurrente, réutilisable dans de nombreux contextes (pools de ressources, pipelines de traitement, impression, etc.).

Les sémaphore sont une sorte de système de jetons :

  • Le sémaphore possède un certain nombre de jetons initiale.
  • Chaque thread qui veut accéder à la ressource mange un jeton (sem_wait).
  • Quand il a fini, il rend le jeton (sem_post).

sem_wait(&sem) : prendre un jeton voir doc

  • Si le compteur > 0 : décrémente le compteur, le thread continue immédiatement
  • Si le compteur = 0 : aucun jeton disponible, le thread est mis en attente (bloqué), il se réveillera lorsqu’un sem_post sera effectué par un autre thread
  • sem_wait est atomique : aucune interférence possible entre la vérification et la décrémentation.

sem_post(&sem) : rendre/produit un jeton voir doc

  • Incrémente le compteur
  • Si des threads sont bloqués en attente, réveille un thread en attente
  • sem_post ne bloque jamais.

Il existent d’autres fonction intéréssantes telque sem_trywait qui n’est pas bloquant (retour 0 ou 1 si le jeton est prit) et sem_timedwait qui bloque pendent un quantum de temps.

Attention : sémaphore binaire ≠ mutex Bien qu’un sémaphore initialisé à 1 se comporte comme un mutex, c’est une mauvaise pratique de l’utiliser ainsi : Pas d’optimisations mutex (priority inheritance, etc.) ! Règle : Utilisez un mutex pour l’exclusion mutuelle, un sémaphore pour compter des ressources.

Contexte : Un buffer de taille limitée partagé entre :

  • Producteurs : Ajoutent des éléments (bloqués si buffer plein)
  • Consommateurs : Retirent des éléments (bloqués si buffer vide)

Défis de synchronisation :

  1. Éviter que les producteurs remplissent un buffer plein : contrôle de flux
  2. Éviter que les consommateurs vident un buffer vide : synchronisation production / consommation
  3. Protéger l’accès concurrent au buffer : exclusion mutuelle sur la structure de données

Architecture de la solution : Nous utiliserons des sémaphores pour résoudre ces problèmes indépendamment.

graph TB subgraph Buffer B[Tableau circulaire] end subgraph Sémaphores S1[places_libres<br/>init=N] S2[elements_dispos<br/>init=0] S3[mutex_buffer<br/>init=1] end P[Producteur] -->|wait places_libres| S1 P -->|wait mutex| S3 P -->|ajoute élément| B P -->|post mutex| S3 P -->|post elements_dispos| S2 C[Consommateur] -->|wait elements_dispos| S2 C -->|wait mutex| S3 C -->|retire élément| B C -->|post mutex| S3 C -->|post places_libres| S1

TODO : Créez producteur_consommateur.c:

Étape 1 : Déclarez les sémaphores : Les sémaphores s’équilibrent tel que places_libres + elements_disponibles = TAILLE_BUFFER (toujours vrai !)

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define TAILLE_BUFFER 5

// Buffer circulaire
static int buffer[TAILLE_BUFFER];
static int index_prod = 0;
static int index_cons = 0;

// TODO: Déclarer 3 sémaphores avec les bonnes valeurs initiales viq sem_init
// Le deuxième paramètre `0` signifie "partagé entre threads du même processus" (vs `1` pour inter-processus).

static sem_t places_libres;
static sem_t elements_disponibles;  
static sem_t mutex_buffer; // Qulle est le soucis ici ?

Étape 2 : Implémentez les fonctions de manipulation du buffer

void ajouter_buffer(int produit) {
    // Quelle soucis peut on imaginer ici ?
    buffer[index_prod] = produit;
    index_prod = (index_prod + 1) % TAILLE_BUFFER;  // Circulaire
}

int retirer_buffer(void) {
    // Quelle soucis peut on imaginer ici ?
    int produit = buffer[index_cons];
    index_cons = (index_cons + 1) % TAILLE_BUFFER;  // Circulaire
    return produit;
}

Étape 3 : Implémentez le thread producteur

void* thread_producteur(void* arg) {
    int id = *(int*)arg;

    for (int i = 0; i < 10; i++) {
        int produit = id * 100 + i;  // Produit unique

        // TODO: 1. Attendre une place libre
        // TODO: 2. Ajouter l'élément au buffer

        ajouter_buffer(produit);

        printf("[Producteur %d] Ajouté : %d (index=%d)\n", 
               id, produit, (index_prod - 1 + TAILLE_BUFFER) % TAILLE_BUFFER);

        // TODO: 3. Signaler qu'un élément est disponible
        usleep(50);
    }

    printf("[Producteur %d] terminé (10 produits)\n", id);
    return NULL;
}

Ordre crucial des sem_wait() ! Si le buffer est plein et qu’on bloque sur places_libres après avoir pris le mutex, les consommateurs ne peuvent pas accéder au buffer pour libérer des places → deadlock ! Règle : Toujours sem_wait() sur les sémaphores de ressources avant les mutex.

Étape 4 : Implémentez le thread consommateur

void* thread_consommateur(void* arg) {
    int id = *(int*)arg;

    for (int i = 0; i < 7; i++) {
        // TODO: 1. Attendre qu'un élément soit disponible
        // TODO: 2. Retirer un élément du buffer
        int produit = retirer_buffer();

        printf("[Consommateur %d] Retiré : %d (index=%d)\n", 
               id, produit, (index_cons - 1 + TAILLE_BUFFER) % TAILLE_BUFFER);

        // TODO: 3. Signaler qu'une place est libre
        sem_post(&places_libres);

        usleep(50);
    }

    printf("[Consommateur %d] terminé (7 produits)\n", id);
    return NULL;
}

Symétrie : Le consommateur fait exactement l’inverse du producteur. Chaque sem_wait() du producteur correspond à un sem_post() du consommateur et vice-versa.

Étape 5 : Implémentez le main avec tests

int main(void) {
    srand(time(NULL));

    // Initialisation des sémaphores

    printf("=== Producteur-Consommateur ===\n");
    printf("Taille buffer : %d\n\n", TAILLE_BUFFER);

    // Scénario : 2 producteurs × 10 = 20 produits
    //            3 consommateurs × 7 = 21 demandes
    // → 1 consommateur va bloquer indéfiniment !

    pthread_t producteurs[2];
    pthread_t consommateurs[3];
    int ids_prod[2] = {1, 2};
    int ids_cons[3] = {1, 2, 3};

    // Démarrage
    for (int i = 0; i < 2; i++)
        pthread_create(&producteurs[i], NULL, thread_producteur, &ids_prod[i]);

    for (int i = 0; i < 3; i++)
        pthread_create(&consommateurs[i], NULL, thread_consommateur, &ids_cons[i]);

    // Attente des producteurs
    for (int i = 0; i < 2; i++)
        pthread_join(producteurs[i], NULL);

    printf("\n  Tous les producteurs terminés !\n");
    printf("    Un consommateur va bloquer car 3×7=21 > 2×10=20\n\n");

    // Attendre 2 secondes puis annuler les consommateurs bloqués
    sleep(2);

    for (int i = 0; i < 3; i++) {
        pthread_cancel(consommateurs[i]);
    }
    for (int i = 0; i < 3; i++) {
        pthread_join(consommateurs[i], NULL);
    }

    // Nettoyage
    sem_destroy(&places_libres);
    sem_destroy(&elements_disponibles);
    sem_destroy(&mutex_buffer);

    return 0;
}

TODO : Créez test_semaphore.c avec différents scénarios :

// Scénario 1 : Équilibré (production = consommation)
// 3 producteurs × 10 = 30 produits
// 5 consommateurs × 6 = 30 consommations

// Scénario 2 : Producteurs plus rapides
// 5 producteurs × 10 = 50 produits
// 2 consommateurs × 10 = 20 consommations

// Scénario 3 : Consommateurs plus rapides  
// 1 producteur × 50 = 50 produits
// 10 consommateurs × 10 = 100 consommations

// TODO: Implémenter ces 3 scénarios et observer les comportements