Partie 5 - Threads ================== .. raw:: html Support présentation Introduction ------------ Un **thread** (*fil d'exécution*), ou processus léger, est une unité d'exécution légère à l'intérieur d'un processus. Contrairement aux processus, les threads d'un même programme partagent la même mémoire (code, données, heap), mais disposent de leur propre *stack* (pile) et compteur ordinal (registre du processeur qui contient l'adresse mémoire de la prochaine instruction à exécuter). - **Processus** : unité lourde, isolée, avec espace mémoire séparé - **Threads** : unités légères, partagent la mémoire, plus rapides à créer et à communiquer Avantages : - Partage facile de données (même mémoire) - Création et destruction plus rapides qu'un processus - Communication plus simple (pas besoin de mécanismes IPC lourds, voir :ref:`chapitre IPC `) Inconvénients : - Risques de *data race* (accès concurrent non synchronisé) - Synchronisation nécessaire (mutex, sémaphores, ...) - Un crash d'un thread peut faire planter tout le processus Cas d'utilisation de threads : - Serveurs web multi-clients : chaque requête HTTP peut être gérée par un thread, avec partage du cache et des données globales. - Applications interactives : séparer l'interface utilisateur (UI) et le traitement lourd pour éviter le blocage (ex. un éditeur ou un jeu vidéo). - Calcul parallèle : diviser un gros calcul en plusieurs tâches parallèles sur des cœurs différents. - Producteur / consommateur : un thread lit des données (entrée clavier, socket réseau, fichier) pendant qu'un autre les traite. - Simulations : gestion de plusieurs entités indépendantes (ex. particules, joueurs dans un jeu en réseau). Avec les systèmes Unix, nous disposons de deux types de threads : - les pthreads, pour POSIX thread, définis dans ````, pour les systèmes Unix, que nous utiliserons dans ce cours - les threads, donnés par l'API C11, définis dans ````, plus portable Dans les deux cas, le thread commence à s'exécuter à sa construction avec ``create``. Il prend en entrée une variable qui contiendra le thread (utilisée principalement pour attendre celle-ci par la suite), un pointeur sur fonction et des arguments. Le fil principal pourra attendre la fin du thread avec ``join``, s'il n'y a pas de ``join`` il n'est pas garanti que le thread aura le temps de finir son execution. La signature du pointeur sur fonction des pthreads est ``void *(*) (void *)`` (fonction qui prend en entrée un ``void *`` et retourne un ``void *``), pour les threads de l'API C11, la signature est ``int(*) (void*)`` (fonction qui prend en entrée un ``void *`` et retourne un ``int``). Les threads ne peuvent donc prendre en entrée qu'un seul argument de type ``void *``, pour passer plusieurs arguments il faut donc passer par une structure. Il faudra faire attention si des éléments dans la structure peuvent sortir de portée avant la fin de celui-ci. Par exemple, avoir un attribut ``char * nom`` dans la structure où le ``malloc`` est fait dans le fil principal, puis la structure est passée au thread avant de faire un ``free`` sur ``nom`` dans la boucle principale avant la fin du thread. Ce genre d'information doit être clairement documentée dans le code (qui libère la mémoire). Pour le type de retour, les pthreads retournent ce qu'ils veulent (``void *``) et les threads uniquement un ``int``. POSIX threads ------------- L'API standard sous Linux est **Pthreads** (POSIX threads), disponible avec l'en-tête ```` (voir ``man 7 pthreads``). Pour la compilation, il ne faut pas oublier d'ajouter ``-pthread`` aux flags : .. code-block:: bash gcc -std=c2x -Wall -Wextra -pedantic threads.c -o threads -pthread La création se fait avec ``pthread_create`` : .. code-block:: c int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg); - ``thread`` : pointeur où sera stocké l'identifiant du thread - ``attr`` : attributs du thread (souvent ``NULL``) - ``start_routine`` : fonction exécutée par le thread - ``arg`` : argument passé à cette fonction L'attente du thread se fait avec ``pthread_join`` : .. code-block:: c int pthread_join(pthread_t thread, void **retval); - ``thread`` : l'identifiant du thread - ``retval`` : la valeur retournée par le thread (``NULL`` si aucune valeur) Le fil principal attendra la fin du thread au niveau de cette fonction tant que le thread n'aura pas terminé son calcul. Si un pthread veut retourner une valeur, il peut utiliser ``pthread_exit`` à la fin de sa fonction : .. code-block:: c void pthread_exit(void *retval); Exemple sans retour de valeur : .. code-block:: c :linenos: #include #include #include void *ma_fonction(void *arg) { int *val = (int *)arg; printf("Hello depuis le thread, arg = %d\n", *val); return NULL; } int main(void) { pthread_t tid; int valeur = 42; if (pthread_create(&tid, NULL, ma_fonction, &valeur) != 0) { perror("pthread_create"); exit(EXIT_FAILURE); } // attendre la fin du thread pthread_join(tid, NULL); printf("Thread terminé !\n"); return EXIT_SUCCESS; } Exemple partiel avec une valeur retournée : .. code-block:: c void *ma_fonction(void *arg) { int *res = malloc(sizeof(int)); *res = 1234; pthread_exit(res); } // main ... int *resultat; pthread_join(tid, (void **)&resultat); printf("Résultat : %d\n", *resultat); free(resultat); Synchronisation --------------- Comme plusieurs threads partagent les mêmes variables globales ou les données du tas (*heap*), il faut éviter les accès concurrents non contrôlés. Sinon, on obtient des résultats incohérents : c'est le problème des *race conditions* ("conditions de compétition") ou *data race* (course aux données, présenté ci-dessous). Exemple classique : incrémenter une variable globale depuis deux threads. .. code-block:: c :linenos: #include #include int compteur = 0; void *incremente(void *arg) { (void)arg; for (int i = 0; i < 1000000; i++) { compteur++; } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, incremente, NULL); pthread_create(&t2, NULL, incremente, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("Compteur final = %d\n", compteur); return 0; } Résultats observés (variables d'une exécution à l'autre) : .. code-block:: console Compteur final = 1048495 Compteur final = 1102100 Compteur final = 1069216 Compteur final = 1087636 alors que le résultat attendu est ``2 000 000`` (chaque thread fait ``1 000 000`` incréments). Pourquoi ce bug ? ~~~~~~~~~~~~~~~~~ L'opération ``compteur++`` n'est pas atomique, elle se décompose en plusieurs instructions machine : 1. Lire la valeur actuelle de ``compteur`` 2. Ajouter 1 3. Réécrire la nouvelle valeur dans ``compteur`` Si deux threads exécutent cette séquence en même temps, ils peuvent interférer : Exemple : - ``compteur = 50`` - **Thread t1** lit ``50`` - **Thread t2** lit aussi ``50`` (presque en même temps) - **t1** calcule ``50+1 = 51`` et écrit ``51`` - **t2** calcule ``50+1 = 51`` et écrit aussi ``51`` Résultat final, ``compteur = 51``, alors qu'on aurait dû avoir ``compteur = 52``. Ce phénomène se produit des millions de fois dans la boucle, expliquant pourquoi le résultat final varie et est **toujours inférieur à 2000000**. Solution : utiliser des mécanismes de synchronisation (mutex, opérations atomiques, etc.) pour rendre l'incrément atomique. Mutex ~~~~~ Un **mutex** (*mutual exclusion* - verrou d'exclusion mutuelle) est un verrou qui garantit qu'un seul thread accède à une section critique à la fois. Avec les pthreads, le mutex est un ``pthread_mutex_t`` qui peut être initialisé statiquement ou dynamiquement : - Initialisation statique : ``pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;`` - Initialisation dynamique : ``pthread_mutex_init(&m, NULL);`` - Libération (si init dynamique) : ``pthread_mutex_destroy(&m);`` Il peut ensuite être utilisé en le verrouillant avec ``pthread_mutex_lock`` avant la zone critique puis en le déverrouillant avec ``pthread_mutex_unlock`` après la zone critique. Si un thread essaie d'appeler ``pthread_mutex_lock`` sur un verrou déjà verrouillé, il est mit en attente jusqu'à ce que le mutex soit déverrouillé. Exemple : .. code-block:: c :linenos: #include #include pthread_mutex_t verrou = PTHREAD_MUTEX_INITIALIZER; int compteur = 0; void *incremente(void *arg) { (void)arg; for (int i = 0; i < 1000000; i++) { pthread_mutex_lock(&verrou); compteur++; pthread_mutex_unlock(&verrou); } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, incremente, NULL); pthread_create(&t2, NULL, incremente, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("Compteur final = %d\n", compteur); return 0; } Grâce au mutex, il n'y a plus de problèmes de *data race* (accès concurrent non synchronisé) et le compteur est bien à 2000000 en fin de calcul. Il est aussi possible de mettre le mutex dans une structure qui accompagnera la donnée critique. .. Threads en C11 .. -------------- .. Depuis la norme **C11**, le langage C intègre sa propre API de threads dans l'en-tête ```` (voir `documentation sur cppreference `__ ). .. Elle est plus simple et portable que **Pthreads**, mais aussi plus limitée (pas de gestion fine de l'ordonnancement, pas de sémaphores nommés, peu d'attributs configurables). .. Principales fonctionnalités fournies par ```` : .. - création et gestion de threads (``thrd_t``) .. - synchronisation avec **mutex** et **variables de condition** .. - mécanisme de **thread-local storage** (mémoire propre à chaque thread) .. Cas d'utilisation des threads C11 ou POSIX : .. - **API C11** = plus simple et portable → si vous voulez un code portable multi-OS .. - **API POSIX** (pthread) = plus puissante et complète (contrôle fin, sémaphores, attributs, scheduling…) → si vous codez pour Unix/Linux .. Création d'un thread .. ~~~~~~~~~~~~~~~~~~~~ .. Prototype : .. .. code-block:: c .. int thrd_create(thrd_t *thr, .. int (*func)(void *), .. void *arg); .. - ``thr`` : identifiant du thread créé .. - ``func`` : fonction exécutée par le thread, doit retourner un ``int`` et prendre un ``void *`` en argument .. - ``arg`` : argument passé à la fonction .. Retour possible : .. - ``thrd_success`` : succès .. - ``thrd_nomem`` : mémoire insuffisante .. - ``thrd_error`` : autre erreur .. .. code-block:: c .. :linenos: .. #include .. #include .. int ma_fonction(void *arg) { .. int valeur = *(int *)arg; .. printf("Hello depuis un thread C11, arg = %d\n", valeur); .. return 0; // valeur de retour .. } .. int main(void) { .. thrd_t t; .. int val = 42; .. if (thrd_create(&t, ma_fonction, &val) != thrd_success) { .. fprintf(stderr, "Erreur création thread\n"); .. return 1; .. } .. thrd_join(t, NULL); .. printf("Thread terminé !\n"); .. return 0; .. } .. Attendre la fin d'un thread .. ~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. La fonction ``thrd_join`` attend qu'un thread se termine et peut récupérer son code de retour (``int``). .. .. code-block:: c .. int resultat; .. thrd_join(t, &resultat); .. printf("Code de retour du thread = %d\n", resultat); .. Un thread peut aussi être **détaché** avec ``thrd_detach(t)``, ce qui libère automatiquement ses ressources à la fin de son exécution (équivalent à ``pthread_detach``). .. Synchronisation en C11 .. ~~~~~~~~~~~~~~~~~~~~~~ .. **Mutex** .. .. code-block:: c .. #include .. #include .. mtx_t verrou; .. int compteur = 0; .. int incremente(void *arg) { .. for (int i = 0; i < 1000000; i++) { .. mtx_lock(&verrou); .. compteur++; .. mtx_unlock(&verrou); .. } .. return 0; .. } .. int main(void) { .. thrd_t t1, t2; .. mtx_init(&verrou, mtx_plain); .. thrd_create(&t1, incremente, NULL); .. thrd_create(&t2, incremente, NULL); .. thrd_join(t1, NULL); .. thrd_join(t2, NULL); .. printf("Compteur final = %d\n", compteur); .. mtx_destroy(&verrou); .. return 0; .. }