• Aucun résultat trouvé

DérouIement et annulation d'un thread

Dans le document Programmation système en C sous (Page 153-156)

Un thread peut vouloir annuler un autre thread. Il envoie alors une demande d'annulation, qui sera prise en compte ou non, en fonction de la configuration du thread récepteur. Lorsqu'un thread est annulé, il se comporte exactement comme s'il invoquait la fonction pthread_exit( ) avec l'argument spécial PTHREAD_CANCELED. Lorsque le thread annulé se termine, il exécute toutes les fonctions de terminaison programmées, comme nous le verrons plus bas.

Le thread récepteur peut accepter la requête, la refuser ou la repousser jusqu'à atteindre un point d'annulation dans son exécution. Pour envoyer une demande d'annulation, on emploie la fonction pthread_cancel( ), qui renvoie 0 si elle réussit, ou l'erreur ESRCH si le thread visé n'existe pas ou plus.

int pthread_cancel (pthread_t thread);

La suppression d'un thread est un phénomène assez subtil. On y a recours généralement lorsqu'un thread n'a plus d'intérêt parce qu'on a obtenu par un autre moyen le résultat qu'il devait fournir, ou après un abandon demandé par l'utilisateur. Prenons l'exemple d'une application recherchant un nombre qui vérifie certaines propriétés1. Elle peut, pour tirer profit du parallélisme d'une machine multiprocesseur, scinder son espace de recherche en plusieurs portions et déclencher une série de threads, chacun explorant sa portion personnelle. Aussitôt qu'un thread aura trouvé la valeur recherchée, on pourra annuler ses confrères.

Toutefois, un thread manipule souvent des variables globales en employant des techniques de verrouillage que nous étudierons dans les prochains paragraphes. S'il se trouve annulé brutalement au moment de la mise à jour d'une variable globale, il peut la laisser dans un état instable ou abandonner un verrou en position bloquée. Il faut donc pouvoir interdire temporairement les demandes d'annulation dans certaines portions du code.

La fonction pthread_setcancelstate( ) permet de configurer le comportement du thread appelant vis-à-vis d'une requête d'annulation.

int phtread_setcancelstate (int etat_annulation, int * ancien_etat);

Les valeurs possibles sont les suivantes :

Nom Signification

PTHREAD_CANCEL_ENABLE Le thread acceptera les requêtes d'annulation (comportement par défaut).

PTHREAD_CANCEL_DISABLE Le thread ne tiendra pas compte des demandes d'annulation.

Les requêtes d'annulation ne sont pas mémorisées, contrairement aux signaux par exemple, ce qui fait d'un thread désactivant temporairement les requêtes pendant une zone de code critique ne se terminera pas lorsqu'il autorisera de nouveau les annulations, même si plusieurs demandes sont arrivées pendant ce laps de temps.

Il faut donc trouver un moyen plus souple pour empêcher l'annulation de se produire intempestivement, tout en acceptant les requêtes. Pour cela, on utilise tout simplement un méca-

nisme de synchronisation fondé sur un retardement des annulations jusqu'à atteindre des emplacements bien définis du code. Le thread ainsi configuré ne se terminera pas dès la réception d'une annulation, mais continuera de s'exécuter jusqu'à atteindre un point d'annulation — que nous allons définir ci-dessous. Pour configurer ce comportement, on emploie la fonction pthread_setcanceltype( ) dont le prototype est :

int phtread_setcanceltype (int type_ annulation, int * ancien_type);

Le type d'annulation peut correspondre à l'une des constantes suivantes :

Nom Signification

PTHREAD_CANCEL_DEFERRED Le thread ne se terminera qu'en atteignant un point d'annulation (comportement par défaut).

PTHREAD_CANCEL_ASYNCHRONOUS L'annulation prendra effet dès réception de la requête.

La norme Posix.1c décrit quatre fonctions qui constituent des points d'annulation, c'est-à-dire des fonctions dans lesquelles un thread est susceptible de se terminer brutalement :

