POSIX Threads — pthread_once, données spécifiques aux threads et stockage local
1. OBJECTIF DU CHAPITRE
Dans un programme multithread, plusieurs threads peuvent appeler simultanément la même fonction. Cette situation devient dangereuse dès que la fonction utilise un état partagé : variable globale, variable static, tampon commun, liste globale ou ressource modifiable.
Ce cours présente trois moyens de résoudre le problème :
1. protéger les données partagées avec un mutex ;
2. écrire une fonction réentrante ne dépendant d’aucun état partagé ;
3. donner à chaque thread sa propre copie des données avec les Thread-Specific Data ou le stockage local au thread.
Nous verrons également comment exécuter une initialisation exactement une fois avec pthread_once().
2. THREAD-SAFE, RÉENTRANTE ET NON RÉENTRANTE
Fonction thread-safe
Une fonction est thread-safe lorsqu’elle peut être appelée simultanément par plusieurs threads sans course critique, corruption mémoire ni résultat incohérent.
Elle peut utiliser un état partagé à condition que cet état soit correctement synchronisé.
Fonction réentrante
Une fonction est réentrante lorsqu’une nouvelle invocation peut commencer avant la fin d’une invocation précédente sans perturber celle-ci.
Une fonction réentrante évite normalement :
- les variables globales modifiables ;
- les variables static modifiables ;
- les tampons partagés ;
- un état caché conservé entre les appels ;
- les pointeurs vers une zone interne réutilisée.
Relation importante :
Une fonction réentrante est normalement thread-safe. En revanche, une fonction thread-safe protégée par un mutex n’est pas forcément réentrante.
Un mutex protège les appels concurrents entre threads, mais une fonction interrompue alors qu’elle détient le mutex pourrait se bloquer si elle est réappelée dans un contexte comme un gestionnaire de signal.
3. EXEMPLE DE COURSE CRITIQUE
Le programme suivant incrémente une variable globale sans synchronisation :
Code: Select all
#include <stdio.h>
#include <pthread.h>
static int g_value = 0;
static void *worker(void *arg)
{
int loops = *(int *)arg;
for (int i = 0; i < loops; i++) {
int local = g_value;
local++;
g_value = local;
}
return NULL;
}
int main(void)
{
pthread_t t1;
pthread_t t2;
int loops = 100000;
pthread_create(&t1, NULL, worker, &loops);
pthread_create(&t2, NULL, worker, &loops);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Résultat : %d\n", g_value);
return 0;
}
Séquence possible :
Code: Select all
Thread A lit g_value = 42
Thread B lit g_value = 42
Thread A écrit 43
Thread B écrit 43
Attention : le mot-clé volatile ne rend pas cette opération atomique et ne remplace pas un mutex.
4. PREMIÈRE SOLUTION : LE MUTEX
Code: Select all
#include <stdio.h>
#include <pthread.h>
static int g_value = 0;
static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
static void *worker(void *arg)
{
int loops = *(int *)arg;
for (int i = 0; i < loops; i++) {
pthread_mutex_lock(&g_mutex);
g_value++;
pthread_mutex_unlock(&g_mutex);
}
return NULL;
}
Bonne pratique : verrouiller uniquement la section qui accède aux données partagées. Si le mutex couvre toute une fonction longue, les appels sont totalement sérialisés et le parallélisme disparaît.
Un mutex est adapté lorsque les threads doivent réellement partager la même donnée. Il n’est pas nécessaire lorsque chaque thread peut travailler sur sa propre copie.
5. PRÉFÉRER UNE INTERFACE RÉENTRANTE
Une mauvaise interface renvoie un pointeur vers un tampon interne :
Code: Select all
char *format_value(int value);
Code: Select all
int format_value_r(int value, char *buffer, size_t size);
Code: Select all
#include <stdio.h>
#include <stddef.h>
int format_value_r(int value, char *buffer, size_t size)
{
if (buffer == NULL || size == 0)
return -1;
int written = snprintf(buffer, size, "Valeur = %d", value);
if (written < 0 || (size_t)written >= size)
return -1;
return 0;
}
Code: Select all
char buffer[64];
if (format_value_r(42, buffer, sizeof(buffer)) == 0)
printf("%s\n", buffer);
Principe : lorsque l’interface peut être modifiée, le tampon fourni par l’appelant est souvent la solution la plus propre.
6. FONCTIONS HISTORIQUES NON THREAD-SAFE
Certaines anciennes interfaces renvoient un pointeur vers un stockage interne statique ou maintiennent un état global.
Exemples classiques :
- asctime() et ctime() peuvent utiliser un tampon statique ;
- gmtime() et localtime() ont historiquement renvoyé une structure interne ;
- strtok() maintient un état caché entre plusieurs appels ;
- d’anciennes fonctions de consultation des utilisateurs, groupes, hôtes ou réseaux possèdent des variantes réentrantes ;
- certaines fonctions historiques portent une variante dont le nom se termine par _r.
Portabilité : toutes les variantes _r ne sont pas disponibles ou normalisées de la même manière sur tous les systèmes. Il faut consulter la documentation de la plateforme ciblée.
7. INITIALISATION UNIQUE AVEC pthread_once()
Dans une bibliothèque, l’initialisation ne peut pas toujours être placée dans main(). Le premier appel peut provenir de n’importe quel thread.
Un simple booléen n’est pas suffisant :
Code: Select all
if (!initialized) {
initialize();
initialized = 1;
}
La solution POSIX est pthread_once().
Prototype
Code: Select all
#include <pthread.h>
int pthread_once(pthread_once_t *once_control,
void (*init)(void));
Code: Select all
static pthread_once_t g_once = PTHREAD_ONCE_INIT;
Code: Select all
static void initialize_library(void)
{
/* Initialisation exécutée une seule fois */
}
Code: Select all
int status = pthread_once(&g_once, initialize_library);
Exemple complet
Code: Select all
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
static pthread_once_t g_once = PTHREAD_ONCE_INIT;
static int *g_table = NULL;
static void initialize_table(void)
{
g_table = malloc(256 * sizeof(*g_table));
if (g_table == NULL) {
fputs("Allocation impossible\n", stderr);
abort();
}
for (int i = 0; i < 256; i++)
g_table[i] = i * i;
}
static void *worker(void *arg)
{
(void)arg;
int status = pthread_once(&g_once, initialize_table);
if (status != 0)
return NULL;
printf("g_table[10] = %d\n", g_table[10]);
return NULL;
}
- création d’une clé de données spécifiques aux threads ;
- initialisation paresseuse d’une bibliothèque ;
- construction d’une table globale en lecture seule ;
- initialisation d’un cache ;
- chargement unique d’une configuration.
Les Thread-Specific Data, abrégées TSD, permettent de conserver une valeur persistante différente pour chaque thread.
Une clé est commune au processus, mais la valeur associée dépend du thread :
Code: Select all
Clé K
├── thread A -> pointeur vers le contexte A
├── thread B -> pointeur vers le contexte B
└── thread C -> pointeur vers le contexte C
Code: Select all
pthread_getspecific(K);
Contrairement à une variable automatique, la donnée survit au retour de la fonction et peut être récupérée lors de l’appel suivant effectué par le même thread.
9. pthread_key_create()
Prototype
Code: Select all
#include <pthread.h>
int pthread_key_create(pthread_key_t *key,
void (*destructor)(void *));
key reçoit l’identifiant de la clé.
destructor est une fonction facultative appelée lorsqu’un thread se termine alors qu’une valeur non NULL est encore associée à cette clé.
Forme du destructeur :
Code: Select all
static void destroy_value(void *value)
{
free(value);
}
Code: Select all
static pthread_key_t g_key;
int status = pthread_key_create(&g_key, destroy_value);
- la clé n’est créée qu’une seule fois ;
- tous les threads utilisent le même identifiant de clé ;
- chaque thread possède sa propre valeur associée ;
- le destructeur reçoit la valeur du thread qui se termine ;
- le destructeur peut être NULL ;
- l’ordre d’appel de plusieurs destructeurs n’est pas garanti.
Code: Select all
static pthread_once_t g_once = PTHREAD_ONCE_INIT;
static pthread_key_t g_key;
static void create_key(void)
{
int status = pthread_key_create(&g_key, free);
if (status != 0)
abort();
}
Prototype
Code: Select all
int pthread_setspecific(pthread_key_t key,
const void *value);
Elle ne copie pas le contenu du bloc : elle mémorise seulement le pointeur.
Code: Select all
struct thread_context *ctx = malloc(sizeof(*ctx));
if (ctx == NULL)
return NULL;
int status = pthread_setspecific(g_key, ctx);
Pour supprimer l’association du thread courant :
Code: Select all
pthread_setspecific(g_key, NULL);
11. pthread_getspecific()
Prototype
Code: Select all
void *pthread_getspecific(pthread_key_t key);
Si aucune valeur n’a encore été enregistrée, elle renvoie NULL.
Cette propriété permet une allocation paresseuse :
Code: Select all
struct thread_context *ctx = pthread_getspecific(g_key);
if (ctx == NULL) {
ctx = calloc(1, sizeof(*ctx));
if (ctx == NULL)
return NULL;
int status = pthread_setspecific(g_key, ctx);
if (status != 0) {
free(ctx);
return NULL;
}
}
12. pthread_key_delete()
Prototype
Code: Select all
int pthread_key_delete(pthread_key_t key);
Très important : pthread_key_delete() n’appelle pas les destructeurs des valeurs actuellement associées aux threads. Elle ne libère donc pas automatiquement les objets pointés.
Une clé de bibliothèque utilisée jusqu’à la fin du processus est souvent laissée active pendant toute la vie du programme.
Il ne faut pas supprimer une clé pendant que d’autres threads peuvent encore l’utiliser.
13. EXEMPLE COMPLET DE CONTEXTE PAR THREAD
Code: Select all
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
struct thread_context {
unsigned long calls;
char buffer[128];
};
static pthread_once_t g_once = PTHREAD_ONCE_INIT;
static pthread_key_t g_context_key;
static void destroy_context(void *value)
{
struct thread_context *ctx = value;
free(ctx);
}
static void create_context_key(void)
{
int status = pthread_key_create(&g_context_key,
destroy_context);
if (status != 0) {
fprintf(stderr,
"pthread_key_create a échoué : %d\n",
status);
abort();
}
}
static struct thread_context *get_context(void)
{
int status = pthread_once(&g_once, create_context_key);
if (status != 0)
return NULL;
struct thread_context *ctx =
pthread_getspecific(g_context_key);
if (ctx != NULL)
return ctx;
ctx = calloc(1, sizeof(*ctx));
if (ctx == NULL)
return NULL;
status = pthread_setspecific(g_context_key, ctx);
if (status != 0) {
free(ctx);
return NULL;
}
return ctx;
}
static const char *format_for_current_thread(int value)
{
struct thread_context *ctx = get_context();
if (ctx == NULL)
return NULL;
ctx->calls++;
snprintf(ctx->buffer,
sizeof(ctx->buffer),
"thread=%lu, appel=%lu, valeur=%d",
(unsigned long)pthread_self(),
ctx->calls,
value);
return ctx->buffer;
}
static void *worker(void *arg)
{
int id = *(int *)arg;
for (int i = 0; i < 3; i++) {
const char *text =
format_for_current_thread(id * 100 + i);
if (text != NULL)
puts(text);
}
return NULL;
}
int main(void)
{
pthread_t threads[3];
int ids[3] = { 1, 2, 3 };
for (int i = 0; i < 3; i++) {
int status = pthread_create(&threads[i],
NULL,
worker,
&ids[i]);
if (status != 0) {
fprintf(stderr,
"pthread_create a échoué : %d\n",
status);
return EXIT_FAILURE;
}
}
for (int i = 0; i < 3; i++) {
int status = pthread_join(threads[i], NULL);
if (status != 0) {
fprintf(stderr,
"pthread_join a échoué : %d\n",
status);
return EXIT_FAILURE;
}
}
return EXIT_SUCCESS;
}
- son compteur calls ;
- son propre tampon buffer ;
- une allocation libérée automatiquement à la fin du thread.
Une implémentation peut maintenir deux niveaux de tableaux.
Table globale des clés
Elle appartient au processus et peut contenir, pour chaque clé :
- un indicateur précisant si la clé est utilisée ;
- l’adresse du destructeur associé.
Chaque thread possède un tableau de pointeurs :
Code: Select all
TSD du thread A :
index 0 -> NULL
index 1 -> contexte A
index 2 -> autre objet A
TSD du thread B :
index 0 -> NULL
index 1 -> contexte B
index 2 -> autre objet B
pthread_setspecific() écrit dans le tableau du thread courant.
pthread_getspecific() lit dans le tableau du thread courant.
Cette représentation explique pourquoi une clé est globale tandis que sa valeur est privée.
15. DESTRUCTEURS ET FIN DE THREAD
Lorsqu’un thread se termine normalement, Pthreads examine ses valeurs spécifiques.
Si une valeur non NULL possède un destructeur, le destructeur est appelé :
Code: Select all
static void destroy_context(void *value)
{
free(value);
}
- un retour depuis sa fonction de démarrage ;
- un appel à pthread_exit() ;
- la fin normale du traitement.
- un destructeur ne doit pas dépendre de l’ordre d’exécution des autres destructeurs ;
- il doit accepter la valeur exacte enregistrée avec pthread_setspecific() ;
- il doit tolérer un objet partiellement initialisé si le code peut échouer en cours de construction ;
- il ne doit pas supposer que les autres contextes existent encore.
16. LIMITES DES CLÉS
Le nombre de clés disponibles n’est pas infini.
La constante suivante peut indiquer une limite minimale ou connue à la compilation :
Code: Select all
PTHREAD_KEYS_MAX
Code: Select all
#include <unistd.h>
long max_keys = sysconf(_SC_THREAD_KEYS_MAX);
Code: Select all
struct library_tls {
char error_buffer[256];
unsigned long operation_count;
void *temporary_object;
};
Une implémentation simplifiée non thread-safe peut utiliser :
Code: Select all
static char g_buffer[256];
char *my_strerror(int error)
{
snprintf(g_buffer,
sizeof(g_buffer),
"Erreur numéro %d",
error);
return g_buffer;
}
Code: Select all
Thread principal :
p1 = my_strerror(1);
Autre thread :
p2 = my_strerror(2);
Thread principal :
affiche p1
Un mutex autour de my_strerror() ne suffit pas toujours : le mutex serait relâché avant que l’appelant ait fini d’utiliser le pointeur retourné.
La donnée doit donc être propre au thread, ou le tampon doit être fourni par l’appelant.
18. VERSION THREAD-SAFE AVEC pthread_once ET TSD
Code: Select all
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define ERROR_BUFFER_SIZE 256
static pthread_once_t g_once = PTHREAD_ONCE_INIT;
static pthread_key_t g_error_key;
static void destroy_error_buffer(void *value)
{
free(value);
}
static void create_error_key(void)
{
int status = pthread_key_create(&g_error_key,
destroy_error_buffer);
if (status != 0)
abort();
}
char *my_strerror_tsd(int error)
{
int status = pthread_once(&g_once, create_error_key);
if (status != 0)
return NULL;
char *buffer = pthread_getspecific(g_error_key);
if (buffer == NULL) {
buffer = malloc(ERROR_BUFFER_SIZE);
if (buffer == NULL)
return NULL;
status = pthread_setspecific(g_error_key, buffer);
if (status != 0) {
free(buffer);
return NULL;
}
}
snprintf(buffer,
ERROR_BUFFER_SIZE,
"Erreur numéro %d",
error);
return buffer;
}
Code: Select all
pthread_once()
-> création globale de la clé si nécessaire
pthread_getspecific()
-> NULL, car ce thread n’a pas encore de tampon
malloc()
-> allocation du tampon du thread
pthread_setspecific()
-> mémorisation du pointeur pour ce thread
Code: Select all
pthread_getspecific()
-> renvoie directement le tampon déjà alloué
Code: Select all
destroy_error_buffer()
-> free(buffer)
19. STOCKAGE LOCAL AU THREAD AVEC __thread
GCC et Clang fournissent le spécificateur :
Code: Select all
__thread
Code: Select all
static __thread char g_error_buffer[256];
La fonction précédente devient beaucoup plus simple :
Code: Select all
#include <stdio.h>
#define ERROR_BUFFER_SIZE 256
char *my_strerror_tls(int error)
{
static __thread char buffer[ERROR_BUFFER_SIZE];
snprintf(buffer,
sizeof(buffer),
"Erreur numéro %d",
error);
return buffer;
}
- pthread_once() ;
- pthread_key_create() ;
- pthread_getspecific() ;
- pthread_setspecific() ;
- malloc() ;
- destructeur.
Chaque instance existe pendant la vie du thread.
Adresse
L’opérateur & peut être utilisé, mais l’adresse obtenue désigne l’instance du thread courant.
Support nécessaire
Le compilateur, l’éditeur de liens, le chargeur et le système doivent prendre en charge le stockage local au thread.
20. ÉQUIVALENTS MODERNES
En C11, le mot-clé standard est :
Code: Select all
_Thread_local
Code: Select all
static _Thread_local char buffer[256];
Code: Select all
#include <threads.h>
static thread_local char buffer[256];
En C++11 et versions ultérieures :
Code: Select all
static thread_local char buffer[256];
- utiliser _Thread_local pour du C standard moderne ;
- utiliser thread_local en C++ ;
- utiliser __thread lorsque l’on cible explicitement GCC ou Clang et que les contraintes de la plateforme sont connues ;
- utiliser les TSD Pthreads pour des données dynamiques, des destructeurs configurables ou une bibliothèque POSIX devant rester très portable.
TSD Pthreads
Avantages :
- API POSIX ;
- valeur dynamique de type pointeur ;
- destructeur associé à la clé ;
- adaptée aux structures complexes ;
- allocation paresseuse possible ;
- l’interface publique d’une fonction peut rester inchangée.
- plus de code ;
- allocation éventuelle ;
- accès indirect par une clé ;
- gestion des erreurs ;
- nombre de clés limité.
Avantages :
- syntaxe très simple ;
- accès direct à la variable ;
- aucune table de clés visible dans le code ;
- aucune allocation pour un tableau de taille fixe ;
- souvent très efficace.
- contraintes de plateforme et de modèle de chargement ;
- moins flexible pour des objets créés dynamiquement ;
- initialisation et destruction plus délicates selon le langage ;
- __thread n’est pas du C ISO.
1. Créer la clé à chaque appel
Code: Select all
pthread_key_create(&key, free);
2. Enregistrer l’adresse d’une variable locale
Code: Select all
int local;
pthread_setspecific(key, &local);
3. Oublier de libérer après un échec de pthread_setspecific()
Code: Select all
ptr = malloc(size);
if (pthread_setspecific(key, ptr) != 0) {
free(ptr);
return NULL;
}
La fonction copie uniquement la valeur du pointeur.
5. Remplacer une valeur sans libérer l’ancienne
Pthreads n’appelle pas automatiquement le destructeur au moment du remplacement.
6. Supprimer la clé trop tôt
Il est dangereux d’appeler pthread_key_delete() pendant que d’autres threads peuvent encore utiliser la clé.
7. Utiliser uniquement un mutex autour d’un tampon retourné
Si la fonction renvoie l’adresse du tampon puis déverrouille le mutex, un autre thread peut écraser le tampon avant son utilisation par l’appelant.
8. Supposer un ordre entre les destructeurs
L’ordre de nettoyage de plusieurs clés n’est pas garanti.
23. COMPILATION
Commande recommandée :
Code: Select all
gcc -std=c11 -Wall -Wextra -Wpedantic -pthread programme.c -o programme
Pour utiliser explicitement __thread, GCC ou Clang l’acceptent comme extension. Avec -Wpedantic, un avertissement peut rappeler que ce mot-clé n’appartient pas au C ISO.
24. TABLEAU RÉCAPITULATIF DES API
Élément — Rôle
pthread_once_t : Type de la variable contrôlant une initialisation unique
PTHREAD_ONCE_INIT : Initialisation statique d’un pthread_once_t
pthread_once() : Exécute une fonction d’initialisation une seule fois
pthread_key_t : Type représentant une clé de données spécifiques aux threads
pthread_key_create() : Crée une clé et associe éventuellement un destructeur
pthread_setspecific() : Associe une valeur à la clé pour le thread courant
pthread_getspecific() : Récupère la valeur du thread courant
pthread_key_delete() : Supprime la clé sans appeler les destructeurs des valeurs existantes
PTHREAD_KEYS_MAX : Limite liée au nombre de clés disponibles
sysconf(_SC_THREAD_KEYS_MAX) : Interroge la limite à l’exécution lorsque disponible
__thread : Extension GNU créant une variable distincte pour chaque thread
_Thread_local : Spécificateur standard C11 de stockage local au thread
thread_local : Spécificateur C++ et macro possible de threads.h en C
25. MÉTHODE DE CHOIX
Posez les questions suivantes :
La donnée doit-elle être réellement partagée ?
Oui : utiliser un mutex, un verrou lecture-écriture ou une primitive atomique adaptée.
L’interface peut-elle recevoir un tampon ?
Oui : préférer une fonction réentrante avec stockage fourni par l’appelant.
L’interface doit-elle rester inchangée ?
Oui : utiliser une donnée spécifique au thread.
La donnée est-elle un simple objet déclaré statiquement ?
Oui : envisager _Thread_local, thread_local ou __thread.
La donnée est-elle dynamique et doit-elle avoir un destructeur ?
Oui : utiliser pthread_key_create() avec un destructeur.
26. CONCLUSION
La meilleure fonction multithread est souvent celle qui ne partage aucun état modifiable.
Lorsque cela est possible, une interface réentrante avec un tampon fourni par l’appelant est simple, portable et efficace.
Lorsque l’interface ne peut pas être changée, les données spécifiques aux threads permettent de conserver un état privé et persistant pour chaque thread :
Code: Select all
pthread_once()
pthread_key_create()
pthread_getspecific()
pthread_setspecific()
Pour une variable simple, le stockage local au thread offre une solution encore plus directe :
Code: Select all
static _Thread_local char buffer[256];
- les données réellement partagées, qui exigent une synchronisation ;
- les données propres à un appel, qui doivent rester locales ;
- les données persistantes propres à chaque thread, qui relèvent des TSD ou du TLS.
