Les infrastructures informatiques d'aujourd'hui offrent des composantes multi coeurs et multi processeurs qu'il serait absurde de ne pas exploiter. Sans entrer dans un parallélisme extrême, il existe de nombreuses situations pour lesquelles les mécanismes asynchrones peuvent exploiter à leur avantage ces architectures.
Techniquement, un thread est défini par un ensemble d'instructions qui peut être exécuté indépendamment par le système d'exécution. Cela signifie simplement que, du point de vue du développeur, si le code est correctement structuré, certaines parties du programme pourraient tirer avantage d'être exécutées de manière asynchrone, déplacées sur un ou plusieurs processeurs sans forcer le programme principal à attendre un éventuel retour de ces dernières avant de continuer son exécution. Le résultat serait récupéré uniquement lorsque celui-ci serait disponible, ne bloquant donc pas le programme principal.
Avant tout, rappelons-nous ce qu'est un processus : c'est globalement un ensemble d'informations relatives à l'exécution d'un programme que le système d'exploitation réserve au démarrage de ce programme. L'exécution d'un processus impose une certaine charge en mémoire et processeur pour démarrer et mettre en place les éléments suivants :
Avant l'apparition des threads, il était d'usage de n'avoir que des programmes en version "monolithique" ou exploitant un mécanisme de forking pour dupliquer l'ensemble du code et n'exploiter qu'une petite partie de ce qui a été dupliqué. Le problème de cette méthode est de provoquer un overhead important lors de cette duplication, ainsi que de dupliquer l'ensemble des ressources, ne permettant pas toujours le partage des informations entre les différentes parties dupliquées.
Les threads, à contrario, n'ont pas besoin d'être créés car ils existent déjà au coeur des processus. Ils ne dupliquent que le minimum requis pour leur fonctionnement et existent tant que leur parent existe. Il n'y a pas de processus zombie (errant) dans le système pour lequel traiter spécialement leur recherche et suppression. Ils ne dupliquent que les ressources essentielles pour être planifiées indépendamment et peuvent partager des ressources du processus principal avec d'autres threads.
Dans cette partie nous traiterons des threads UNIX, qui sont devenus la norme POSIX 1003.1c en 1995. Actuellement tous les systèmes disposent d'une compatibilité avec les threads POSIX, en plus des threads qui sont nativement mis à disposition par le système d'exploitation. De par leur structure, les threads POSIX (que nous appellereons PThreads) ont une interface native avec le langage C (et C++ par la même occasion). Tous les exemples seront donc codés en langage C. Nous verrons probablement dans un autre paragraphe le fonctionnement des threads Windows à travers le langage C#.
L'intérêt principal de l'usage des PThreads est d'augmenter les performances globales d'un programme.
En comparant les différentes méthodes de création et de gestion des processus, un thread peut être
créé en requérant beaucoup moins de ressources.
Tous les threads partagent le même espace d'adressage, rendant la communication inter-thread plus
efficace que la communication inter-processus.
Différents tests menés par diverses sources montrent que, pour la création de 50 000 processus, et
selon l'architecture utilisée, les gains de performances vont de 10% à 150% en utilisant des threads
à la place des processus.
De même, si l'on compare les modes d'échange d'informations entre les processus (mémoire partagée
impliquant au moins une opération de copie mémoire) et les threads (transfert direct depuis le même
espace d'adressage que le processeur), nous obtenons des performances générale améliorées entre
300% et 5000% en faveur des threads.
Nous avons déjà annoncé précédemment que les threads tiraient pleinement avantage des architectures nouvelles à base de multiprocesseurs et multicoeurs. Pour cela il faut tout de même que le programme soit pensé pour en tirer partie :
Lorsque l'on parle de données "thread safe", cela signifie que les threads ne vont pas aller modifier les données des autres threads ou créer des conditions de blocage (race conditions). Il est important de bien gérer la synchronisation entre tous les threads, notamment lors de l'usage de bibliothèques n'étant pas elles-mêmes "thread-safe".
L'API POSIX PThreads a été définie par l'IEEE en 1995 sous le terme 1003.1. Ce standard continue cependant à évoluer pour prendre en comptees les apports des nouvelles architectures. Les sous-routines sont groupées en quatre groupes :
Sans entrer dans l'énumération de tous les compilateurs utilisables sur l'ensemble des systèmes d'exploitation, citons simplement :
Au démarrage du programme, il n'y a qu'un seul thread initié par le système d'exploitation. Si vous souhaitez mettre en place d'autres threads, ce sera à vous, développeur, de les implémenter.
La fonction pthread_create() créé un nouveau thread et le rend exécutable. Celle-ci peut être appelée autant de fois que vous le souhaitez. Les arguments de la fonction sont les suivants :
Par défaut un thread est créé avec certains attributs, qui pourront être changés par programmation en utilisant les fonctions pthread_attr_init() et pthread_attr_destroy. Les attributs sont parmi :
Il y a plusieurs façon de terminer l'exécution d'un thread :
Dans cet exemple nous allons créer 5 threads qui ne vont faire qu'afficher "Hello world" avec un paramètre en mode console. La conversion 64 bits entre les int et les pointeurs doit s'effectuer avec un type d'entier qui supporte la notation 64 bits. Il est conseillé d'utiliser le type intptr_t prévu à cet effet de conversion de type sous peine d'avoir des messages d'erreur du genre [-Wpointer-to-int-cast] causés par le compilateur GCC.
#include <pthread.h>
#include <stdio.h>
#define NUM_THREADS 5
/*
* Fonction mise en thread et disposant d'un paramètre sous la forme d'un pointeur sur void
*/
void *print_hello(void *thread_id)
{
intptr_t tid;
tid = (intptr_t) thread_id; // Récupère la valeur du paramètre
printf("Hello world. Je suis le thread numero #%ld\n", tid);
pthread_exit(NULL);
}
int main(int argc, char *argv[])
{
pthread_t threads[NUM_THREADS]; // Tableau de 5 identifiants de threads
int rc;
intptr_t t; // Pour la conversion 64 bts entre pointeurs (8 octets) et int (4 octets)
for (t = 0 ; t < NUM_THREADS ; t++)
{
printf("Dans main(): creation du thread %ld\n", t);
/*
* Création du thread avec la fonction pthread_create()
* Le premier paramètre est un pointeur qui sera rempli lorsque pthread_create() aura
* créé la structure du thread.
* Le second paramètre représente les attributs du thread. Ici, avec NULL, nous utilisons
* les paramètres par défaut de création d'un thread.
* Le troisième paramètre est la start_routine, qui sera appelée par le thread. C'est toujours
* une fonction de type (void *).
* Le dernier paramètre est le paramètre passé à la fonction du thread. Il doit être forcé
* du type (void *) également.
*/
rc = pthread_create(&threads[t], NULL, print_hello, (void *) t);
if (rc)
{
printf("ERREUR: code de retour de pthread_create() = %d\n", rc);
exit(-1);
}
}
/*
* Pour faire propre, nous attendons que tous les threads aient terminé leur exécution avant
* de quitter le programme.
* Cela va éviter d'avoir des ressources non fermées
*/
for (t = 0 ; t < NUM_THREADS ; t++)
{
pthread_join(threads[t], NULL);
}
pthread_exit(NULL);
}
La fonction pthread_create() permet de passer un seul paramètre à la fonction mise en thread. Pour outrepasser cette limitation, il suffit de passer l'ensemble sous forme d'un pointeur sur une structure C.
Joindre un thread correspond à une des façons de synchroniser des threads entre eux. La fonction pthread_join() bloque le thread appelant jusqu'à ce que le thread de numéro thread_id se termine. Il existe deux autres méthodes de synchronisation des threads qui seront discutées ultérieurement : les mutexes et les variables conditionnelles.
Lorsqu'un thread est créé, un de ses attributs définit si oui ou non le thread est synchronisable ou est détachable. Seuls les threads définis comme synchronisables (joignable) peuvent l'être. Si un thread est défini comme détachable, il ne pourra jamais être synchronisé. La norme POSIC précise que tous les threads devraient être définis comme joignables.
Dans cet exemple nous allons montrer comment "attendre" la terminaison d'un thread en utilisant la fonction de synchronisation pthread_join(). Puisque certaines implémentations de la bibliothèque PThread pourraient ne pas créer les threads joignables par défaut, nous allons les forcer en tant que telles.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#define NUM_THREADS 4
void *do_work(void *t)
{
int i;
intptr_t tid;
double result = 0.0;
tid = (intptr_t)t;
printf("Le thread %ld est en cours de démarrage...\n", tid);
for (i = 0; i < 100000 ; i++)
{
result = result + sin(i) * tan(i);
}
printf("Terminaison du thread %ld. Le résultat est %e\n", tid, result);
pthread_exit((void *)t);
}
int main(int argc, char *argv[])
{
pthread_t threads[NUM_THREADS];
pthread_attr_t attr;
int rc;
intptr_t t;
void *status;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
for (t = 0; t < NUM_THREADS; t++)
{
printf("Main : création du thread %ld\n", t);
rc = pthread_create(&threads[t], &attr, do_work, (void *)t);
if (rc)
{
printf("Erreur: code de retour de pthread_create() : %d\n", rc);
exit(-1);
}
}
// Libère les attributs et attend les autres threads
pthread_attr_destroy(&attr);
for (t = 0; t < NUM_THREADS; t++)
{
rc = pthread_join(threads[t], &status);
if (rc)
{
printf("Erreur : code de retour de pthread_join() : %d\n", rc);
exit(-1);
}
printf("Main: join terminé avec le thread %ld avec un status de %ld\n", t, (intptr_t)status);
}
printf("Main: programme terminé.\n");
pthread_exit(NULL);
}
La norme POSIX n'impose pas de taille particulière pour la pile d'un thread. C'est chaque implémentation de chaque système qui en a la charge. Comme toujours, si l'on dépasse la taille de la pile allouée au thread, vous obtiendrez une terminaison anormale avec ou non corruption des données. Pour garantir le bon fonctionnement d'un thread, il est plus prudent de procéder à l'allocation mémoire requise par le thread via la routine pthread_attr_setstacksize(). Les fonctions de gestion de l'emplacement de la pile ne sont à utiliser que lorsque des systèmes requièrent une gestion particulière de l'adressage mémoire.
Comme toutes les gestions de piles n'offrent pas les mêmes caractéristiques, le tableau ci-après rappelle globalement les éléments pris en compte dans la mise en place des piles de threads.
Architecture | # CPUs | RAM (Go) | Taille par défaut (en octets) |
Intel Xeon E5-2670 | 16 | 32 | 2 097 152 |
AMD Opteron | 8 | 16 | 2 097 152 |
Intel IA64 | 4 | 8 | 33 554 432 |
IBM Power 5 | 8 | 32 | 196 608 |
Dans cet exemple nous allons montrer comment interroger et spécifier la taille de le pile pour un thread.