• pthread_cond_wait( ) et pthread_cond_timedwait( ) que nous verrons dans un prochain paragraphe.

• pthread_join( ) que nous avons déjà examinée.

• pthread_testcancel( ).

Cette dernière fonction est déclarée ainsi : void pthread_testcancel (void);

Dès qu'on l'invoque, le thread peut se terminer si une demande d'annulation est en attente. On voit donc que dans le cas PTHREAD_CANCEL_DEFERRED, un thread ne sera jamais interrompu au milieu d'un calcul ou dans une boucle de manipulation des données. On pourra donc répartir des appels pthread_testcancel( ) dans ce genre de code, aux endroits où on est sûr qu'une annulation ne présente pas de danger.

Il existe aussi un ensemble minimal de fonctions et d'appels-système qui représentent des points d'annulation sur toutes les implémentations des Pthreads :

• aio_suspend( ),

• close( ), creat( ),

• fcntl( ), fsync( ),

• msync( ),

• nanosleep( ),

• open( ),

• pause( ),

• read( ),

• sem_wait( ), sigsuspend( ), sigtimedwait( ), sigwait( ), sigwaitinfo(

), sleep( ), system( ),

• tcdrain( ),

• wait( ),waitpid( ),write( ).

Ces fonctions ont en commun de pouvoir bloquer indéfiniment, ce qui représente un gâchis de ressource si le thread doit être annulé. Il existe aussi un nombre important de routines qui peuvent être des points d'annulation, si leurs concepteurs le désirent. On consultera à cet effet la documentation Gnu pour connaître les fonctions de bibliothèques concernées.

Un grand nombre de fonctions et d'appels-système modifient temporairement des données statiques et ne peuvent pas se permettre d'être interrompus n'importe quand. Cela signifie qu'un appel-système lent, comme read( ), ne doit jamais être invoqué si le thread est configuré avec une annulation asynchrone. Les fonctions qui supportent l'annulation asynchrone (async-cancel safe) sont explicitement documentées comme telles. Étant donné qu'elles sont extrêmement rares, on se fixera comme règle de ne configurer un thread en mode d'annulation asynchrone que pour des portions de code où il réalise des boucles de calculs intenses, sans aucun appel-système.

En résumé, on utilisera principalement les configurations suivantes :

Configuration Utilisation

PTHREAD_CANCEL_DISABLE Zone critique où l'on ne peut supporter aucune annulation, même si on invoque un appel-système.

PTHREAD_CANCEL_ENABLE

PTHREAD_CANCEL_DEFERRED Comportement habituel des threads.

PTHREAD_CANCEL_ENABLE

PTHREAD_CANCEL_ASYNCHRONOUS Boucle de calculs gourmande en CPU, sans appel-système.

Comme un thread peut être légitimement annulé pratiquement à n'importe quel moment, il convient de trouver un moyen de libérer les ressources qu'il peut maintenir, avant qu'il se termine vraiment. En effet, quant un thread disparaît, la mémoire dynamique qu'il s'était allouée n'est pas libérée automatiquement par le noyau, contrairement à la fin d'un processus. Le même problème se pose avec les fichiers ouverts, les tubes de communication, les sockets réseau... De plus. un thread peut avoir verrouillé une ou plusieurs ressources partagées, afin d'en avoir temporairement l'usage exclusif, et il convient de relâcher les verrous posés.

Pour cela. la norme Posix. 1c propose un mécanisme assez élégant, bien qu'un peu surprenant à première vue. Lorsqu'un thread s'attribue une ressource — mémoire, fichier, verrous, etc. — qui nécessitera une libération ultérieure, il enregistre le nom d'une routine de libération dans une pile spéciale. avec la fonction pthread_cleanup_push( ). Lorsque le thread se termine, les routines sont dépilées — dans l'ordre inverse de leur enregistrement — et exécutées.

