Partie 5 - Threads

Compilation : ajoutez le flag -pthread

gcc -std=c2x -Wall -Wextra -pedantic -g -pthread prog.c

Pour utiliser valgrind en multi-threading, il est possible d’utiliser helgrind ou drd :

valgrind --tool=helgrind ./a.out
valgrind --tool=drd ./a.out

p5e1 - Premier thread & passage de paramètres

Écrire un programme qui :

  • définit une structure Args { int valeur; const char *message; }

  • crée un thread qui affiche message et valeur ainsi que le PID (getpid) et TID (gettid() et ajouter #define _GNU_SOURCE)

  • le thread retourne NULL, le main fait pthread_join et affiche son PID et TID

Résultat attendu :

$ gcc -Wall -Wextra -pedantic -g -pthread p5/p5e1.c && ./a.out
[main   PID: 114334 - TID: 114334] creating the thread...
[thread PID: 114334 - TID: 114335] value : 5 , message : coucou
[main   PID: 114334 - TID: 114334] thread ended

p5e2 - Valeur de retour

Écrire un programme qui demande à un thread de calculer une somme de 0 à N dans une boucle (uint64_t n = 9876543210, importer stdint.h) et de retourner la valeur.

Résultat attendu :

$ gcc -Wall -Wextra -pedantic -g -pthread p5/p5e2.c && ./a.out
[main] Somme de 1 à 9876543210 = 11879564747017720423
$ # le calcul prend 10-30 secondes

p5e3 - Parallélisation de la somme

Écrire un programme qui demande un nombre de threads nb_threads à utiliser en paramètres qui vont se répartir le calcul de la somme de 0 à N dans une boucle (uint64_t n = 9876543210, importer stdint.h) et de retourner la somme partielle qui sera additionnée dans le main.

Chaque thread calcul la somme partielle sur un intervalle [start, end] qui lui est donné par le main (le dernier thread récupère les éléments restants si la division ne tombe pas juste).

Tester avec plusieurs valeurs de nb_threads (1, 2, 4, 8, 16, 32, 64) et regardez le temps d’exécution avec time.

Résultat attendu :

$ gcc -std=c2x -Wall -Wextra -pedantic -g -pthread p5/p5e3.c && time ./a.out 8
[main] N=9876543210 nb_threads=8
[thread 4] calcul de 4938271605 à 6172839505
[thread 1] calcul de 1234567902 à 2469135802
[thread 0] calcul de 1 à 1234567901
[thread 2] calcul de 2469135803 à 3703703703
[thread 6] calcul de 7407407407 à 8641975307
[thread 3] calcul de 3703703704 à 4938271604
[thread 5] calcul de 6172839506 à 7407407406
[thread 7] calcul de 8641975308 à 9876543210
[thread 7] résultat = 11431184286716963877
[thread 6] résultat = 9907026364784331657
[thread 5] résultat = 8382868462604785856
[thread 1] résultat = 2286236853886602652
[thread 3] résultat = 5334552658245694254
[thread 4] résultat = 6858710560425240055
[thread 2] résultat = 3810394756066148453
[thread 0] résultat = 762078951707056851
[main] Somme parallèle (8 threads) = 11879564747017720423
real    0m0,801s
user    0m5,242s
sys     0m0,003s

p5e4 - N threads et ordonnancement non déterministe

Écrire un programme C qui crée N threads. Le nombre de thread et la graine du générateur aléatoire peut être passé en paramètre, par défaut, 4 threads seront crées et la graine aléatoire sera le nombre de secondes écoulées depuis 1970.

Les fonctions suivantes convertissent une chaîne de caractère en entier :

int atoi(const char *nptr);
long atol(const char *nptr);
long long atoll(const char *nptr);

// mettre NULL pour endptr pour ces deux fonctions
unsigned long strtoul(const char *nptr, char **endptr, int base);
unsigned long long strtoull(const char *nptr, char **endptr, int base);

Le programme principal affiche son PID (getpid), TID (gettid() ajouter #define _GNU_SOURCE à la première ligne du fichier) et paramètres (nombre de threads et random seed).

A chaque thread sera donné un rang ([0..n-1]) et un temps de « travail » entre 1 et 5 secondes (qu’il passera à attendre avec sleep).

Chaque thread affiche des informations sur lui-même :

  • rang

  • PID du processus

  • TID du thread

Puis effectue son « travail » (avec sleep) et fini par afficher qu’il a terminé sa tâche.

Résultat attendu :

$ gcc -Wall -Wextra -pedantic -g -pthread p5/p5e3.c && ./a.out 6 0
Main thread - PID: 67719 - TID: 67719 - nb_threads: 6 - seed: 0
thread 1 (PID: 67719 - TID: 67721) will work for 3 s
thread 0 (PID: 67719 - TID: 67720) will work for 4 s
thread 2 (PID: 67719 - TID: 67722) will work for 2 s
thread 4 (PID: 67719 - TID: 67724) will work for 2 s
thread 5 (PID: 67719 - TID: 67725) will work for 4 s
thread 3 (PID: 67719 - TID: 67723) will work for 4 s
thread 2 (PID: 67719 - TID: 67722) finished its 2 s work
thread 4 (PID: 67719 - TID: 67724) finished its 2 s work
thread 1 (PID: 67719 - TID: 67721) finished its 3 s work
thread 0 (PID: 67719 - TID: 67720) finished its 4 s work
thread 3 (PID: 67719 - TID: 67723) finished its 4 s work
thread 5 (PID: 67719 - TID: 67725) finished its 4 s work

p5e5 - Data race volontaire : compteur global sans protection

Créer 4 threads qui incrémentent 1 000 000 fois chacun une variable globale. Affichez le compteur à la fin.

Quelle devrait être la valeur du compteur ? Pourquoi se décalage ?

Testez avec valgrind --tool=helgrind ./a.out ou valgrind --tool=drd ./a.out (ils « corrigeront » l’erreur pendant l’exécution mais afficheront les raisons de l’erreur).

Qu’est ce que vous comprenez de l’erreur ?

(une data race en C = comportement indéfini (UB, Undefined Behavior), donc les résultats peuvent varier, et parfois être « corrects » par hasard.)

p5e6 - Atomic : rendre le compteur correct

Reprendre p5e4 mais utiliser un atomic_int (inclure stdatomic.h) pour protéger la modification.

Testez avec valgrind --tool=helgrind ./a.out.

p5e7 - Mutex : rendre le compteur correct

Reprendre p5e4 mais protéger l’incrément avec un mutex.

Testez avec valgrind --tool=helgrind ./a.out.

p5e8 - Pattern producteur/consommateur

En partant de l”exemple du cours sur les variables conditionnelles, proposez un programme qui utilise deux threads, un producteur qui va mettre des variables dans un tableau et un consommateur qui va lire les variables et les afficher.

Vous aurez besoin de :

  • une taille de tableau TAILLE (à déclarer avec un #define)

  • un tableau d”int de taille TAILLE

  • un compteur d’éléments dans le tableau (count = 0)

  • un index en écriture pour savoir dans quelle case le producteur va ensuite écrire (idx_in = 0)

  • un index en lecture pour savoir dans quelle case le consommateur va ensuite lire (idx_out = 0)

  • un mutex

  • une variable de condition pour savoir si le tableau est non plein

  • une variable de condition pour savoir si le tableau est non vide

Le producteur va déclarer un int item = 0 puis commencer une boucle infinie dans laquelle il va :

  • bloquer le mutex

  • attendre tant que le tableau est plein (le compteur d’éléments dans le tableau égal à la taille du tableau) et se réveille en cas de réception de la variable de condition de tableau non plein

  • produit un élément dans le tableau (met l”item à l’indice où il est rendu avec son compteur idx_in)

  • incrémente son compteur dans les limites du tableau (tableau circulaire)

  • incrémente le compteur d’éléments dans le tableau

  • affiche "[producteur] produit #item# (count=#count#)"

  • notifie que le tableau n’est pas vide

  • relâche le mutex

  • dors un temps aléatoire entre 100000 et 500000 microsecondes (avec la fonction usleep, #define _GNU_SOURCE au début du fichier et importer unistd.h)

Le consommateur va commencer une boucle infinie dans laquelle il va :

  • bloquer le mutex

  • attendre tant que le tableau est vide (count = 0) et se réveille en cas de réception de la variable de condition de tableau non vide

  • récupère l’élément à l’indice idx_out

  • incrémente idx_out dans les limites du tableau (tableau circulaire)

  • décrémente le compteur d’éléments dans le tableau

  • affiche "[consommateur] consomme #item# (count=#count#)"

  • notifie que le tableau n’est pas plein

  • relâche le mutex

  • dors un temps aléatoire entre 100000 et 500000 microsecondes

p5e9 - Somme parallèle d’un grand tableau

Créez un main demandant :

  • deux ranges minimum et maximum (int64_t r_min; int64_t r_max;).

  • une random seed (graine générateur aléatoire, unsigned int rand_seed;)

  • une taille de tableau (uint64_t taille;, conversion avec atoll)

  • un nombre de thread (uint64_t nb_thread;)

Ensuite, le main :

  • alloue dynamiquement le tableau de taille taille puis le remplit avec des valeurs aléatoires entre [r_min, r_max]

  • crée nb_thread threads, où chacun somme un segment du tableau [start, end[ et retourne sa somme (par exemple, t1 calcule de 0 à 10000, t2 de 10000 à 20000, … si la division ne tombe pas sur un entier, le dernier thread prend les cases « en plus »)

Le main additionne les sommes partielles et affiche le résultat et le temps de calcul.

Tester différents paramètres et mesurer le gain de temps en faisant varier le nombre de threads.