Dans la série des threads Linux, nous avons discuté des manières dont un thread peut se terminer et de la manière dont le statut de retour est transmis du thread de terminaison à son thread parent. Dans cet article, nous allons éclairer un aspect important connu sous le nom de synchronisation des threads.
Linux Threads Series :partie 1, partie 2, partie 3, partie 4 (cet article).
Problèmes de synchronisation de thread
Prenons un exemple de code pour étudier les problèmes de synchronisation :
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; void* doSomeThing(void *arg) { unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); return NULL; } int main(void) { int i = 0; int err; while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); return 0; }
Le code ci-dessus est simple dans lequel deux threads (tâches) sont créés et dans la fonction de démarrage de ces threads, un compteur est maintenu à travers lequel l'utilisateur obtient les journaux sur le numéro de travail qui est démarré et quand il est terminé. Le code et le flux ont l'air bien mais quand on voit la sortie :
$ ./tgsthreads Job 1 started Job 2 started Job 2 finished Job 2 finished
Si vous vous concentrez sur les deux derniers journaux, vous verrez que le journal "Travail 2 terminé" est répété deux fois alors qu'aucun journal pour "Travail 1 terminé" n'est affiché.
Maintenant, si vous revenez au code et essayez de trouver une faille logique, vous ne trouverez probablement pas de faille facilement. Mais si vous regardez de plus près et visualisez l'exécution du code, vous constaterez que :
- Le journal "Job 2 started" est imprimé juste après "Job 1 Started". Il est donc facile de conclure que pendant le traitement du thread 1, le planificateur a programmé le thread 2.
- Si l'hypothèse ci-dessus était vraie, la valeur de la variable "compteur" a été incrémentée à nouveau avant la fin de la tâche 1.
- Ainsi, lorsque la tâche 1 s'est réellement terminée, la mauvaise valeur du compteur a produit le journal "Tâche 2 terminée" suivi de "Tâche 2 terminée" pour la tâche réelle 2 ou vice versa, car cela dépend du planificateur.
- Nous voyons donc que ce n'est pas le journal répétitif mais la mauvaise valeur de la variable "counter" qui pose problème.
Le vrai problème était l'utilisation de la variable 'counter' par le deuxième thread lorsque le premier thread l'utilisait ou était sur le point de l'utiliser. En d'autres termes, nous pouvons dire que le manque de synchronisation entre les threads lors de l'utilisation du "compteur" de ressources partagées a causé les problèmes ou, en un mot, nous pouvons dire que ce problème est dû à un "problème de synchronisation" entre deux threads.
Mutex
Maintenant que nous avons compris le problème de base, discutons de la solution. Le moyen le plus populaire de réaliser la synchronisation des threads consiste à utiliser des mutex.
Un mutex est un verrou que nous définissons avant d'utiliser une ressource partagée et que nous relâchons après l'avoir utilisée. Lorsque le verrou est défini, aucun autre thread ne peut accéder à la région de code verrouillée. Nous voyons donc que même si le thread 2 est planifié alors que le thread 1 n'a pas fini d'accéder à la ressource partagée et que le code est verrouillé par le thread 1 à l'aide de mutex, le thread 2 ne peut même pas accéder à cette région de code. Cela garantit donc un accès synchronisé des ressources partagées dans le code.
En interne, cela fonctionne comme suit :
- Supposons qu'un thread ait verrouillé une région de code à l'aide de mutex et exécute ce morceau de code.
- Maintenant, si le planificateur décide de faire un changement de contexte, alors tous les autres threads qui sont prêts à exécuter la même région sont débloqués.
- Un seul de tous les threads arriverait à l'exécution, mais si ce thread essaie d'exécuter la même région de code qui est déjà verrouillée, il se remettra en veille.
- Le changement de contexte aura lieu encore et encore, mais aucun thread ne pourra exécuter la région de code verrouillée tant que le verrou mutex ne sera pas libéré.
- Le verrou mutex ne sera libéré que par le thread qui l'a verrouillé.
- Ainsi, cela garantit qu'une fois qu'un thread a verrouillé un morceau de code, aucun autre thread ne peut exécuter la même région jusqu'à ce qu'il soit déverrouillé par le thread qui l'a verrouillé.
- Par conséquent, ce système assure la synchronisation entre les threads tout en travaillant sur des ressources partagées.
Un mutex est initialisé puis un verrou est réalisé en appelant les deux fonctions suivantes :
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); int pthread_mutex_lock(pthread_mutex_t *mutex);
La première fonction initialise un mutex et grâce à la seconde fonction, toute région critique du code peut être verrouillée.
Le mutex peut être déverrouillé et détruit en appelant les fonctions suivantes :
int pthread_mutex_unlock(pthread_mutex_t *mutex); int pthread_mutex_destroy(pthread_mutex_t *mutex);
La première fonction ci-dessus libère le verrou et la deuxième fonction détruit le verrou afin qu'il ne puisse plus être utilisé nulle part à l'avenir.
Un exemple pratique
Voyons un morceau de code où les mutex sont utilisés pour la synchronisation des threads
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; pthread_mutex_t lock; void* doSomeThing(void *arg) { pthread_mutex_lock(&lock); unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); pthread_mutex_unlock(&lock); return NULL; } int main(void) { int i = 0; int err; if (pthread_mutex_init(&lock, NULL) != 0) { printf("\n mutex init failed\n"); return 1; } while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); pthread_mutex_destroy(&lock); return 0; }
Dans le code ci-dessus :
- Un mutex est initialisé au début de la fonction principale.
- Le même mutex est verrouillé dans la fonction "doSomeThing()" lors de l'utilisation de la ressource partagée "counter"
- A la fin de la fonction 'doSomeThing()', le même mutex est déverrouillé.
- À la fin de la fonction main lorsque les deux threads sont terminés, le mutex est détruit.
Maintenant, si nous regardons la sortie, nous trouvons :
$ ./threads Job 1 started Job 1 finished Job 2 started Job 2 finished
Nous voyons donc que cette fois, les journaux de début et de fin des deux travaux étaient présents. La synchronisation des threads a donc eu lieu grâce à l'utilisation de Mutex.