• Aucun résultat trouvé

Les threads, briques de base du parallélisme de bas niveau

4.5 Évaluation de l’approche

5.1.1 Les threads, briques de base du parallélisme de bas niveau

Les threads, ou fils d’exécution, représentent des sous-processus légers pouvant s’exécuter en concurrence, selon les ressources matérielles disponibles. Tous les threads créés lors de l’exécution d’un processus ont accès à la mémoire de ce processus. Pendant sa durée de vie, chaque thread dispose de sa propre pile d’instructions, indépendante de celle du processus maître. Pour éviter des conflits dans l’accès à cette mémoire, des primitives de synchronisation permettent de contraindre l’accès à certaines zones mémoire partagées.

Les programmes traditionnels s’exécutant séquentiellement ne comportent qu’un seul thread/processus tout au long de leur exécution. Puisqu’en général, un programme ne peut jamais être totalement parallélisé, un cas d’usage basique de threads consiste à accélérer certaines portions de code denses et intenses en calcul, comme des boucles. En pratique, au cours de son exécution, un programme multi-threadé va créer plusieurs threads qui vont 54

5.1. Programmation parallèle sur architecture à mémoire partagée (a) (b) (d) (e) (c)

Figure 5.2 – Exemple d’un programme utilisant des threads : (a) le programme est lancé et il ne comporte qu’un seul thread ; (b) quatre threads sont démarrés par le thread principal ; (c) les cinq threads se partagent le travail ; (d) le thread principal attend la fin des autres threads ; (e) le programme se termine avec un seul thread

se partager les calculs du code ainsi parallélisé. À la fin de leur exécution, ils rendent, en général, la main au thread principal. Ce fonctionnement est illustré figure 5.2.

De nouvelles contraintes

Si la programmation parallèle par threads promet une réduction des temps de calcul en utilisant les cœurs disponibles, cela ne se fait pas sans contraintes. En effet, si, dans les programmes traditionnels, sans parallélisme, les instructions sont généralement exécutées dans l’ordre dans lequel elles apparaissent, ce n’est plus le cas dans les programmes parallèles. Ceci introduit plusieurs problèmes qui doivent être pris en compte par les concepteurs et développeurs logiciels. On peut notamment citer les étreintes fatales (deadlocks) et les accès

concurrents (race conditions).

Accès concurrents Les accès concurrents interviennent lorsque deux ou plusieurs threads

veulent modifier simultanément une donnée à une même adresse mémoire. Un thread peut alors mettre à jour la donnée sans que le ou les autres threads ne répercutent cette modification, ce qui conduit logiquement à des erreurs de calcul souvent très difficiles à détecter, puisqu’elles dépendent de l’ordre d’exécution des threads. Une solution consiste à utiliser un système de verrou, qui empêche plusieurs threads d’écrire simultanément à la même adresse mémoire, au prix d’une perte de performance.

Étreintes fatales Dans un système utilisant des verrous, des étreintes fatales interviennent

si deux threads veulent chacun accéder à une ressource verrouillée par l’autre thread. Ces threads sont alors bloqués dans leur exécution ; le programme ne peut donc s’exécuter correctement. Pour éviter les étreintes fatales, il faut éviter de verrouiller deux données en même temps.

Outre ces nouvelles classes de bogues pouvant intervenir dès que plusieurs threads entrent en jeu, la programmation parallèle nécessite également de maîtriser de nouvelles interfaces de programmation permettant un contrôle explicite ou implicite des threads. Actuellement, deux interfaces standard pour la gestion des threads coexistent :

5. Parallélisme multiprocesseur à mémoire partagée

— POSIX Threads est une interface de programmation commune aux systèmes d’ex- ploitation Unix et Linux qui fournit un ensemble de routines permettant de gérer explicitement les threads ;

— OpenMP [19] est une norme qui repose sur des directives préprocesseur offrant une interface de plus haut niveau.

