Débogage pour la programmation concurrente — Contexte
Problèmes
La programmation concurrente est source d’erreurs qui sont difficiles à reproduire car leur apparition dépend d’un certains nombre de paramètres tels que l’ordonnanceur utilisé, le nombre de processus sur le système, … Il est donc nécessaire d’avoir des outils pour aider à les détecter. On retrouve principalement deux grands type d’erreurs : les situations de compétition et les interblocages.
Situation de compétition
Dans une situation de compétition deux threads accèdent à la même zone mémoire sans protection, et au moins un des accès est une écriture. Exemple avec le code suivant :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | #include <stdlib.h> #include <stdio.h> #include <pthread.h> struct T { int i; }; static struct T* foo = NULL; void init_foo(void) { if(!foo) foo = malloc(sizeof *foo); } static void* thread1(void* dummy) { (void)dummy; init_foo(); return NULL; } static void* thread2(void* dummy) { (void)dummy; init_foo(); return NULL; } |
Dans ce code, la variable foo
est partagée entre les deux threads
mais aucun mécanisme de synchronisation n’est mis en place pour en contrôler
l’accès. On peut donc avoir l’exécution suivante :
-
thread1
commence son exécution, il appelleinit_foo
; -
init_foo
commence son exécution et exécute le test à la ligne 12,foo
estNULL
donc on entre dans leif
; -
init_foo
est interrompu par l’ordonnanceur, il donne la main àthread2
; -
thread2
s’exécute et appelleinit_foo
à son tour ; -
init_foo
commence son exécution, exécute le test de la ligne 12,foo
estNULL
donc on entre dans leif
et on alloue la mémoire pourfoo
(ligne 13) ; -
l’ordonnanceur redonne la main à
thread1
qui reprend l’exécution d’init_foo
là où elle s’était arrêtée, donc ligne 13 (après le test, car celui-ci a été réalisé avant l’interruption). La mémoire est donc allouée une seconde fois.
On a donc un problème: malgré le if
, la mémoire est allouée deux
fois et la seconde allocation écrase l’adresse de la première ce qui provoque
une fuite de mémoire. Ce problème est dû à une situation de compétition
entre thread1
et thread2
pour la
variable foo
.
Interblocage
Pour résoudre ce problème on peut ajouter des verrous pour contrôler l’accès à
foo
. Mais cela doit être fait avec soin, sous peine de provoquer
un interblocage. Encore une fois, voici un petit code d’exemple (oui c’est
stupide, un seul verrou est nécessaire dans ce cas-là, mais c’est pour
illustrer l’interblocage) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | #include <stdlib.h> #include <stdio.h> #include <pthread.h> struct T { int i; }; pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER; static struct T* foo = NULL; void init_foo(void) { if(!foo) foo = malloc(sizeof *foo); } static void* thread1(void* dummy) { (void)dummy; /* Ordre A-B. */ pthread_mutex_lock(&mutexA); pthread_mutex_lock(&mutexB); init_foo(); pthread_mutex_unlock(&mutexB); pthread_mutex_unlock(&mutexA); return NULL; } static void* thread2(void* dummy) { (void)dummy; /* Ordre B-A => ordre différent de thread1 => /!\ Problème !!!! */ pthread_mutex_lock(&mutexB); pthread_mutex_lock(&mutexA); init_foo(); pthread_mutex_unlock(&mutexA); pthread_mutex_unlock(&mutexB); return NULL; } |
Dans ce code, l’ordre d’utilisation des verrous n’est pas cohérent et peux donc provoquer un interblocage si l’on a l’exécution suivante :
-
thread1
s’exécute, il acquiertmutexA
; -
thread1
est interrompu par l’ordonnanceur, il donne la main àthread2
; -
thread2
s’exécute, il acquiertmutexB
et attend la libération demutexA
(qui est en possession dethread1
) ; -
thread1
attend la libération demutexB
(qui est en possession dethread2
) ; - les deux threads s’attendent mutuellement, il y a interblocage.
On voit donc l’importance d’avoir des outils pouvant détecter ce genre d’erreur.
Solutions
Pour détecter les erreurs présentées dans la section précédente, il existe quatre grandes approches.
Model checking
Cette approche consiste à modéliser le système à vérifier sous la forme d’un automate fini. On obtient alors un graphe orienté où chaque nœud est associé à des propositions logiques (propositions qui ne peuvent qu’être vraies ou fausses) qui en décrivent l’état. Avec cette structure, on est en mesure de vérifier le respect de certaines propriétés, telle que l’absence d’interblocage. Le problème majeur de cette approche est l’explosion de l’espace des états, ce qui limite fortement la taille des systèmes vérifiables avec une puissance de calcul raisonnable et/ou dans un temps raisonnable.
Analyse statique
Dans cette approche on analyse le code source du programme pour essayer d’y trouver des erreurs. Le problème est qu’il n’est pas possible (dans un temps raisonnable) de trouver toutes les erreurs de ce genre car c’est un problème NP-difficile1. Un problème NP-difficile étant un problème au moins aussi difficile à résoudre qu’un problème NP (problème que l’on peut résoudre efficacement en un temps polynomial en utilisant une machine de Turing non déterministe).
Analyse dynamique
Cette analyse nécessite d’intercepter et de traiter certains évènements qui surviennent lors de l’exécution pendant l’exécution elle-même. L’inconvénient est que le traitement induit un ralentissement sensible de l’exécution.
Analyse post-mortem
Contrairement à l’approche précédente, on ne traite pas les évènements interceptés. On se contente de les enregistrer. Un enregistrement étant bien plus rapide qu’un traitement, le surcoût est plus faible et le ralentissement induit est donc négligeable dans la plupart des cas. Une fois l’exécution du programme terminée, on analyse les traces générées. Le problème ici est que les traces générées peuvent être d’une taille conséquente. En fait, si l’on veut traquer les erreurs de programmation concurrente, ce qui est notre cas ici, il est nécessaire d’enregistrer tous les accès mémoire et cela rend les traces très grosses.
Dans le prochain épisode…
Comme on peut le voir, aucune des approches n’est exempte d’inconvénients. Étant donné qu’il est plus facile d’étudier un outil open source (car on a accès au code), nous allons donc nous pencher sur la détection d’erreurs par analyse dynamique au travers de Valgrind et de ses différents outils dans les deux prochains articles.
-
Netzer, R. H., and Miller, B. P. What are race conditions? - some issues and formalizations. ACM Letters on Programming Languages and Systems 1 (1992), 74–88.↩