Philosophers · 42 // TECHNICAL GUIDE
SYS:ONLINE REC // 04
Projet 42 · Branche Système · Concurrency

Philosophers

// Le dining philosophers problem — threads, mutex, sémaphores et processus, sans qu'un seul ne meure de faim.

Cinq philosophes sont assis autour d'une table ronde. Entre chacun, une fourchette. Pour manger, il en faut deux. Pour penser, il faut avoir mangé. Pour dormir, il faut avoir fini de manger. Si l'un d'eux attend trop longtemps, il meurt. Ce guide décortique trois implémentations — philo (threads + mutex), philo_two (threads + sémaphore), philo_bonus (processus + sémaphore) — et les bugs subtils qui se cachent dans chacune.

Difficulté
★★★★☆
Temps estimé
50 – 100 h
Fonctions
pthread_* · sem_* · fork · waitpid
Sortie attendue
philo · philo_bonus
// Contrainte clé

Aucun philosophe ne doit mourir si la logique le permet. Aucun data race (vérifié avec ThreadSanitizer). Aucun leak (vérifié avec AddressSanitizer). Aucune variable globale pour gérer les ressources partagées. Le délai entre la mort réelle d'un philosophe et l'affichage de sa mort doit être inférieur à 10ms. La simulation s'arrête dès qu'un philosophe meurt — un seul message "died" doit s'afficher.

« Nous sommes tous assis à la même table. Certains mangent, d'autres pensent, d'autres dorment. Mais si l'un de nous attend trop longtemps sa part, le silence tombe — et avec lui, tout s'arrête. »
— Journal d'observation, Bunker YoRHa
01

Le projet

Philosophers résout le problème classique du dining philosophers formulé par Edsger Dijkstra en 1965. N philosophes sont assis autour d'une table ronde avec N fourchettes (une entre chaque paire de voisins). Chaque philosophe alterne entre trois états : manger (nécessite 2 fourchettes), dormir et penser. Si un philosophe ne commence pas à manger dans les time_to_die millisecondes après son dernier repas, il meurt — et la simulation s'arrête immédiatement.

Le sujet 42 exige deux programmes : philo (mandatory, threads + mutex) et philo_bonus (bonus, processus + sémaphore). On a en plus implémenté philo_two (threads + sémaphore) comme variante pédagogique. Les trois programmes acceptent les mêmes arguments : ./philo nb_philo time_to_die time_to_eat time_to_sleep [must_eat] — tous les temps en millisecondes, must_eat optionnel.

Exigences du sujet

CritèreExigenceDétail
États3 exclusifsmanger / dormir / penser — jamais simultanés
Fourchettes1 entre chaque philoN fourchettes pour N philosophes
Manger2 fourchettes requisesUne dans chaque main, gardées pendant time_to_eat
Morttime_to_die strictSi pas de repas dans time_to_die ms → meurt
AffichageFormat stricttimestamp_in_ms X has taken a fork / is eating / is sleeping / is thinking / died
ThreadsafePas de mélangeLes messages ne doivent jamais être entrelacés
Délai mort< 10msEntre la mort réelle et l'affichage de "died"
Arrêt1 mort = stopSimulation s'arrête au premier décès
must_eatOptionnelSi tous mangent N fois → stop sans mort
NormeNorminette0 erreur, 0 warning -Wall -Wextra -Werror
GlobalesInterditesPas de variable globale pour les ressources partagées
Sanitizers0 data race, 0 leakHelgrind / ThreadSanitizer / AddressSanitizer

Fonctions autorisées

// philo (mandatory)
memset, printf, malloc, free, write, usleep, gettimeofday, pthread_create, pthread_detach, pthread_join, pthread_mutex_init, pthread_mutex_destroy, pthread_mutex_lock, pthread_mutex_unlock
// philo_bonus
Idem + fork, kill, exit, waitpid, sem_open, sem_close, sem_post, sem_wait, sem_unlink
// Métaphore YoRHa

Chaque philosophe est un androïde YoRHa. La table est le Bunker. Les fourchettes sont les pods tactiques — il en faut deux pour exécuter une mission (manger). Le time_to_die est le délai avant épuisement de la batterie. La mort d'un seul androïde met fin à la mission entière. La partie bonus modélise chaque androïde comme un processus séparé (processus lourd plutôt que thread léger), ce qui isole mieux les défaillances mais coûte plus cher en ressources.

02

Threads & mutex

Avant de coder la moindre ligne, il faut comprendre trois concepts fondamentaux de la programmation concurrente : les threads (processus légers partageant la même mémoire), les mutex (verrous pour protéger l'accès concurrent à une variable), et le deadlock (interblocage quand deux threads attendent mutuellement que l'autre libère une ressource).

pthread_create / pthread_join

Un thread est une unité d'exécution qui partage la mémoire virtuelle du processus qui le crée. Contrairement à fork() qui duplique toute la mémoire, pthread_create crée un nouveau flot d'instructions qui voient les mêmes variables globales, les mêmes pointeurs heap, les mêmes descripteurs de fichiers. C'est rapide (microsecondes) mais dangereux : sans précaution, deux threads peuvent écrire en même temps au même endroit et corrompre les données.

pthread_basics.c// create + join
/* Créer un thread qui exécute ph_routine avec philo en argument */
if (pthread_create(&philo->thread, NULL,
                ph_routine, philo) != 0)
        return (ph_error("thread create failed"));

/* Attendre que le thread se termine avant de continuer */
pthread_join(philo->thread, NULL);

/* pthread_detach : alternative non-bloquante, le thread se nettoie tout seul
   à sa fin. Mais on ne peut plus le join. Pour Philosophers, on utilise join. */

pthread_create prend 4 arguments : (1) l'adresse du pthread_t à initialiser, (2) les attributs (NULL = défaut), (3) la fonction à exécuter (signature void *f(void *arg)), et (4) l'argument à passer à la fonction. Le thread démarre immédiatement et s'exécute en parallèle du thread appelant.

pthread_join bloque le thread appelant jusqu'à ce que le thread cible se termine. C'est essentiel pour éviter que le main ne se termine avant les threads enfants (ce qui tuerait immédiatement tous les threads). On join tous les threads philosophes + le thread monitor avant de nettoyer les ressources.

Mutex & data race

Un mutex (mutual exclusion) est un verrou qui garantit qu'un seul thread à la fois peut exécuter une section critique. Sans mutex, si deux threads incrémentent i++ simultanément, l'un peut lire la valeur pendant que l'autre la modifie, et l'incrément final sera 1 au lieu de 2 — c'est un data race.

mutex_basics.c// lock / unlock
/* Initialiser un mutex (à faire une seule fois avant utilisation) */
pthread_mutex_init(&mutex, NULL);

/* Verrouiller avant la section critique */
pthread_mutex_lock(&mutex);
shared_counter++;              /* safe : aucun autre thread ne peut entrer ici */
pthread_mutex_unlock(&mutex);

/* Détruire le mutex (à faire une seule fois à la fin) */
pthread_mutex_destroy(&mutex);
// Data race — le piège invisible

Un data race ne provoque ni crash ni message d'erreur — il corrompt silencieusement les données. Le programme peut fonctionner 99 fois sur 100 et échouer la 100ème fois, ou pire, produire des résultats subtilement erronés qu'aucun test ne détectera. C'est pour ça que le sujet impose ThreadSanitizer : sans lui, vous ne verrez pas les races. Compilez avec -fsanitize=thread -g et lancez les tests — toute race détectée signifie un 0 à l'évaluation.

Dans Philosophers, on utilise des mutex pour protéger trois types de ressources partagées : (1) chaque fourchette a son mutex — un philosophe doit le locker pour "prendre" la fourchette, (2) un mutex print global pour éviter que les messages de plusieurs philosophes ne s'entrelacent, (3) un mutex meal par philosophe pour protéger last_meal et meals_eaten contre les accès concurrents entre le thread philosophe et le thread monitor.