Quand un thread désire libérer explicitement une ressource, à la fin d'une fonction par exemple, il appelle pthread_cleanup_pop( ). qui extrait la dernière routine enregistrée et l'invoque. Les prototypes de ces deux fonctions sont les suivants :

void pthread_cleanup_push (void (* fonction) (void * argument), void * argument);

void pthread_cleanup_pop (int execution_routine);

La routine pthread_cleanup_push( ) prend donc en premier argument un pointeur de fonction, et en second un pointeur générique pouvant représenter n'importe quel objet.

Lorsque la

routine de nettoyage est appelée, elle reçoit en argument le second pointeur. En général, on utilisera:

FILE * fp;

fp = fopen (nom fichier, "r");

pthread_cleanup_push (fclose, fp);

ou

char * buffer;

buffer = malloc (BUFSIZE);

pthread_cleanup_push (free buffer);

ou encore int fd;

fd = open (nom_fichier, O_RDONLY);

pthread_cleanup_push (close, (void *) fd);

Lorsqu'on désire invoquer explicitement la routine de libération, on emploie pthread_cleanup_pop( ) en lui passant un argument entier. Si cet argument est nul, la routine est retirée de la pile de nettoyage, mais elle n'est pas exécutée. Sinon, la routine est extraite et invoquée.

Ce mécanisme nécessite donc de soigner la conception du programme pour disposer les allocations et libérations autour des zones où le thread peut être annulé. On se disciplinera pour adopter ce comportement dans toutes les fonctions de l'application. En employant par exemple :

void

