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
messageetvaleurainsi que le PID (getpid) et TID (gettid()et ajouter#define _GNU_SOURCE)le thread retourne
NULL, lemainfaitpthread_joinet 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”
intde tailleTAILLEun 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 compteuridx_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_SOURCEau début du fichier et importerunistd.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 viderécupère l’élément à l’indice
idx_outincrémente
idx_outdans 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 avecatoll)un nombre de thread (
uint64_t nb_thread;)
Ensuite, le main :
alloue dynamiquement le tableau de taille
taillepuis le remplit avec des valeurs aléatoires entre[r_min, r_max]crée
nb_threadthreads, 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.