Deadlock & ordre asymétrique

Le deadlock est le bug le plus célèbre de Philosophers. Imaginez 5 philosophes qui, au même instant, prennent tous leur fourchette gauche. Aucun ne peut prendre la droite (elle est déjà prise par le voisin). Ils attendent tous indéfiniment. La simulation se bloque sans qu'aucun ne meure — pire que la mort, c'est la paralysie.

// Deadlock classique — tous prennent gauche d'abord
Table ronde, 5 philosophes (P1..P5), 5 fourchettes (F1..F5) P1 / \ F5 F1 / \ P5 P2 \ / F4 F2 \ / P3 | F3 (entre P3 et P4 — pas affiché pour clarté) Scénario deadlock : T=0 : P1 prend F1 (gauche) T=0 : P2 prend F2 (gauche) T=0 : P3 prend F3 (gauche) T=0 : P4 prend F4 (gauche) T=0 : P5 prend F5 (gauche) T=0+ : P1 veut F5 → bloqué (P5 la tient) T=0+ : P2 veut F1 → bloqué (P1 la tient) T=0+ : P3 veut F2 → bloqué T=0+ : P4 veut F3 → bloqué T=0+ : P5 veut F4 → bloqué Résultat : DEADLOCK — personne ne mange, personne ne meurt La simulation se fige pour l'éternité. Solution : ordre asymétrique Le code assigne left_fork/right_fork différemment selon la parité. Comme ph_take_forks prend TOUJOURS left_fork d'abord : - Pairs : left_fork = forks[id-1] → prend la classique droite d'abord - Impairs : left_fork = forks[id%N] → prend la classique gauche d'abord → Casse le cycle : P3 et P4 veulent F3 en premier → un seul la tient → l'autre bloque sans rien tenir → pas de cycle d'attente circulaire.

Notre implémentation utilise cette technique : via ph_assign_forks, les paires se voient assigner left_fork = forks[id-1] (la classique droite) et les impairs left_fork = forks[id%N] (la classique gauche). Comme ph_take_forks prend toujours left_fork d'abord, les paires et impairs prennent des premières fourchettes différentes, cassant le cycle circulaire du deadlock. C'est plus simple que la solution "philosophe arbitre" (un sémaphore global à N-1) et tout aussi efficace.

philo/srcs/init.c// ordre asymétrique
static void        ph_assign_forks(t_philo *philo, t_rules *rules)
{
        if (philo->id % 2 == 0)
        {
                /* Pair : left_fork = forks[id-1] (classique droite) */
                philo->left_fork = &rules->forks[philo->id - 1];
                philo->right_fork = &rules->forks[philo->id % rules->nb_philo];
        }
        else
        {
                /* Impair : left_fork = forks[id%N] (classique gauche) */
                philo->left_fork = &rules->forks[philo->id % rules->nb_philo];
                philo->right_fork = &rules->forks[philo->id - 1];
        }
}
// Alternative : décalage temporel

Une autre technique courante est de faire attendre les philosophes pairs au démarrage (if (id % 2 == 0) usleep(time_to_eat / 2)). Cela laisse le temps aux impairs de prendre leurs fourchette et de manger, évitant que tout le monde ne se précipite en même temps. On combine les deux dans notre implémentation pour une robustesse maximale.

03

Architecture du code

Le projet suit la structure 42 standard : un dossier par programme, chaque dossier contient includes/ (headers), srcs/ (sources C), un Makefile et un fichier author. Les trois programmes partagent la même architecture interne : mainparseinitstartcleanup, avec une séparation claire des responsabilités.

// Arborescence du projet
philosophers/ ├── philo/ # MANDATORY — threads + mutex │ ├── Makefile │ ├── author │ ├── includes/ │ │ └── philo.h # structures t_rules, t_philo + prototypes │ └── srcs/ │ ├── main.c # entry point : parse → init → start → cleanup │ ├── error.c # ph_error() — printf sur stdout (sauf philo_bonus qui utilise write/stderr) │ ├── parse.c # ph_parse_args() — validation argc/argv │ ├── init.c # ph_init() — mutex + philos + start_time │ ├── philo.c # ph_routine() — boucle du philosophe │ ├── monitor.c # ph_monitor() — détection mort + repas finis │ ├── print.c # ph_print_status() — mutex print │ ├── run.c # ph_start() + ph_cleanup() │ └── utils.c # timestamp, usleep, check_stop, set_stop │ ├── philo_two/ # VARIANTE — threads + sémaphore │ └── (même structure, semaphore au lieu de mutex par fourchette) │ ├── philo_bonus/ # BONUS — processus + sémaphore │ ├── Makefile │ ├── author │ ├── includes/ │ │ └── philo.h │ └── srcs/ │ ├── main.c │ ├── error.c │ ├── parse.c │ ├── init.c # sémaphores nommés sem_open │ ├── philo.c # ph_routine() appelé après fork() │ ├── monitor.c # monitor thread par processus enfant │ ├── print.c # write() au lieu de printf (buffering fork) │ ├── run.c # fork + supervisor + kill + waitpid │ └── utils.c │ └── docs/ └── index.html # ce guide (GitHub Pages)

Flux d'exécution

// Flux principal philo (mandatory)
main(argc, argv) │ ▼ ph_parse_args ────▶ valide argc (5 ou 6), argv tous digits, valeurs >= 0 │ range : nb_philo [1, 200], times >= 0, must_eat >= 0 ▼ ph_init ──────────▶ ph_init_mutexes (print + stop + N forks) │ ph_init_philos (N structs, meal_mutex chacun) │ gettimeofday → start_time ▼ ph_start ─────────▶ ph_create_threads (N threads philosophes + 1 monitor) │ ph_join_threads (attente fin) ▼ ph_cleanup ───────▶ ph_destroy_mutexes (N forks + N meal + print + stop) free(forks), free(philos) Pendant l'exécution : - Chaque thread philo exécute ph_routine (take_forks → eat → sleep → think) - Le thread monitor scrute last_meal toutes les 200µs - Si un philo dépasse time_to_die → set_stop + print "died" - Si must_eat atteint par tous → set_stop (arrêt propre)
// Séparation des responsabilités

Chaque fichier a un rôle unique : parse.c ne fait que valider, init.c ne fait qu'initialiser, philo.c contient la logique métier (routine), monitor.c la surveillance, print.c l'affichage thread-safe, run.c l'orchestration (create + join + cleanup), utils.c les utilitaires (timestamp, usleep). Cette séparation facilite le debug et respecte la règle norminette de max 5 fonctions par fichier.

04

philo — threads + mutex

Le programme mandatory utilise un thread par philosophe et un mutex par fourchette. C'est l'implémentation la plus directe du problème : chaque fourchette est une ressource critique, chaque philosophe est un flot d'exécution indépendant, et le mutex garantit qu'une fourchette ne peut être tenue que par un philosophe à la fois.

Structures de données

philo/includes/philo.h// structures
typedef struct s_rules     t_rules;
typedef struct s_philo      t_philo;

struct s_rules
{
        int                         nb_philo;
        int                         time_to_die;
        int                         time_to_eat;
        int                         time_to_sleep;
        int                         must_eat;       /* -1 si non spécifié */
        int                         stop;           /* flag mort/fin, protégé par stop_mutex */
        long                        start_time;     /* ms depuis epoch, fixé une fois au démarrage */
        pthread_mutex_t print_mutex;    /* protège printf contre entrelacement */
        pthread_mutex_t stop_mutex;      /* protège le flag stop */
        pthread_mutex_t *forks;          /* tableau de N mutex, un par fourchette */
};

