Bibliothèque C et threads
Comme nous l’avons vu dans des exercices précédents, les variables locales aux threads sont stockées dans des piles séparées.
On considère donc qu’il n’y a aucun risque de concurrence entre variables locales de threads différents.
C’est ce que nous allons voir !
Dans la première partie, nous allons montrer que certaines fonctions standard du C créent à l’insu de l’utilisateur des variables globales.
Ces fonctions peuvent être appelées “simultanément” par plusieurs threads, l’accès concurrent à cette variable globale sans aucune protection peut provoquer des erreurs.
Dans la deuxième et la troisième partie, nous allons montrer que l’implémentation de certaines parties de la bibliothèque standard du C++ dans la glibc
est “plus ou moins” sécurisé dans un contexte de multi-threading.
Travail demandé
Créez le projet CCppAndThreads
.
Modifier le .pro
du projet pour pouvoir utiliser les boost_thread
et libUtil
.
Partie A
Dans cet exercice, plusieurs threads créent une copie locale d’une NTCTS commune.
Chacun décompose ensuite sa donnée locale en tokens au moyen de la fonction C standard strtok()
.
Cet exercice doit faire apparaître une interférence entre les traitements des NTCTSs, qui sont pourtant des variables locales aux threads.
Dans l’espace de noms anonyme du fichier CCppAndThreads_a.cpp
, déclarer le mutex io_Mtx
destiné à effectuer les affichages en exclusion mutuelle.
Ajouter à l’espace de noms anonyme la définition de la fonction-thread Thread()
de profil :
void Thread (const string * Param);
qui :
-
crée localement une NTCTS contenant la copie du contenu de Param
,
-
affiche en exclusion mutuelle la NTCTS créée.
Dans la fonction main()
(le main-thread) :
-
fabriquer une longue chaîne (string
), par exemple en concaténant 200 000 fois le même mot (terminé par un espace),
-
récupérer le nombre de threads à lancer, passé en argument de la commande,
-
lancer les threads en leur passant en paramètre la chaîne obtenue par concaténation.
Au début de l’espace de noms anonyme, ajouter la ligne :
typedef vector <char *> CVpChar; // vector de NTCTS
Dans la fonction Thread()
, déclarer un tableau de NTCTS de type CVpChar
, puis décomposer la NTCTS locale en mots grâce à la fonction strtok()
, en prenant l’espace comme séparateur.
A chaque itération de cette décomposition :
En fin de Thread()
, afficher en exclusion mutuelle le nombre de mots trouvés (en principe 200 000 !!!)
Compilez.
Ouvrez un terminal et testez, d’abord avec un seul thread pour vérifier le bon fonctionnement de votre fonction et connaître le nombre de mots, puis avec deux ou trois threads (ça devrait suffire).
En principe, vous devriez constater quelques petits défauts … Pour quelle raison, à votre avis ?
Remarque :
Il est très surprenant que ces threads interfèrent alors qu’ils n’ont, en principe, aucune donnée commune, une fois la NTCTS recopiée localement.
Comme ce n’est pas cette copie qui provoque l’erreur, il faut aller chercher beaucoup plus profondément, dans l’implémentation même de la fonction strtok()
.
Lors de son premier appel, le premier paramètre lui indique l’adresse mémoire à partir de laquelle doit être effectué le découpage de la chaîne.
Dans les appels suivants, c’est un pointeur nul qui lui est passé.
Cela signifie que le découpage doit continuer à partir de la position courante dans la chaîne.
La fonction conserve donc entre chaque appel la position courante dans la chaîne traîtée, jusqu’à une prochaine réinitialisation.
On peut imaginer que la fonction a la structure suivante :
char *strtok (char *s, const char * delim)
{
static char * Debut;
if (s) Debut = s;
char * Result = Debut;
char * Ptr = Debut;
// balayage de la NTCTS au moyen du pointeur Ptr jusqu'à ce qu'un
// délimiteur soit atteint
*Ptr = '\0';
// suite du balayage de la NTCTS au moyen du pointeur Ptr
// jusqu'à ce que tous les délimiteurs consécutifs aient été
// sautés.
// Ptr pointe alors sur le début du prochain token
Debut = Ptr; // ou analogue !
return Result;
} // strtok()
Cette fonction garde donc dans une variable globale, statique et unique une valeur qui mémorise l’avancement dans une NTCTS donnée.
Son utilisation dans un contexte multi-threading a donc toutes les “chances” de provoquer un accès concurrent à cette donnée involontairement partagée.
Plusieurs autres fonctions C effectuent un traitement analogue, et doivent donc être utilisées avec infiniment de précaution.
Il s’agit en particulier de :
rand()
strtok()
asctime()
ctime()
gmtime()
localtime()
Il existe des bibliothèques C écrites spécialement pour être utilisées dans un tel contexte.
Le man
de la fonction strtok()
indique d’ailleurs ce risque d’erreur dans un contexte de multi-threading et conseille l’utilisation de la fonction strtok_r()
.
Suite
Modifier l’exercice en remplaçant la fonction strtok()
par strtok_r()
(le suffixe _r
signifie “réentrant” et est utilisé dans la bibliothèque GLibC pour toutes les fonctions dont le code est susceptible d’être parcouru par plusieurs exécutions simultanées – c’est le cas du multi-threading).
#include <string.h>
char * strtok_r (char * str, const char * delim, char **saveptr);
où saveptr
est un paramètre résultat de type char *
, à utiliser ainsi :
char * Ptr;
... strtok_r (..., ..., & Ptr);
Constatez-vous une amélioration ?
Partie B
Classe string
C++ et threads
Il y a deux façons de considérer qu’une bibliothèque est thread-safe :
-
la bibliothèque garantit que les actions en parallèle sur deux objets distincts de la même classe n’interagissent pas.
C’est ce qu’indique par exemple la documentation de l’implémentation SGI :
Client must lock shared mutable containers
The SGI implementation of STL is thread-safe only in the sense that simultaneous accesses to distinct containers are safe, and simultaneous read accesses to to shared containers are safe. If multiple threads access a single container, and at least one thread may potentially write, then the user is responsible for ensuring mutual exclusion between the threads during the container accesses.
-
la bibliothèque garantit que les actions en parallèle sur le même objet sont cohérentes : les opérations d’écriture se font en exclusion mutuelle entre elles et en exclusion mutuelle avec les opérations de lecture.
Nous allons tout d’abord tenter de vérifier la première définition.
Dans le fichier CCppAndThreads_b.cpp
, définir une string
globale initialisée par 10000 '.'
.
Dans la fonction Thread()
ayant pour paramètre un int
représentant un caractère :
-
recopier la chaîne globale dans une variable locale.
Inutile de faire cette opération en exclusion mutuelle (pourquoi ?)
-
dans une boucle, remplacer chaque caractère de la chaîne locale par le caractère reçu en paramètre (temporiser chaque opération comme précédemment).
-
en fin de boucle, vérifier si la chaîne locale ainsi créée est “cohérente” (c-à-d. si tous les caractères qu’elle contient sont identiques).
Afficher (en exclusion mutuelle !) l’identifiant du thread (this_thread::get_id()
) et le résultat de cette vérification : Chaine coherente
ou Chaine non coherente
Compiler et, comme précédemment, tester d’abord avec un seul thread, puis avec plusieurs.
Que constatez-vous et qu’en concluez-vous?
Remarque :
Sans pouvoir l’affirmer (il faudrait aller voir le code source), il semble que la classe string
de la bibliothèque C++ de la glibc 3.2
respecte la première définition du safethreading : les accès parallèles aux chaînes distinctes (qui partagent pourtant la même NTCTS au début) semblent ne pas perturber le fonctionnement normal (aucune anomalie relevée jusqu’à 100 threads, ce qui ne constitue aucunement une preuve !)
Partie C
Nous allons pour finir tenter de vérifier si la bibliothèque glibc
vérifie la seconde définition de safethreading.
Recopier le fichier CCppAndThreads_b.cpp
dans CCppAndThreads_c.cpp
.
Remplacer la taille de la chaîne globale par 10 000 000
.
Dans la fonction Thread()
, remplir une chaîne locale par 10 000 000
fois le caractère passé en paramètre.
Affecter la chaîne locale à a chaîne globale.
Après la fin de tous les threads, afficher dans le main-thread si la chaîne globale est cohérente.
Compiler et, comme précédemment, tester d’abord avec un seul thread, puis avec plusieurs (avec un peu de chances, dix suffisent !).
Il arrive parfois que le programme plante par :
*** glibc detected *** double free or corruption (!prev): 0x0804d610 ***
Abort (core dumped)
ou par
Erreur de segmentation
Qu’en concluez-vous?
Remarque :
La glibc n’est pas safethreaded au second sens de ce terme.
C’est le cas de la majorité des implémentations courantes (SGI, HP, etc.) de la bibliothèque standard du C++, contrairement aux bibliothèques correspondantes de Java.