Le standard POSIX

POSIX est un ensemble de normes pour des interfaces de programmation de systèmes d’exploitation mis en place à la fin des années 1980. Parmi celles-ci, la norme POSIX Threads [66], standardisée en 1995, décrit une interface explicite de gestion des threads. Cette interface fournit au langage de programmation de bas niveau C un ensemble de routines permettant de gérer finement un ou plusieurs threads pendant leur durée de vie.

1 #include <stdio.h> 2 #include <pthread.h>

3

4 #define NTHREADS 8

5

6 void *task(void *arg) { 7 int thread_n = *(int *)arg;

8 printf("Hello world from thread %d\n", thread_n); 9 return NULL;

10 } 11

12 int main(void) {

13 pthread_t threads[NTHREADS]; 14 int arg[NTHREADS];

15 for (unsigned int i = 0; i < NTHREADS; i++) {

16 arg[i] = i;

17 pthread_create(&threads[i], NULL, task, &arg[i]); 18 }

19

20 for (unsigned int i = 0; i < NTHREADS; i++) {

21 pthread_join(threads[i], NULL); 22 }

23

24 return 0;

25 }

Extrait 5.1 – Exemple basique de programme C utilisant POSIX Threads : huit threads sont instanciés par le programme ; chaque thread affiche la chaîne de caractères « Hello world from thread “i” » avant de terminer son exécution

L’extrait 5.1 montre ainsi l’utilisation de threads au travers d’un exemple basique « hello

world ». La fonctionvoid *task(void *arg); est exécutée en parallèle par huit threads et, par conséquent, l’exécution du programme n’est plus déterministe : une autre exécution pourra montrer un autre ordre de complétion des threads. Dans cet exemple, les threads sont lancés par la routine

1 int pthread_create(pthread_t *thread, const pthread_attr_t *attr,

2 void *(*start_routine)(void *), void *arg);

à laquelle on passe un pointeur vers la fonction task, ainsi qu’un pointeur générique vers une structure ou un tableau d’arguments. Un objet pthread_t est alors alloué et servira à 56

5.1. Programmation parallèle sur architecture à mémoire partagée

1 Hello world from thread 2 2 Hello world from thread 3 3 Hello world from thread 4

4 Hello world from thread 5

5 Hello world from thread 6

6 Hello world from thread 7 7 Hello world from thread 0 8 Hello world from thread 1

Extrait 5.2 – Un résultat de l’exécution du programme extrait 5.1 : l’ordre des lignes n’est pas déterministe puisque tous les threads sont exécutés concurrem- ment ; chaque ligne est en revanche complète car chaque appel à la fonction

int printf(const char *fmt, ...); acquiert un verrou qui bloque l’accès à l’affi- chage aux autres threads

référencer et manipuler le thread. Le programme attend la fin de l’exécution des différents threads ainsi créés en appelant la routine

4 int pthread_join(pthread_t thread, void **retval);

Celle-ci permet également d’accéder aux structures renvoyées par l’exécution des threads, au travers de son argument retval.

Pour éviter les accès concurrents, la norme POSIX définit une structure de donnée appelée

mutex (abrégé de MUTual EXclusion) permettant de verrouiller un ensemble d’instructions.

Dans ce cas, un unique thread peut exécuter ces instructions à un instant donné, les autres restant bloqués dans leur exécution.

Outre la gestion des threads ainsi que les mutexes, l’interface POSIX Threads couvre également

— certaines propriétés des threads lors de leur exécution (adresse et taille de leur pile), — des conditions d’exécution de certains threads dépendant de la valeur de données

partagées et

— les barrières de synchronisation entre threads.

La richesse et l’exhaustivité de cette interface force néanmoins à une restructuration globale des programmes souhaitant l’utiliser pour des besoins non triviaux. Dans les cas où cette restructuration est trop coûteuse, l’utilisation de l’interface OpenMP peut s’avérer plus pertinente.