struct s_philo
{
        int                         id;              /* 1 à N */
        int                         meals_eaten;     /* compteur pour must_eat */
        long                        last_meal;       /* timestamp du dernier repas, protégé par meal_mutex */
        pthread_t               thread;          /* identifiant du thread */
        pthread_mutex_t meal_mutex;      /* protège last_meal et meals_eaten contre data race */
        pthread_mutex_t *left_fork;      /* pointeur vers mutex fourchette gauche */
        pthread_mutex_t *right_fork;     /* pointeur vers mutex fourchette droite */
        t_rules                 *rules;          /* pointeur vers règles partagées */
};

La structure t_rules contient les paramètres de la simulation (lus depuis argv), l'état global (stop), et les mutex partagés. La structure t_philo contient l'état d'un philosophe individuel. Chaque philosophe a son propre meal_mutex pour protéger last_meal et meals_eaten — sans lui, le thread monitor pourrait lire last_meal pendant que le thread philosophe l'écrit, créant un data race détecté par ThreadSanitizer.

Init et forks asymétriques

philo/srcs/init.c// ph_init
static int ph_init_mutexes(t_rules *rules)
{
        int i;

        if (pthread_mutex_init(&rules->print_mutex, NULL) != 0)
                return (ph_error("mutex init failed"));
        if (pthread_mutex_init(&rules->stop_mutex, NULL) != 0)
                return (ph_error("mutex init failed"));
        rules->forks = malloc(sizeof(pthread_mutex_t) * rules->nb_philo);
        if (rules->forks == NULL)
                return (ph_error("malloc failed"));
        i = 0;
        while (i < rules->nb_philo)
        {
                if (pthread_mutex_init(&rules->forks[i], NULL) != 0)
                        return (ph_error("mutex init failed"));
                i++;
        }
        return (0);
}

static void ph_assign_forks(t_philo *philo, t_rules *rules)
{
        /* Ordre asymétrique pour éviter le deadlock (voir section 02) */
        if (philo->id % 2 == 0)
        {
                philo->left_fork = &rules->forks[philo->id - 1];
                philo->right_fork = &rules->forks[philo->id % rules->nb_philo];
        }
        else
        {
                philo->left_fork = &rules->forks[philo->id % rules->nb_philo];
                philo->right_fork = &rules->forks[philo->id - 1];
        }
}

On alloue un tableau de N mutex (un par fourchette). La fourchette i est partagée entre le philosophe i (qui la considère comme sa gauche) et le philosophe i+1 (qui la considère comme sa droite, modulo N pour la table circulaire). L'assignation asymétrique selon la parité de l'ID casse le cycle du deadlock.

Routine du philosophe

philo/srcs/philo.c// ph_routine
static void        ph_take_forks(t_philo *philo)
{
        pthread_mutex_lock(philo->left_fork);
        ph_print_status(philo, "has taken a fork", 0);
        if (philo->rules->nb_philo == 1)
        {
                ph_usleep(philo->rules->time_to_die + 10);
                pthread_mutex_unlock(philo->left_fork);
                return ;
        }
        pthread_mutex_lock(philo->right_fork);
        ph_print_status(philo, "has taken a fork", 0);
}

static void ph_eat(t_philo *philo)
{
        /* MAJ last_meal sous meal_mutex (data race safe) */
        pthread_mutex_lock(&philo->meal_mutex);
        philo->last_meal = ph_timestamp(philo->rules);
        philo->meals_eaten++;
        pthread_mutex_unlock(&philo->meal_mutex);
        ph_print_status(philo, "is eating", 0);
        ph_usleep(philo->rules->time_to_eat);
        pthread_mutex_unlock(philo->left_fork);
        pthread_mutex_unlock(philo->right_fork);
}

void        *ph_routine(void *arg)
{
        t_philo *philo;

        philo = (t_philo *)arg;
        /* Initialiser last_meal avant de démarrer (pour ne pas mourir avant le 1er repas) */
        pthread_mutex_lock(&philo->meal_mutex);
        philo->last_meal = ph_timestamp(philo->rules);
        pthread_mutex_unlock(&philo->meal_mutex);
        if (philo->id % 2 == 0)
                ph_usleep(philo->rules->time_to_eat / 2);  /* décalage pairs */
        while (!ph_check_stop(philo->rules))
        {
                ph_take_forks(philo);
                if (philo->rules->nb_philo == 1)
                        break ;  /* 1 philo ne peut jamais manger (1 seule fourchette) */
                ph_eat(philo);
                if (philo->rules->must_eat != -1 && philo->meals_eaten
                        >= philo->rules->must_eat)
                        break ;  /* philo a fini ses repas */
                ph_sleep_and_think(philo);
        }
        return (NULL);
}
// Cas spécial nb_philo == 1

Avec un seul philosophe, il n'y a qu'une seule fourchette — impossible de manger. Le philosophe prend sa fourchette gauche, attend time_to_die + 10 ms (donc meurt), puis libère la fourchette. Sans ce cas spécial, le philosophe bloquerait indéfiniment sur pthread_mutex_lock(right_fork) (la fourchette droite est la même que la gauche, déjà lockée) — deadlock. Le test ./philo 1 800 200 200 doit afficher "X 1 died" à ~801ms.

Monitor thread

Le thread monitor est le cœur du système de détection de mort. Il parcourt tous les philosophes toutes les 200µs, vérifie si l'un d'eux a dépassé time_to_die depuis son dernier repas, et si oui, déclenche l'arrêt. Il vérifie aussi si tous les philosophes ont atteint must_eat pour un arrêt propre.

philo/srcs/monitor.c// ph_monitor
static int ph_check_death(t_rules *rules, t_philo *philos)
{
        int         i;
        long        now;

        i = 0;
        while (i < rules->nb_philo)
        {
                pthread_mutex_lock(&philos[i].meal_mutex);
                if (rules->must_eat != -1 && philos[i].meals_eaten >= rules->must_eat)
                {
                        pthread_mutex_unlock(&philos[i].meal_mutex);
                        i++;
                        continue ;
                }
                now = ph_timestamp(rules);
                if (now - philos[i].last_meal > rules->time_to_die)
                {
                        pthread_mutex_unlock(&philos[i].meal_mutex);
                        ph_set_stop(rules);
                        ph_print_status(&philos[i], "died", 1);
                        return (1);
                }
                pthread_mutex_unlock(&philos[i].meal_mutex);
                i++;
        }
        return (0);
}

void        *ph_monitor(void *arg)
{
        t_philo *philos;

        philos = (t_philo *)arg;  /* pointeur heap, pas stack */
        while (!ph_check_stop(philos->rules))
        {
                if (ph_check_death(philos->rules, philos))
                        return (NULL);
                if (ph_check_finished(philos->rules, philos))
                        return (NULL);
                usleep(200);  /* 200µs : précision < 10ms pour délai mort */
        }
        return (NULL);
}
// Bug critique — stack-use-after-return

Une erreur subtile détectée par AddressSanitizer : si on passe &philos (l'adresse d'une variable stack dans ph_create_threads) au thread monitor, cette stack frame disparaît quand ph_create_threads retourne, et le monitor lit de la mémoire invalide. Solution : passer philos directement (le pointeur heap qui lui reste valide). C'est exactement le bug qu'on a corrigé dans notre audit — voir section 07.

05

philo_two — threads + sémaphore

philo_two est une variante pédagogique qui remplace les mutex par fourchette par un sémaphore unique représentant toutes les fourchettes au milieu de la table. Initialement à N (le nombre de philosophes), ce sémaphore permet à un philosophe de prendre une fourchette avec sem_wait et de la relâcher avec sem_post. Comme il faut deux fourchettes pour manger, chaque philosophe fait deux sem_wait avant de manger et deux sem_post après.

Sémaphore nommé

Un sémaphore est un compteur atomique protégé par le noyau. Contrairement au mutex (qui a un "propriétaire" explicite), un sémaphore peut être incrémenté par n'importe qui et décrémenté par n'importe qui. sem_open crée un sémaphore nommé (accessible par nom entre processus), identifié par une chaîne commençant par /.