routine_dialogue (char * nom_serveur, char *nom fichier enregistrement) {

char * buffer;

FILE * fichier;

int socket serveur;

int nb_octets_recus;

buffer = malloc (BUFSIZE);

if (buffer != NULL) {

pthread_cleanup_push (free, buffer):

socket_serveur = ouverture_socket (nom_serveur);

if (socket_serveur >= 0) {

pthread_cleanup_push (close, (void *) socket_serveur);

fichier = fopen (nom_fichier_enregistrement, "w");

if (fichier != NULL) {

pthread_cleanup_push (fclose, fichier);

while (1) {

nboctetsrecus = lecture socket (socket_serveur, buffer);

if (nb_octets_recus < 0) break;

if (fwrite (buffer, 1, nb_octets_recus, fichier) != nb_octets_recus)

break;

}

pthread_cleanup_pop (1); /* fclose (fichier) */

}

pthread_cleanup_pop (1); /* close (socket_ serveur) */

}

pthread_cleanup_pop (1); /* free (buffer) */

}

Pour obliger le programmeur à adopter un comportement cohérent dans toute la fonction, la norme Posix.1c impose une contrainte assez restrictive à l'utilisation de ces routines.

En effet, les appels pthread_cleanup_push( ) et pthread_cleanup_pop( doivent se trouver dans la même fonction et dans le même bloc lexical. Cela signifie qu'ils doivent être au même niveau d'imbrication entre accolades. On peut le vérifier d'un coup d'oeil en s'assurant que le pthread_cleanup_pop( ) se trouve bien au même niveau d'indentation que le pthread_cleanup_push( ) correspondant.

Pour comprendre la raison de cette restriction, il suffit de savoir que l'implémentation de ces routines dans la plupart des bibliothèques, dont LinuxThreads, est réalisée par deux macros : la première comprend une accolade ouvrante alors que la seconde contient l'accolade fermante associée. Il importe donc de considérer ces deux fonctions comme une paire d'accolades et de ne pas essayer de les séparer de plus d'un bloc lexical.

On prendra comme habitude de faire systématiquement suivre un appel de la fonction pthread_cleanup_pop( ) d'un commentaire indiquant son effet, comme on peut le voir ci-dessus. On remarquera également que le fait de n'avoir plus qu'un seul point de sortie d'une routine oblige parfois à une indentation excessive. Pour éviter ce problème, on peut scinder la routine en plusieurs sous-fonctions ou utiliser des sauts goto, comme c'est souvent l'usage pour les gestions d'erreur. Dans ce cas, le programme précédent deviendrait :

void

routine_dialogue (char * nom_serveur, char *nom fichier_enregistrement) {

char * buffer;

FILE * fichier;

int socket_serveur;

int nb_octets_recus;

if ((buffer = malloc (BUFSIZE))== NULL) return;

pthread_cleanup_push (free, buffer);

if ((socket_serveur = ouverture_socket (nom_serveur)) < 0) goto sortie_cleanup_1;

pthread_cleanup_push (close, (void *) socket_serveur);

if ((fichier = fopen (nom_fichier_enregistrement, "w")) == NULL) goto sortie_cleanup_2;

pthread_cleanup_push (fclose, fichier);

while (1) {

nboctets_recus = lecture_socket (socket_serveur, buffer);

if (nb octets_recus < 0)

break;

if (fwrite (buffer, 1, nb_octets_recus, fichier) != nb_octets_recus)

break;

}

pthread_cleanup_pop (1); /* fclose (fichier) */

sortie_cleanup_2 :

pthread_cleanup_pop (1); /* close (socket_serveur) */

sortie_cleanup_1

pthread_cleanup_pop (1); /* free (buffer) */

}

Ce genre de code est peut-être moins élégant, mais il est particulièrement bien adapté à ce type de gestion d'erreur. On rencontre de fréquents exemples d'emploi de goto dans ce contexte au sein des sources du noyau Linux.

À l'opposé des routines de nettoyage en fin de thread, on a souvent besoin, dans un module d'une application, d'initialiser des données au début de leur mise en oeuvre.

Imaginons un module servant à interroger une base de données. Il doit dissimuler au reste du programme l'implémentation interne. Que la base de données soit représentée par un fichier local, un démon fonctionnant en arrière-plan ou un serveur distant accessible par le réseau, le module doit adopter la même interface vis-à-vis du reste du programme. Il existe ainsi une routine publique d'interrogation susceptible d'être appelée par différents threads, éventuellement de manière concurrente, gérant donc l'accès critique aux données partagées avec des mécanismes décrits dans les prochains paragraphes.

Toutefois il est nécessaire, lors de la première interrogation, d'établir la liaison avec la base de données proprement dite — ouvrir le fichier, accéder au tube de communication avec le démon, contacter le serveur distant —, ce qui ne doit être réalisé qu'une seule fois.

On vérifiera donc à chaque interrogation si l'initialisation a bien eu lieu. La bibliothèque Pthreads propose une fonction pthread_once( ) qui remplit ce rôle en s'affranchissant des problèmes de synchronisation si plusieurs threads l'invoquent simultanément.

Pour utiliser cette fonction, il faut préalablement définir une variable statique de type pthread_once_t, initialisée avec la constante PTHREADONCE_INIT, qu'on passera par adresse à la routine pthreadonce( ). Le second argument est un pointeur sur une fonction d'initialisation qui ne sera ainsi appelée qu'une seule fois dans l'application.

int pthread_once (pthread_once_t * once, void (* fonction) (void));

En poursuivant notre exemple concernant un module de dialogue avec une base de données, on obtiendrait :

void initialisation_dialogue_base (void);

int

interrogation_base_donnees (char * question) {

static pthread_once_t once_initialisation = PTHREAD_ONCE_INIT;

pthread_once (& once_initialisation, initialisation_dialogue_base);

[...]

}

Naturellement, seule la première invocation de pthread_once( ) a un effet, les appels ultérieurs n'ayant plus aucune influence.

On peut se demander ce qui se passe lorsqu'un thread appelle fork( ). Le comportement est tout à fait logique. Le processus entier est dupliqué, y compris les zones de mémoire partagées avec les autres threads. Par contre, il n'y a dans le processus fils qu'un seul fil d'exécution, celui du thread qui a invoqué fork( ), cela quel que soit le nombre de threads concurrents avant la séparation.

Un premier problème se pose, car les piles et les zones de mémoire dynamiquement allouées par les autres threads continuent d'être présentes dans l'espace mémoire du nouveau processus, même s'il n'a aucun moyen d'y accéder. Aussi, en théorie ce mécanisme doit être restreint uniquement à l'utilisation de exec( ) après le fork( ).

Un second problème peut se poser si un autre thread a verrouillé – dans le processus père – une ressource. Si le thread restant dans le processus fils a besoin de cette ressource, celle-ci persiste à être verrouillée, et on risque un blocage définitif.

Pour résoudre ce problème, il existe une fonction nommée pthread_atfork( ), qui permet d'enregistrer des routines qui seront automatiquement invoquées si un thread appelle fork( ). Les fonctions sont exécutées dans l'ordre inverse de leur enregistrement, comme avec une pile. La liste des fonctions mémorisées est commune à tous les threads.

On peut enregistrer trois fonctions avec pthread_atfork( ). La première routine est appelée avant le fork( ) dans le thread qui l'invoque. Les deux autres routines sont appelées après la séparation, l'une dans le processus fils, et l'autre dans le processus père – toujours au sein du thread ayant invoqué fork( ).

int pthread_atfork (void (* avant) (void), void (* dans_pere) (void), void (* dans fils) (void));

Si un pointeur est nul, la routine est ignorée. Nous allons voir dans le prochain paragraphe comment bloquer ou libérer des verrous pour l'accès à des zones critiques.

Dans l'encadrement de fork( ), on essaye d'éviter la situation suivante : 1. Le thread numéro 1 bloque un verrou pour accéder à une zone de données.

2. Le thread numéro 2 appelle fork( ), dupliquant l'ensemble de l'espace mémoire du processus, y compris le verrou bloqué.

3. Le processus père continue de se dérouler normalement, le thread 1 libérant le verrou après ses modifications, et le thread 2 pouvant poursuivre son exécution.

4. Dans le processus fils, le thread 2 veut accéder à la zone de données commune. Celle-ci étant verrouillée, il attend que le thread 1 libère le verrou, mais il n'y a pas de thread 1 dans le fils ! Le processus est définitivement bloqué.

La bonne manière de procéder est la suivante, un peu complexe mais correcte :

1. Avant que le thread 1 bloque le verrou – par exemple pendant son initialisation –, on installe la routine avant( ), qui correspond à un blocage du verrou, ainsi que dans_pere( ) et dans_fils( ) , deux routines qui représentent une libération du verrou.

3. Le thread numéro 2 appelle fork( ). La routine avant( ) est invoquée.

Correspondant à une demande de blocage du verrou, elle reste bloquée jusqu'à ce que le thread 1 ait terminé son travail.

4. Le thread numéro 1 libère le verrou.

5. La routine avant( ) bloque le verrou et se termine.

6. L'appel-système fork( ) a lieu, les processus se séparent. Les routines dans_pere(

) et dans_fils( ) sont invoquées, libérant le verrou dans les deux contextes.

7. Les threads 1 et 2 du processus père continuent normalement.

8. Le thread 2 du processus fils peut accéder à la zone de données s'il le désire, le verrou est libre.

Nous voyons qu'il faut donc enregistrer une série de routines pour chaque verrou susceptible d'être employé dans le processus fils, ce qui complique – parfois excessivement – l'écriture des programmes.

En fait, l'appel pthread_atfork( ) est principalement employé dans des programmes expérimentaux, pour étudier justement les blocages dus aux partages de verrous. Dans des applications courantes, on évite généralement de se trouver dans cette situation. Pour cela, on essaye de ne pas utiliser fork( ), ou de le faire suivre immédiatement d'un exec( ). On peut aussi appeler fork( ) avant la création des threads et installer un mécanisme de communication entre processus, comme nous en verrons au chapitre 28.

Dans le document Programmation système en C sous (Page 153-156)