Cette partie du cours s'inspire grandement du tutoriel sur les pThread (où POSIX threads) du LLNL)
Historiquement plusieurs versions spécifiques de l'implantation des threads ont été créé ce qui posa de nombreux problèmes de portabilité du code. Pour UNIX une interface standard IEEE POSIX 1003.1c a été créé en 1995.
Ce qui différencie un thread d'un processus est que le thread est beaucoup plus léger c'est d'ailleurs pour cela que l'on qualifie souvent les threads de processus légers càd qu'ils demandent moins de ressources qu'un processus lors de sa création et sa gestion. Les threads sont créé à l'intérieur d'un processus et n'utilisent que le strict nécessaire pour leur exécution. On rappelle qu'un processus prend en compte :
des droits : un numéro de processus, l'identifiant du groupe de l'utilisateur, ...
un répertoire de travail
une pile d'exécution
la gestion du tas (heap)
des descripteurs de fichiers
la gestion des signaux
l'utilisation des librairies partagées
gestion des pipes, sémaphores, de la mémoire partagée
Pour simplifier, le thread, quant à lui, nécessite une pile d'exécution et partage les ressources du processus.
La création de 50000 processus/thread donne
les temps suivants :
time
fork
pthread
real
8.1
0.9
user
0.1
0.2
sys
2.9
0.3
total
11.1
1.4
μs/thread
222
28
Temps de création (s) de 50000 processus/threads sur Intel 2.6 GHz Xeon E5-2670
Pour utiliser les threads il faut :
compiler avec le fichier entête : #include <pthread.h>
réaliser l'édition de liens avec -lpthread (GCC/UNIX) ou -pthread selon les systèmes
utiliser pthread_exit(NULL); à la fin de la methode main
3.1.1. Création de POSIX threads
La gestion des threads est laissée à l'utilisateur : il faut créer les threads ainsi qu'une structure de données qui contient les données liées au thread.
Voici un premier exemple. On passe à chaque thread une structure de données qui contient l'identifiant local du processus donné par l'utilisateur ainsi qu'un message à afficher.
La fonction pthread_self() donne l'identifiant du thread donné par le système.
On définit une fonction CHECK qui permet de vérifier la bon déroulement de l'exécution des fonctions liée aux threads.
L'exécution du programme donne le résultat suivant qui est illisible car l'ensemble des threads partage le même flux de sortie (cout) et ils écrivent en même temps. De plus le programme se termine avant que les threads n'aient terminé leur exécution :
Un première amélioration peut être apportée afin que le programme ne termine pas avant que l'ensemble des threads n'aient terminé leur exécution. Il s'agit de faire un join.
// this line won't be executed until all threads have terminated
// their execution
cout<<"!!! END OF PROGRAM !!!!"<< endl;
pthread_exit(NULL);
return0;
}
thread(thread(thread(139750116677376139750108284672) ) 139750125070080, id=thread() , id=139750099891968) , id=230, msg=[, msg=[, msg=[coucou 183coucou 177], repeat=coucou 1155]], repeat=, repeat=55
...
!!! END OF PROGRAM !!!!
3.1.3. verrou (mutex) pour l'exclusion mutuelle
Enfin, afin d'améliorer la lisibilité on utilise un mutex, abréviation pour mutual exclusion. Un mutex est un verrou qui ne pourra être fermé (lock) que par un seul thread à la fois, puis ouvert (unlock). Le verrou permet de créer l'exclusion mutuelle d'une ressource pour les sections critiques.
// this line won't be executed until all threads have terminated
cout<<"!!! END OF PROGRAM !!!!"<< endl;
// destroy mutex
CHECK( pthread_mutex_destroy(&mutex));
pthread_exit(NULL);
return0;
}
3.1.5. variables de condition
Les condition variables sont des primitives de synchronisation qui permettent aux threads de se mettre en attente jusqu'à ce
qu'une condition particulière soit vérifiée. Elles fonctionnent de concert avec les verrous.
Typiquement le code utilisé suit le schéma suivant où cond est la variable de condition :
// code en attente de la réalisation de la condition
pthread_mutex_lock(&lock);
while (SOME-CONDITION is false) {
pthread_cond_wait(&cond, &lock);
}
do_something();
pthread_mutex_unlock(&lock);
// code qui réalise la condition
pthread_mutex_lock(&lock);
ALTER-CONDITION
// réveille les threads en attente de la réalisation de la condition
pthread_cond_signal(&cond);
// levée du verrou
pthread_mutex_unlock (&lock)
Un certain nombre de fonctions sont dédiée à la gestion de ces variables :
pthread_cond_init(pthread_cond_t *cv,
const pthread_condattr_t *cattr); qui permet de créer une variable de condition
pthread_cond_destroy(pthread_cond_t *cv); qui permet de détruire une variable de condition
pthread_cond_wait(pthread_cond_t *cv, pthread_mutex_t *mutex);
qui permet de se mettre dans un état d'attente d'une variable de condition
pthread_cond_signal(pthread_cond_t *cv);
qui permet de signaler qu'une variable de condition vient de changer d'état
pthread_cond_timedwait(pthread_cond_t *cv, pthread_mutex_t *mutex, const struct timespec *abstime);
qui permet de se mettre dans un état d'attente d'une variable de condition pendant un temps maximum
pthread_cond_broadcast(pthread_cond_t *cv);
qui permet de débloquer tous les threads en attente du changement d'une variable de condition
Voici à titre d'exemple le programme précédent avec utilisation des variables de condition. On utilise ici une condition qui indique si le nombre de pneus et de moteurs est suffisant pour produire une voiture.
Lorsque l'on utilise $K$ threads, il se peut que dans certains cas l'affectation des threads à des coeurs du processeur pose problème notamment sur les systèmes SMP.
Considérons le cas de l'Intel Xeon E5 2670 qui dispose de 10 coeurs + Hyperthreading. Dans le cas d'un système SMP composé de deux processeurs de ce type la répartition des coeurs est la suivante :
id
Cpu
Core
0
0
0
1
1
0
2
0
1
3
1
1
...
20
0 (HT)
0
21
1 (HT)
0
...
Identification des coeurs sur Xeon E5 2670
En d'autres termes, les threads se répartissent ainsi :
Si on doit utiliser 4 threads, on obtiendra des temps de calcul différents si l'affectation des threads aux coeurs physiques est réalisée de l'une des manières suivantes :
coeurs du même processeur : 0, 2, 4, 6
coueurs HT du même processeur : 20, 22, 24, 26
coeurs de deux processeurs : 0, 1, 2, 3
coeurs HT de deux processeurs : 20, 21, 22, 23
Pour réaliser l'affectation des threads on peut utiliser la commande :
A titre d'application on considère le problème de Maximum de Parcimonie, l'exécution avec un nombre croissant de threads donne les temps suivants sur un noeud Bull Novascale R422 du cluster taurus du LERIA
#threads
1
2
4
8
10
12
14
16
18
20
40
temps (s)
207
120
67
41
36
36
39
43
47
49
60
facteur
-
1.72
3.08
5.04
5.75
-
-
-
-
-
-
pourcentage
-
- 42%
- 68%
- 80%
- 83%
- 83%
- 81%
- 79%
- 77%
- 76%
- 71%
Temps d'exécution (s) sur Xeon E5 2670
Une étude aprofondie montre que
jusqu'à 10 (voire 12) threads on note une diminution du temps de calcul, au delà on dégrade les performances
sans l'HyperThreading : que l'on soit sur un seul CPU ou sur deux CPU on obtient les mêmes résultats
8 threads : 41 secondes avec taskset -c 0-7 ou taskset -c 0,2,4,6,8,10,12,14
10 threads : 36 secondes avec taskset -c 0-9 ou taskset -c 0,2,4,6,8,10,12,14,16,18
avec l'HyperThreading : on dégrade les performances
8 threads : 1m08s avec taskset -c 0-3,20-23 (4C + 4HT sur le même CPU)
10 threads : 1m06s avec taskset -c 0-4,20-24 (5C + 5HT sur le même CPU)
3.3.1. Coeurs Performance et Efficiency
Chez Intel avec l'arrivée de processeurs dotés de deux type de coeurs, les tests sur machine deviennent problématiques.
Il est primordial de choisir les coeurs adéquats.
Pour rappel, certains processeurs sont dotés de deux types de coeurs :
les coeurs P (Performance) qui sont les plus efficaces et qui sont parfois doté de l'Hyperthreading
les coeurs E (Efficiency) moins efficaces que les coeurs P et qui ne comportent pas d'Hyperthreading
Pour déterminer quels identifiants sont liés aux coeurs P ou E, il faut utiliser la commande suivante :
ou alors utiliser la commande lstopo du package hwloc.
C'est normalement P#n° qui correspond à la numérotation des coeurs.
3.3.1.a Cas de l'Intel I7 12700
L'Intel i7-12700
dispose de 8 coeurs P (+ HT) et 4 coeurs E, soit un total de 20 threads.
En appliquant la commande précédente, on obtient :
processor : 0 core id : 0 coeur P
processor : 1 core id : 0 coeur P HTprocessor : 2 core id : 4 coeur P
processor : 3 core id : 4 coeur P HTprocessor : 4 core id : 8 coeur P
processor : 5 core id : 8 coeur P HTprocessor : 6 core id : 12 coeur P
processor : 7 core id : 12 coeur P HTprocessor : 8 core id : 16 coeur P
processor : 9 core id : 16 coeur P HTprocessor : 10 core id : 20 coeur P
processor : 11 core id : 20 coeur P HTprocessor : 12 core id : 24 coeur P
processor : 13 core id : 24 coeur P HTprocessor : 14 core id : 28 coeur P
processor : 15 core id : 28 coeur P HTprocessor : 16 core id : 36 coeur E
processor : 17 core id : 37 coeur E
processor : 18 core id : 38 coeur E
processor : 19 core id : 39 coeur E
les coeurs P sont numérotés : 0, 2, 4, 6, 8, 10, 12, 14
les coeurs P HT sont numérotés : 1, 3, 5, 7, 9, 11, 13, 15
les coeurs E sont numérotés : 16, 17, 18, 19
3.3.1.b Cas de l'AMD Ryen 5 5600G
L'AMD Ryzen 5 5600g est doté de 6 coeurs dotés
du Simultaneous Multithreading (SMT) soit un total de 12 threads.
processor : 0 core id : 0
processor : 1 core id : 1
processor : 2 core id : 2
processor : 3 core id : 3
processor : 4 core id : 4
processor : 5 core id : 5
processor : 6 core id : 0
processor : 7 core id : 1
processor : 8 core id : 2
processor : 9 core id : 3
processor : 10 core id : 4
processor : 11 core id : 5