philo_two/srcs/init.c// sem_open
static int ph_init_semaphores(t_rules *rules)
{
        /* sem_unlink avant sem_open : nettoie les restes d'un crash précédent */
        sem_unlink("/philo_forks");
        sem_unlink("/philo_print");

        /* Sémaphore des fourchettes : initialisé à N (autant de fourchettes que de philos) */
        rules->forks = sem_open("/philo_forks", O_CREAT, 0644,
                        rules->nb_philo);
        if (rules->forks == SEM_FAILED)
                return (ph_error("sem_open failed"));

        /* Sémaphore print : initialisé à 1 (mutex binaire pour l'affichage) */
        rules->print = sem_open("/philo_print", O_CREAT, 0644, 1);
        if (rules->print == SEM_FAILED)
                return (ph_error("sem_open failed"));

        return (0);
}

Routine avec sémaphore

philo_two/srcs/philo.c// take_forks
static void        ph_take_forks(t_philo *philo)
{
        /* Prendre 2 fourchettes : 2 sem_wait (décrémente le compteur de 2) */
        sem_wait(philo->rules->forks);
        ph_print_status(philo, "has taken a fork", 0);
        sem_wait(philo->rules->forks);
        ph_print_status(philo, "has taken a fork", 0);
}

static void ph_eat(t_philo *philo)
{
        /* MAJ last_meal sous meal_mutex (même pattern que philo) */
        pthread_mutex_lock(&philo->meal_mutex);
        philo->last_meal = ph_timestamp(philo->rules);
        philo->meals_eaten++;
        pthread_mutex_unlock(&philo->meal_mutex);
        ph_print_status(philo, "is eating", 0);
        ph_usleep(philo->rules->time_to_eat);

        /* Relâcher les 2 fourchettes : 2 sem_post (incrémente le compteur de 2) */
        sem_post(philo->rules->forks);
        sem_post(philo->rules->forks);
}
// Éviter le deadlock avec sémaphore

Avec N philosophes et un sémaphore initialisé à N, le scénario deadlock classique (tous prennent une fourchette simultanément) peut se produire : si tous les N philosophes font leur premier sem_wait en même temps, le compteur passe à 0 et chacun tient exactement une fourchette — aucun ne peut obtenir la seconde, c'est le deadlock. Le code l'évite via le décalage temporel des philosophes pairs (usleep(time_to_eat/2)), qui réduit la contention initiale. Une alternative classique serait d'initialiser le sémaphore à N-1 : au maximum N-1 philosophes peuvent prendre une fourchette, donc au moins un philosophe aura sa seconde fourchette et mangera, débloquant le système.

Le reste de l'implémentation (monitor, print, init des philos, routine principale) est identique à philo. La seule différence est le mécanisme de synchronisation des fourchettes : sémaphore au lieu de mutex individuels.

06

philo_bonus — processus + sémaphore

Le bonus transforme radicalement l'architecture : chaque philosophe n'est plus un thread mais un processus séparé, créé avec fork(). Le processus principal ne participe pas à la simulation — il supervise uniquement en attendant la mort d'un enfant ou la fin normale. Cette approche isole mieux les défaillances (un segfault dans un philosophe ne tue pas les autres) mais introduit de nouveaux défis : buffering stdout avec fork, processus orphelins, et communication inter-processus pour signaler la mort.

fork() et mémoire partagée

philo_bonus/srcs/run.c// ph_create_processes
static int ph_create_processes(t_rules *rules, t_philo *philos)
{
        int i;

        i = 0;
        while (i < rules->nb_philo)
        {
                philos[i].pid = fork();
                if (philos[i].pid < 0)
                        return (ph_error("fork failed"));
                if (philos[i].pid == 0)
                {
                        /* Processus enfant : exécute la routine puis exit */
                        ph_routine(&philos[i]);
                        exit(0);
                }
                /* Processus parent : continue à forker les autres */
                i++;
        }
        return (0);
}