OpenMP, une norme fondée sur des directives préprocesseur

OpenMP [19] est une spécification de directives préprocesseur datant de 1997 et faci- litant le parallélisme pour architectures à mémoire partagée. Elle est compatible avec les langages de programmation Fortran et C. Au départ se réduisant à la parallélisation des boucles de calculfor, elle s’est développée et supporte aujourd’hui le paradigme gestion de tâches, la vectorisation ainsi que le déport des calculs sur accélérateur dans les architectures hétérogènes.

L’utilisation d’OpenMP ne contraint pas le développeur à modifier de façon extensive le code à paralléliser. Si le programme s’y prête, la parallélisation via OpenMP consiste à décorer les bouclesforpar la directive préprocesseur

5. Parallélisme multiprocesseur à mémoire partagée

Le code parallélisé sera alors automatiquement généré par le compilateur, qui va s’occuper d’effectuer un partitionnement de la boucle selon le nombre de threads disponibles, lancer et faire exécuter par chaque thread la portion de boucle correspondante. Évidemment, toutes les bouclesforne se parallélisent pas aussi simplement : par exemple, dès qu’une dépendance existe entre deux itérations, la parallélisation n’est pas possible.

1 #include <stdio.h>

2 #include <omp.h>

3

4 int main(void) { 5

6 #pragma omp parallel

7 {

8 printf("Hello world from thread %d\n", omp_get_thread_num()); 9 }

10

11 return 0; 12 }

Extrait 5.3 – Exemple de code C utilisant OpenMP : parallélisation de l’affichage « Hello world from thread “i” »

Le programme présenté en extrait 5.3 est identique au programme POSIX Threads de l’extrait 5.1, à ceci près que, dans le cas présent, c’est l’environnement d’exécution d’OpenMP qui fixe le nombre de threads qui seront utilisés. La sortie d’une exécution de ce programme, est similaire à celle disponible en extrait 5.2 et mérite le même commentaire.

1 void vecAdd(float *a, float *b, float *c, const unsigned int n) { 2 unsigned int i;

3

4 #pragma omp parallel for

5 for (i = 0; i < n; i++) 6 c[i] = a[i] + b[i]; 7 }

Extrait 5.4 – Exemple de code C utilisant OpenMP : addition de deux vecteurs L’extrait 5.4 est un exemple d’utilisation d’OpenMP afin de paralléliser des boucles de calcul, ici pour réaliser une addition de deux vecteurs. Les itérations vont être réparties sur les unités de calcul disponibles, divisant d’autant les temps d’exécution.

Des modèles de programmation alternatifs

Le parallélisme explicite offert par POSIX Threads et OpenMP s’est depuis quelque années effacé au profit d’autres modèles de programmation visant toujours les architectures à mémoire partagée.

Le modèle gestion de tâches disponible dans OpenMP, depuis la version 3.0 [96] datant de 2008, permet de paralléliser plus facilement des structures qu’on ne peut ramener à des boucles simples, par exemple des fonctions récursives. Ce modèle consiste à décrire un calcul sous la forme d’un graphe acyclique dirigé de tâches dépendantes, chaque tâche étant exécutée par un thread lorsque ses dépendances ont été elles-mêmes exécutées et que les ressources matérielles sont disponibles.

5.1. Programmation parallèle sur architecture à mémoire partagée

Le modèle de programmation flot de données, qui est étudié en détail au chapitre 6, est applicable aux architectures à mémoire partagée. OpenStream [101, 100] est ainsi une extension d’OpenMP offrant un parallélisme de type flot de données.

Depuis 2011, le support des architectures hétérogènes a été inclus dans la version 4.0 d’OpenMP. Une même application peut ainsi être exécutée automatiquement sur des accélérateurs matériels, si ceux-ci sont accessibles. Charge à l’environnement d’exécution OpenMP de déporter automatiquement les calculs.