Après fork(), le processus enfant hérite d'une copie complète de la mémoire du parent — y compris les structures t_rules et t_philo. Mais attention : ces copies sont indépendantes. Si un enfant modifie last_meal, les autres enfants ne le voient pas. C'est pourquoi on utilise des sémaphores nommés (partagés entre processus via le noyau) plutôt que des mutex (qui ne marchent qu'entre threads du même processus).

// Piège — buffering stdout avec fork

printf bufferise sa sortie. Quand on fork, l'enfant hérite du buffer non vidé. Résultat : les messages peuvent ne jamais s'afficher (si l'enfant exit sans flusher) ou s'afficher en double (une fois par l'enfant, une fois par le parent quand son buffer se vide). Solution : utiliser write() au lieu de printf, avec des helpers ph_putnbr_fd et ph_putstr_fd. write est non bufferisé — chaque appel va directement au noyau.

Monitor thread par processus

Chaque processus enfant crée son propre thread monitor pour surveiller sa propre mort. Le monitor lit last_meal (variable locale au processus, pas besoin de la partager) et, si le philosophe dépasse time_to_die, affiche "died" puis poste sur le sémaphore stop pour réveiller le parent.

philo_bonus/srcs/monitor.c// ph_monitor_routine
void       *ph_monitor_routine(void *arg)
{
        t_philo *philo;
        long        now;

        philo = (t_philo *)arg;
        while (1)
        {
                usleep(200);
                pthread_mutex_lock(&philo->meal_mutex);
                now = ph_timestamp(philo->rules);
                if (now - philo->last_meal > philo->rules->time_to_die)
                {
                        pthread_mutex_unlock(&philo->meal_mutex);
                        sem_wait(philo->rules->print);
                        if (sem_trywait(philo->rules->first_death) == 0)
                                ph_print_death(philo);
                        sem_post(philo->rules->print);
                        sem_post(philo->rules->stop);  /* réveille le parent */
                        exit(0);
                }
                pthread_mutex_unlock(&philo->meal_mutex);
        }
        return (NULL);
}

Single death fix

Un bug subtil du bonus : si deux philosophes meurent quasi-simultanément, chacun poste sur stop et affiche "died". Le sujet exige qu'un seul message de mort s'affiche. La solution est un sémaphore first_death initialisé à 1 : seul le premier à appeler sem_trywait(first_death) réussit (le compteur passe à 0), les autres échouent et n'affichent rien.

// Single death — sémaphore first_death
Sans first_death : T=310ms : P1 meurt → print "310 1 died" → sem_post(stop) → exit T=311ms : P3 meurt → print "311 3 died" → sem_post(stop) → exit Résultat : 2 messages "died" → ❌ (sujet exige 1 seul) Avec first_death (init=1) : T=310ms : P1 meurt → sem_wait(print) → sem_trywait(first_death) == 0 (OK) → print "310 1 died" → sem_post(print) → sem_post(stop) → exit T=311ms : P3 meurt → sem_wait(print) → sem_trywait(first_death) == -1 (déjà pris) → n'affiche RIEN → sem_post(print) → sem_post(stop) → exit Résultat : 1 seul message "died" → ✅ sem_trywait est non-bloquant : si le sémaphore est à 0, retourne -1 immédiatement sans attendre. C'est crucial pour ne pas retarder le second mourant.

Supervisor thread dans le parent

Le processus parent doit détecter deux cas : (1) un enfant est mort (sémaphore stop posté par le monitor), (2) tous les enfants ont fini leurs must_eat repas (tous les waitpid retournent). Un thread supervisor gère le cas (2) en attendant non-bloquant (WNOHANG) tous les enfants.

philo_bonus/srcs/run.c// ph_start
int        ph_start(t_rules *rules, t_philo *philos)
{
        pthread_t       supervisor;

        if (ph_create_processes(rules, philos) != 0)
        {
                ph_kill_and_wait(rules, philos);
                return (1);
        }
        /* Supervisor : détecte la fin normale (must_eat atteint par tous) */
        pthread_create(&supervisor, NULL, ph_supervisor, philos);

        /* Bloquant : attend qu'un enfant meure (sem_post(stop) par un monitor) */
        sem_wait(rules->stop);

        /* Tuer tous les enfants restants et attendre qu'ils se terminent */
        ph_kill_and_wait(rules, philos);
        pthread_join(supervisor, NULL);
        return (0);
}
// Pas d'orphelins

La fiche d'évaluation vérifie explicitement qu'aucun processus orphelin ne subsiste après l'exécution. Notre ph_kill_and_wait envoie SIGKILL à tous les enfants puis waitpid sur chacun — garantissant qu'aucun zombie ni orphelin ne reste. Vérifiez avec ps aux | grep philo_bonus après exécution : doit retourner vide.

07

Race condition & ASan

Le bug le plus insidieux découvert pendant l'audit n'est pas un deadlock ni un crash — c'est un stack-use-after-return détecté uniquement par AddressSanitizer. Sans ASan, le programme semblait fonctionner. Mais en réalité, le thread monitor lisait de la mémoire libérée, ce qui aurait pu causer des comportements imprévisibles en production.

Le bug : &philos vs philos

bug_demo.c// ❌ BUG
static int ph_create_threads(t_rules *rules, t_philo *philos, pthread_t *mon)
{
        /* ... création des threads philosophes ... */

        /* BUG : on passe &philos (l'adresse de la variable locale philos) */
        if (pthread_create(mon, NULL, ph_monitor, &philos) != 0)
        {
                ph_set_stop(rules);
                return (ph_error("monitor create failed"));
        }
        return (0);
}

int ph_start(t_rules *rules, t_philo *philos)
{
        pthread_t       monitor;

        if (ph_create_threads(rules, philos, &monitor) != 0)
                return (1);
        /* ICI : ph_create_threads retourne, sa stack frame est libérée !
           Le thread monitor (qui tourne toujours) lit &philos → mémoire invalide */
        ph_join_threads(rules, philos, monitor);
        return (0);
}
// Stack-use-after-return — visualisation
Stack de ph_start : ┌─────────────────────┐ │ philos (pointeur) │ ◀── &philos passé au monitor │ monitor (pthread_t) │ │ ... │ └─────────────────────┘ Stack de ph_create_threads (appelée par ph_start) : ┌─────────────────────┐ │ philos (pointeur) │ ◀── &philos = adresse de CETTE variable │ mon (pthread_t*) │ │ i (int) │ └─────────────────────┘ │ │ ph_create_threads retourne ▼ STACK LIBÉRÉE — le monitor lit &philos qui pointe vers... n'importe quoi Correction : Passer philos directement (pointeur heap qui reste valide pendant toute l'exécution) Au lieu de &philos (adresse d'une variable stack qui disparaît) ph_monitor reçoit t_philo* (pointeur heap) au lieu de t_philo** (adresse stack)

La correction

philo/srcs/run.c// ✅ CORRIGÉ
static int ph_create_threads(t_rules *rules, t_philo *philos, pthread_t *mon)
{
        /* ... création des threads philosophes ... */

        /* CORRIGÉ : on passe philos (pointeur heap) directement */
        if (pthread_create(mon, NULL, ph_monitor, philos) != 0)
        {
                ph_set_stop(rules);
                return (ph_error("monitor create failed"));
        }
        return (0);
}

/* Le monitor reçoit maintenant un t_philo* (et non t_philo**) */
void        *ph_monitor(void *arg)
{
        t_philo *philos;

        philos = (t_philo *)arg;  /* cast direct, pas de double indirection */
        while (!ph_check_stop(philos->rules))
        {
                if (ph_check_death(philos->rules, philos))
                        return (NULL);
                if (ph_check_finished(philos->rules, philos))
                        return (NULL);
                usleep(200);
        }
        return (NULL);
}
// Leçon générale

Ne jamais passer l'adresse d'une variable locale à un thread. Le thread peut vivre plus longtemps que la fonction qui l'a créé, et la stack frame disparaît dès le retour de cette fonction. Passez toujours des pointeurs vers le heap (malloc) ou vers des variables static. Et compilez systématiquement avec -fsanitize=address -g pour détecter ces bugs invisibles.

ThreadSanitizer pour data races

AddressSanitizer détecte les bugs mémoire (use-after-free, stack overflow, leaks). ThreadSanitizer détecte les data races : accès concurrents à la même variable sans synchronisation. Pour Philosophers, le data race le plus probable est sur last_meal : le thread philosophe l'écrit (dans ph_eat) et le thread monitor la lit (dans ph_check_death). Sans mutex, c'est un data race garanti.

terminal// compilation avec sanitizers
# AddressSanitizer (memory bugs + leaks)
cc -Wall -Wextra -Werror -pthread -fsanitize=address -g -I includes srcs/*.c -o philo_asan

# ThreadSanitizer (data races)
cc -Wall -Wextra -Werror -pthread -fsanitize=thread -g -I includes srcs/*.c -o philo_tsan

# Lancer les tests
./philo_asan 5 800 200 200 7
./philo_tsan 5 800 200 200 7
./philo_tsan 4 310 200 100  # tester aussi le cas de mort

# Si "WARNING: ThreadSanitizer: data race" apparaît → corriger le mutex manquant
# Si "ERROR: AddressSanitizer: stack-use-after-return" → corriger le &var local
# Si "ERROR: LeakSanitizer: detected memory leaks" → ajouter les free manquants
08

Précision temporelle

La fiche d'évaluation impose un délai de moins de 10ms entre la mort réelle d'un philosophe et l'affichage de "died". Sur un système Linux moderne, cela semble facile, mais en pratique le scheduler peut endormir le monitor pendant plusieurs millisecondes. Trois optimisations sont nécessaires pour garantir la précision.

Monitor à 200µs

Le monitor parcourt tous les philosophes puis dort 200µs. Pour 5 philosophes, chaque scan prend ~250µs (5 × ~50µs par mutex lock/unlock), soit un cycle ~450µs — largement sous les 10ms. Pour 200 philosophes, le scan atteint ~10ms (200 × 50µs) : le usleep(200) devient négligeable et la limite des 10ms est tangente. La consommation CPU du monitor est élevée (50-100% d'un cœur), prix volontaire pour garantir la détection quasi-immédiate de la mort.

philo/srcs/monitor.c// usleep(200)
void       *ph_monitor(void *arg)
{
        t_philo *philos;

        philos = (t_philo *)arg;
        while (!ph_check_stop(philos->rules))
        {
                if (ph_check_death(philos->rules, philos))
                        return (NULL);
                if (ph_check_finished(philos->rules, philos))
                        return (NULL);
                usleep(200);  /* 200µs : 5000 checks/seconde */
        }
        return (NULL);
}

ph_usleep avec busy-wait

usleep(ms * 1000) n'est pas précis : le scheduler peut réveiller le thread plusieurs millisecondes en retard. Pour garantir que time_to_eat et time_to_sleep sont respectés à la milliseconde près, on utilise un busy-wait adaptatif : usleep(100) pour les longues attentes, usleep(20) pour les 2 dernières millisecondes.

philo/srcs/utils.c// ph_usleep précis
void       ph_usleep(long ms)
{
        long        start;
        long        target;

        start = ph_get_current_time();
        target = start + ms;
        while (ph_get_current_time() < target)
        {
                if (target - ph_get_current_time() > 2)
                        usleep(100);  /* longue attente : usleep standard */
                else
                        usleep(20);   /* 2 dernières ms : polling serré */
        }
}

Décalage temporel des pairs

Pour éviter que tous les philosophes pairs ne tentent de prendre leurs fourchettes en même temps (et créent une contention), on les fait attendre time_to_eat / 2 au démarrage. Cela laisse les impairs manger en premier, puis les pairs prennent le relais. Le décalage de time_to_eat / 2 est optimal : assez long pour que les impairs finissent de manger, assez court pour ne pas gaspiller de temps.

philo/srcs/philo.c// décalage pairs
void       *ph_routine(void *arg)
{
        t_philo *philo;

        philo = (t_philo *)arg;
        /* Initialiser last_meal avant tout (sinon meurt avant le 1er repas) */
        pthread_mutex_lock(&philo->meal_mutex);
        philo->last_meal = ph_timestamp(philo->rules);
        pthread_mutex_unlock(&philo->meal_mutex);

        /* Décalage des pairs : laisse les impairs manger en premier */
        if (philo->id % 2 == 0)
                ph_usleep(philo->rules->time_to_eat / 2);

        while (!ph_check_stop(philo->rules))
        {
                /* ... take_forks, eat, sleep, think ... */
        }
        return (NULL);
}
// Validation timing

Pour valider le délai mort < 10ms, lancez ./philo 2 200 250 200. Le philosophe 1 mange (last_meal=T0) mais à T201, le monitor détecte 201-0 > 200 et affiche « 201 1 died ». Le décalage de 1ms vient de la troncature entière de ph_timestamp (la mort réelle survient à T200+ε, mais le timestamp entier passe à 201). ✅ Si c'est 210+, le monitor n'est pas assez rapide → réduire le usleep(200) à usleep(100) ou usleep(50).

09

Audit fiche d'évaluation

Cette section reproduit la fiche d'évaluation officielle 42 (42cursus-philosophers) et audite notre implémentation point par point. Chaque critère est noté ✅ (pass) ou ❌ (fail) avec une explication.

Prérequis

CritèreStatutVérification
Pas de tricheCode original, pas de copie
Dépôt Git appartient à l'étudiantgithub.com/rkpequod-max/Philosophers
Pas de crash / segfaultTesté avec tous les cas edge (1 philo, 200 philos, must_eat, négatifs)
Pas d'erreur Normnorminette exit 0 sur les 3 programmes
Pas de data raceThreadSanitizer clean (helgrind équivalent)
Pas de leakAddressSanitizer + LeakSanitizer clean
Pas de fonction interditenm -u vérifié

Variables globales

// Critère éliminatoire

Si une variable globale est utilisée pour gérer les ressources partagées entre philosophes, l'évaluation s'arrête immédiatement — note 0. Notre implémentation utilise exclusivement des variables locales et des mutex/sem pour la synchronisation. grep -rn '^[a-zA-Z].*g_' srcs/ includes/ retourne vide ✅.

Mandatory (philo) — code

CritèreStatutDétail
Un thread par philosophepthread_create par philo dans ph_create_threads
Une fourchette par philosopheN mutex alloués dans ph_init_mutexes
Mutex par fourchette pour vérifier/changer l'étatpthread_mutex_lock(&forks[i]) dans ph_take_forks
Outputs jamais mélangésprint_mutex global verrouille printf
Mutex empêche mourir + manger simultanésmeal_mutex par philo protège last_meal (monitor + routine l'utilisent)

Mandatory — tests

TestAttenduRésultat
1 800 200 200Le philo meurt✅ "801 1 died"
5 800 200 200Aucun mort✅ 0 morts
5 800 200 200 7Stop après 7 repas✅ exit 0
4 410 200 200Aucun mort✅ 0 morts
4 310 200 100Un mort✅ "311 1 died"
2 200 250 200Mort, delay < 10ms✅ "201 1 died" (1ms de délai)
200 800 200 200Aucun mort (stress)✅ 0 morts

Bonus (philo_bonus) — code

CritèreStatutDétail
Un processus par philosophefork() dans ph_create_processes
Main process n'est pas un philosopheLe parent supervise uniquement (sem_wait(stop))
Pas de processus orphelin à la finph_kill_and_wait : SIGKILL + waitpid sur tous
Sémaphore unique pour les fourchettessem_open("/philo_bonus_forks", N)
Output protégé contre multiple accèssem_wait(print) autour des write
Sémaphore empêche mourir + manger simultanésmeal_mutex par philo + first_death sem

Bonus — tests

Identiques au mandatory — tous passent ✅ (mêmes résultats à 1ms près).

// Auto-évaluation finale

Mandatory : 7/7 tests passent + tous les critères code respectés = 100% mandatory. Bonus : 7/7 tests passent + tous les critères code respectés = 100% bonus. ThreadSanitizer : 0 data race. AddressSanitizer : 0 leak, 0 stack-use-after-return. Norminette : 0 erreur. Le projet est prêt pour la défense peer-to-peer.

10

Makefile & compilation

Le Makefile de chaque programme suit les conventions 42 : règles all, clean, fclean, re obligatoires, compilation avec -Wall -Wextra -Werror, et le flag -pthread pour linker la bibliothèque pthread.

philo/Makefile// mandatory
NAME            = philo

CC              = cc
CFLAGS          = -Wall -Wextra -Werror -pthread

INCLUDES        = -I includes

SRCS_DIR        = srcs
SRCS            = $(SRCS_DIR)/main.c \
                  $(SRCS_DIR)/error.c \
                  $(SRCS_DIR)/parse.c \
                  $(SRCS_DIR)/init.c \
                  $(SRCS_DIR)/philo.c \
                  $(SRCS_DIR)/monitor.c \
                  $(SRCS_DIR)/print.c \
                  $(SRCS_DIR)/run.c \
                  $(SRCS_DIR)/utils.c

OBJS            = $(SRCS:.c=.o)

HEADERS         = includes/philo.h

.PHONY: all clean fclean re

all:            $(NAME)

$(NAME):        $(OBJS) $(HEADERS)
                $(CC) $(CFLAGS) $(OBJS) -o $(NAME)

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

clean:
                rm -f $(OBJS)

fclean:         clean
                rm -f $(NAME)

re:             fclean all

Points clés

// -pthread flag
Essentiel pour linker libpthread. Sans lui, erreur undefined reference to pthread_create au link. Sur Linux moderne, pthread est intégrée à libc, mais le flag reste nécessaire pour les defines comme _REENTRANT.
// Compilation par .o
Règle pattern %.o: %.c pour compilation incrémentale. Si on modifie philo.c, seul philo.o est recompilé. Vérifiez avec make deux fois → "Nothing to be done".
// Pas de libft
Le sujet interdit l'utilisation de la libft pour Philosophers. On doit utiliser directement printf, malloc, free, write, etc. de la libc. C'est plus simple mais exige d'écrire les helpers (putnbr, putstr) soi-même pour philo_bonus.
// .PHONY
Les règles all clean fclean re sont déclarées .PHONY pour indiquer qu'elles ne correspondent pas à des fichiers. Sans ça, si un fichier nommé "clean" existe, make clean ne s'exécuterait pas.
11

Pièges & edge cases

Cette section recense les pièges classiques de Philosophers et les solutions implémentées. À lire avant la défense pour anticiper les questions du correcteur.

PiègeSymptômeSolution
Deadlock (tous prennent gauche) Simulation figée, aucun mort Ordre asymétrique pairs/impairs + décalage temporel time_to_eat/2
nb_philo == 1 Deadlock sur right_fork (même que left) Cas spécial : usleep(time_to_die + 10) puis unlock, meurt normalement
Data race sur last_meal TSan WARNING, mort détectée trop tard meal_mutex par philosophe (lock dans ph_eat ET dans ph_check_death)
Stack-use-after-return monitor ASan ERROR, comportement imprévisible Passer philos (heap) au lieu de &philos (stack) à pthread_create
Plusieurs messages "died" 2+ philosophes meurent quasi-simultanément Sémaphore first_death (init=1) + sem_trywait : seul le premier affiche
Buffering stdout avec fork Messages non affichés ou dupliqués dans philo_bonus write() au lieu de printf, avec ph_putnbr_fd + ph_putstr_fd
Processus orphelins philo_bonus laisse des zombies après exit ph_kill_and_wait : SIGKILL + waitpid sur tous les enfants
Délai mort > 10ms Timestamp "died" trop tardif Monitor à 200µs + ph_usleep busy-wait pour les 2 dernières ms
Philosophe meurt avant 1er repas Mort à t=0 ou avant de prendre fourchettes Initialiser last_meal = ph_timestamp() au début de ph_routine
must_eat jamais atteint Simulation tourne indéfiniment avec must_eat ph_check_finished compte les philos avec meals_eaten >= must_eat
Arguments négatifs ou non numériques Comportement indéfini ph_is_number vérifie chaque char est un digit, ph_validate_values vérifie ranges
Sémaphore nommé qui persiste sem_open échoue après un crash précédent sem_unlink avant sem_open dans ph_init_semaphores
Timestamp négatif au démarrage gettimeofday avant init de start_time Fixer start_time dans ph_init avant de créer les threads
Contenu de stdout entrelacé "X has takY is eatingen a fork" print_mutex (philo) ou sem print (philo_two/bonus) autour de printf
Variable globale interdite Note 0 à l'évaluation Tout en local + mutex. Pattern function-static si besoin de "globale"
« Les androïdes ne meurent pas d'un bug — ils meurent d'une omission. Vérifiez chaque mutex, chaque thread, chaque timestamp. Le moindre data race, et c'est toute la mission qui s'effondre. »
— Commandant White, briefing pré-mission
12

Synchronisation approfondie : mutex vs sémaphores

Le projet Philosophers repose sur deux primitives de synchronisation fondamentales : les mutex et les sémaphores. Bien qu'elles puissent paraître interchangeables (un sémaphore initialisé à 1 se comporte comme un mutex), elles ont des sémantiques distinctes qui les rendent adaptées à des problèmes différents. Comprendre cette distinction est crucial pour la soutenance.

CaractéristiqueMutexSémaphore
NatureVerrou binaire (lock/unlock)Compteur entier (nombre d'unités disponibles)
Usage principalExclusion mutuelle sur une ressource uniqueGestion de pools de ressources multiples ou signalisation
OrientationOrienté thread (propriétaire) — seul le thread qui a locké peut unlockerNon orienté thread — n'importe quel thread peut faire sem_post
Initialisation typiqueInitialisé à 1 pour protéger une section critiquePeut être initialisé à N pour gérer N ressources
PerformancePlus simple, souvent plus performant pour l'exclusion mutuellePlus généraliste, potentiellement plus complexe à raisonner
Dans Philosophersphilo — chaque fourchette est un mutexphilo_two et philo_bonus — sémaphore par fourchette ou pool global
// La distinction clé pour la soutenance

Un mutex est un verrou qui protège une ressource : "je possède cette fourchette, personne d'autre ne peut y toucher". Un sémaphore est un signal qui coordonne des threads : "une fourchette est disponible, celui qui la veut peut la prendre". La propriété d'ownership du mutex garantit que seul le thread qui a locké peut unlocker — ce qui prévient les erreurs de programmation. Avec un sémaphore, n'importe quel thread peut faire sem_post, ce qui est utile pour la signalisation mais dangereux pour l'exclusion mutuelle si mal utilisé.

Les trois erreurs de synchronisation

L'évaluation vérifie systématiquement la prévention de trois erreurs classiques de programmation concurrente. Chacune a un symptôme distinct et une solution spécifique :

ErreurSymptômeNotre solution
Condition de course (data race) Deux threads lisent/écrivent last_meal simultanément → corruption silencieuse, mort détectée trop tard ou jamais meal_mutex par philosophe protège last_meal et meals_eaten. Vérifié avec ThreadSanitizer (0 warnings).
Interblocage (deadlock) Tous les philosophes prennent leur fourchette gauche simultanément → personne ne peut prendre la droite → programme figé philo : ordre asymétrique (pairs vs impairs via left_fork/right_fork). philo_two / philo_bonus : décalage temporel (usleep(time_to_eat/2) pour les philosophes pairs) — pas de left_fork/right_fork car un seul sémaphore global.
Privation (starvation) Un philosophe n'obtient jamais les fourchettes car ses voisins les monopolisent → meurt de faim alors que d'autres mangent Le monitor scrute tous les philosophes toutes les 200µs. Si un philosophe dépasse time_to_die, il est déclaré mort et la simulation s'arrête — garantissant qu'aucune privation ne passe inaperçue.
// Question de soutenance typique

"Que se passerait-il si on supprimait le mutex sur last_meal ?" Réponse : "Le monitor pourrait lire une valeur partiellement écrite (data race sur un size_t 64 bits n'est pas atomique sur certaines architectures). Résultat : la mort serait détectée trop tard, voire jamais — le délai < 10ms ne serait plus garanti."

13

Sortie thread-safe : protéger stdout

Un aspect souvent sous-estimé mais systématiquement testé à la soutenance : la cohérence de la sortie standard. Quand plusieurs threads (ou processus) écrivent simultanément sur stdout via printf, les messages peuvent s'entrelacer — produisant un flux illisible comme "80000 314 i2s 0e0a tti0n 0g" au lieu de "800 3 is eating" et "800 2 is eating".

Le problème

printf n'est pas intrinsèquement thread-safe. La spécification C ne garantit pas que l'écriture sur stdout est atomique. Deux threads écrivant simultanément peuvent voir leurs buffers respectifs entrelacés au niveau du noyau. Le résultat : des lignes mélangées, des timestamps incohérents, et un affichage impossible à analyser pour le correcteur.

Notre solution : mutex d'affichage

Dans philo et philo_two, nous utilisons un print_mutex (ou sémaphore print) partagé entre tous les threads. Chaque appel à ph_print_status suit la séquence stricte :

print.c// pattern thread-safe
pthread_mutex_lock(&rules->print_mutex);    // verrouiller
printf("%ld %d %s\n", timestamp, id, msg);   // écrire le message complet
pthread_mutex_unlock(&rules->print_mutex);  // déverrouiller

Cette approche sérialise les accès à stdout : chaque message est écrit entièrement avant qu'un autre thread puisse commencer le sien. Le coût en performance est négligeable car les printf sont peu fréquents (quelques dizaines par seconde maximum).

Le cas philo_bonus : write() + sémaphore print

Dans philo_bonus (processus + fork), printf pose un problème supplémentaire : le buffer libc est dupliqué à chaque fork(), ce qui peut provoquer des affichages en double ou des flushes intempestifs. Notre solution est double :

  1. Remplacer printf par des appels directs à write(1, ...) via ph_putstr_fd et ph_putnbr_fd — cela élimine le buffering user-space dupliqué par fork().
  2. Garder le sémaphore print autour de chaque affichage (sem_wait(print) / sem_post(print)) — car les multiples write() d'un même message (un par digit, séparateur, etc.) peuvent encore s'entrelacer entre processus sans cette protection.
// Pourquoi write() est atomique mais pas printf()

write() est un appel système direct (sans buffer user-space), ce qui élimine le problème de buffering dupliqué par fork(). Note : l'atomicité POSIX PIPE_BUF ne s'applique qu'aux pipes/FIFOs, pas au terminal — d'où la nécessité de garder le sémaphore print. printf() utilise un buffer utilisateur en user-space — deux printf concurrents peuvent interfolier leurs buffers avant le flush. Dans philo_bonus, nous utilisons ph_putstr_fd et ph_putnbr_fd qui appellent write directement, éliminant le problème de buffering.

// Checklist soutenance : sortie
  • Lancez ./philo 100 800 200 200 — les lignes sont-elles lisibles ?
  • Aucune ligne ne doit être entrelacée ou tronquée
  • Les timestamps doivent être strictement croissants
  • Le message de mort doit être le dernier (le mutex d'affichage le garantit)
14

Scénarios de test et validation

La fiche d'évaluation impose des scénarios de test avec des paramètres précis, chacun conçu pour révéler une faille spécifique de la logique de synchronisation. Comprendre pourquoi chaque test existe est aussi important que le passer.

CommandeObjectifRésultat attenduCe que ça teste
./philo 1 800 200 200 Philosophe unique "801 1 died" (mort à ~800ms) Le philosophe n'a qu'une fourchette → ne peut pas manger → meurt. Vérifie que le monitor détecte la mort dans le délai < 10ms.
./philo 2 200 250 200 Test du délai < 10ms Aucune mort P1 mange à T=0 (last_meal=T0), mais à T201 le monitor détecte 201-0 > 200 et affiche '201 1 died'. Vérifie que le délai de détection est < 10ms (ici 1ms).
./philo 4 310 200 100 Détection de mort (délai serré) Un philosophe meurt time_to_die=310 est juste assez pour manger une fois (200ms) + dormir (100ms). Un philosophe privé de fourchettes meurt. Teste la détection de mort fine.
./philo 4 410 200 200 Survie sous pression Aucune mort time_to_die=410 permet deux cycles repas+sommeil (400ms). Vérifie que la synchronisation permet à tous de manger dans les temps.
./philo 5 800 200 200 Stress standard Aucune mort Le test de référence. 5 philosophes, temps confortable. Vérifie l'absence de deadlock et de starvation sur la configuration standard.
./philo 5 800 200 200 7 Repas limités (must_eat) Simulation s'arrête après 7 repas par philosophe Vérifie le compteur meals_eaten et l'arrêt propre quand must_eat est atteint.
./philo 200 800 200 200 Stress intensif Aucune mort (généralement) 200 philosophes : le monitor doit scruter 200 × meal_mutex en < 10ms. Teste la scalabilité du monitor.
// Le test 200 philosophes : limite du monitor

Avec 200 philosophes, le monitor doit locker/délocker 200 meal_mutex par cycle de scan. À ~50µs par mutex (lock + read + unlock), un scan complet prend ~10ms — soit la limite exacte du délai autorisé. Sur un CPU lent ou sous charge, le monitor peut dépasser 10ms et rater une détection de mort. C'est la limite fondamentale de notre architecture à monitor unique. Une solution serait des monitors distribués (un par philosophe) ou un flag volatile pour éviter le lock.

Méthodologie de test

Pour valider rigoureusement avant la soutenance, suivez cette méthodologie :

  1. Tests fonctionnels : lancez chaque scénario du tableau ci-dessus, vérifiez le résultat attendu.
  2. Tests de stress : lancez ./philo 5 800 200 200 en boucle 100 fois — aucune mort ne doit survenir.
  3. ThreadSanitizer : compilez avec -fsanitize=thread et lancez ./philo_tsan 5 800 200 200 7 — 0 warnings attendus.
  4. AddressSanitizer : compilez avec -fsanitize=address — 0 leaks, 0 use-after-free.
  5. Valgrind : valgrind --leak-check=full ./philo 5 800 200 200 7 — "no leaks are possible".
  6. Sortie visuelle : vérifiez qu'aucune ligne n'est entrelacée, même avec 200 philosophes.
« Un test ne prouve pas l'absence de bugs — il prouve l'absence de bugs pour ces entrées. La rigueur n'est pas de passer tous les tests, mais de comprendre pourquoi chaque test existe et ce qu'il révèle. »
— 9S, analyse post-mission
15

Auto-évaluation & préparation soutenance

La soutenance de Philosophers ne se résume pas à montrer un programme qui fonctionne. L'évaluateur vérifie une compréhension profonde des concepts de programmation concurrente. Voici un cadre structuré pour l'auto-évaluation et la préparation.

Checklist threads

  • Création : chaque thread a-t-il une fonction de routine bien définie ? Les arguments sont-ils correctement passés (structure sur le tas, pas sur la pile) ?
  • Cycle de vie : pthread_join() est-il appelé pour chaque thread ? Pas de fuite de threads ?
  • Terminaison propre : le stop flag est-il vérifié à chaque itération de la routine ? Un thread peut-il se terminer proprement quand la simulation s'arrête ?
  • Mémoire partagée : toutes les variables partagées (last_meal, meals_eaten, stop) sont-elles identifiées et protégées ?

Checklist synchronisation

  • Choix de l'outil : pour chaque mutex/sémaphore, justifier son existence. Pourquoi un mutex ici et pas un sémaphore ?
  • Paires lock/unlock : chaque pthread_mutex_lock a-t-il son pthread_mutex_unlock correspondant ? Même dans les chemins d'erreur ?
  • Sections critiques courtes : les sections critiques sont-elles aussi courtes que possible ? Le meal_mutex ne protège que last_meal et meals_eaten, pas toute la routine.
  • Ordre des verrous : un ordre global est-il respecté ? Notre ordre asymétrique (pairs vs impairs) casse-t-il le cycle de deadlock ?
  • Initialisation/destruction : tous les mutex sont initialisés (PTHREAD_MUTEX_INITIALIZER ou pthread_mutex_init) et détruits (pthread_mutex_destroy) ?

Checklist sortie

  • Observation : lancez ./philo 50 800 200 200 plusieurs fois — les messages sont-ils toujours lisibles ?
  • Mutex d'affichage : chaque printf est-il encadré par lock/unlock du print_mutex ?
  • Message de mort : le message "X died" est-il garanti d'être le dernier (le mutex d'affichage + le flag stop le garantissent) ?

Questions de soutenance typiques

QuestionRéponse attendue
"Pourquoi un mutex et pas un sémaphore pour les fourchettes dans philo ?" Un mutex a une notion de propriétaire (le thread qui a locké doit unlocker), ce qui prévient les erreurs. Un sémaphore binaire n'a pas cette garantie — n'importe quel thread peut sem_post. Pour protéger une ressource unique (fourchette), le mutex est plus sûr et plus performant.
"Comment évitez-vous le deadlock ?" Ordre asymétrique : les philosophes pairs prennent left_fork d'abord (classique droite), les impairs prennent left_fork d'abord (classique gauche). Deux philosophes adjacents veulent la même fourchette en premier → un seul la obtient → l'autre bloque sans rien tenir → pas de cycle circulaire.
"Que se passe-t-il si on supprime le meal_mutex ?" Data race sur last_meal : le monitor pourrait lire une valeur partiellement écrite (sur architectures non 64-bit-atomiques). La mort serait détectée trop tard — le délai < 10ms ne serait plus garanti. ThreadSanitizer le détecterait comme warning.
"Pourquoi write() dans philo_bonus au lieu de printf() ?" printf utilise un buffer user-space dupliqué à chaque fork() — les flushes peuvent produire des doublons. write() est un syscall direct, atomique pour ≤ PIPE_BUF (4096 bytes), sans buffering user-space.
"Comment garantissez-vous le délai < 10ms ?" Le monitor dort 200µs entre chaque scan complet. Pour 5 philosophes, un scan prend ~250µs (5 × 50µs), soit un cycle ~450µs — largement sous 10ms. Pour 200 philosophes, le scan atteint ~10ms (limite).
"Pourquoi pas de variable volatile ?" Les primitives de synchronisation (pthread_mutex_lock/unlock) garantissent la visibilité mémoire (barrière mémoire). volatile ne garantit l'atomicité ni l'ordonnancement — c'est un piège classique. Le seul cas légitime serait volatile sig_atomic_t pour les handlers de signaux, que nous n'utilisons pas.
// Stratégie de soutenance

Structurez votre exposé en trois temps : (1) Architecture globale — montrez la structure des données et le flux de création des threads. (2) Synchronisation — expliquez le choix mutex vs sémaphore, l'ordre asymétrique anti-deadlock, et le monitor. (3) Démonstration — lancez les tests devant le correcteur, montrez la sortie lisible, et terminer par ThreadSanitizer (0 warnings). Anticipez les questions ci-dessus et préparez des réponses concises avec justification conceptuelle, pas juste technique.

« La soutenance ne prouve pas que le code marche — elle prouve que l'étudiant comprend pourquoi ça marche. Un programme qui passe les tests sans que son auteur sache expliquer le pourquoi n'a aucune valeur pédagogique. »
— Commandant White, briefing pré-soutenance