Table of Contents
Introduction aux fils POSIX
Le multithreading est une technique de programmation qui permet à un seul processus d'exécuter simultanément plusieurs tâches, ce qui permet de mieux utiliser les ressources du processeur et d'améliorer la réactivité de l'application. Dans le langage de programmation C, la norme la plus largement adoptée pour le multithreading sur les systèmes de type Unix est la bibliothèque de fils POSIX, communément appelée pthreads. L'API pthreads fournit un ensemble complet de fonctions pour la création, la synchronisation et la gestion de fils.
En utilisant pthreads, les développeurs peuvent concevoir des programmes qui effectuent des calculs de fond, gèrent plusieurs connexions client simultanément, ou parallélisent les tâches de traitement de données. La bibliothèque abstractionne les détails du système d'exploitation de bas niveau tout en donnant un contrôle fin sur le comportement du thread. Une bonne compréhension des pthreads est essentielle pour tout programmeur C travaillant sur des applications sensibles aux performances ou concurrentes.
Concepts de base des fils de pthread
Avant d'écrire un code multithreaded, il est important de se familiariser avec les types de données et les fonctions de base que pthreads fournit :
- Thread: Unité d'exécution légère qui fonctionne dans l'espace d'adresse d'un processus. Plusieurs threads partagent la même mémoire, les descripteurs de fichiers et d'autres ressources, rendant la communication entre threads efficace mais introduisant également la nécessité d'une synchronisation minutieuse.
- pthread t[: Un type de données opaque utilisé pour représenter un identifiant de thread. Ce n'est pas un entier; vous devriez le traiter comme une poignée retournée par et utilisée dans des appels comme .
- pthread create[: La fonction utilisée pour créer un nouveau thread. Elle prend quatre arguments: un pointeur à une variable , un pointeur à un objet d'attribut thread (généralement pour les valeurs par défaut), la fonction que le thread exécutera (un pointeur à une fonction qui retourne et prend un seul argument ), et un argument à passer à cette fonction.
- pthread join: Appel de blocage qui attend la fin d'un thread spécifique. Il récupère également la valeur de retour de la routine de démarrage du thread.
- pthread mutex t: Le type de données pour un verrou mutex (exclusion mutuelle), qui est le primitif fondamental de synchronisation utilisé pour protéger les données partagées d'un accès simultané.
- pthread cond t[: Une variable de condition utilisée en conjonction avec les mutexes pour permettre aux threads d'attendre que des conditions spécifiques deviennent vraies.
Les fils sont créés dans un état detachéable ou joinable[. Par défaut, les fils sont joignables, ce qui signifie qu'il y a un appel correspondant qui nettoie leurs ressources. Si vous ne comptez pas rejoindre un fil, vous pouvez le détacher avec pour que ses ressources soient automatiquement récupérées dès la fin.
Mettre en œuvre un programme multithreadé simple
La démonstration classique de pthreads implique la création de deux threads qui s'exécutent simultanément et impriment des messages. L'exemple ci-dessous illustre les étapes principales : déclarer des variables , appeler avec une routine de démarrage, puis attendre que les deux threads finissent par .
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void* print_message(void* message) {
char* msg = (char*) message;
printf("%s\n", msg);
return NULL;
}
int main(void) {
pthread_t thread1, thread2;
char* message1 = "Hello from Thread 1!";
char* message2 = "Hello from Thread 2!";
if (pthread_create(&thread1, NULL, print_message, (void*) message1) != 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
if (pthread_create(&thread2, NULL, print_message, (void*) message2) != 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}
Dans ce programme, crée deux fils, chacun d'eux appelle . Les appels assurent que le fil principal attend que les deux fils enfants finissent avant de sortir. Notez que l'ordre de sortie n'est pas garanti: le programmeur du système d'exploitation peut interverger l'exécution des deux fils, de sorte que vous pouvez voir "Bonjour de Thread 2!" imprimé avant le premier message.
Il est de bonne pratique de toujours vérifier la valeur de retour de et d'autres fonctions pthread. Ils retournent zéro sur le succès, ou un code d'erreur positif sur l'échec (similaire à . Le programme ci-dessus utilise et pour gérer les échecs gracieusement.
Cycle de vie et attributs du fil
Chaque thread dans pthreads a un cycle de vie : création, exécution, terminaison et nettoyage. Lorsqu'un thread est créé, il hérite de nombreux attributs du processus d'appel, mais vous pouvez affiner son comportement en utilisant un objet . Les attributs communs incluent :
- État de détachement : Que le thread soit créé comme jumelé (par défaut) ou détaché.
- Taille de la pile[: Permet d'attribuer une taille de pile spécifique au fil.
- Politique et priorité d'établissement des calendriers: Pour le contrôle en temps réel des fils (qui nécessite des privilèges appropriés).
Pour définir des attributs, initialiser un avec , modifier avec des fonctions comme ou , puis passer le pointeur d'attribut comme second argument à . Une fois fait, appeler . Les objets d'attribut ne sont pas modifiés par le thread; ils ne sont utilisés que pendant la création.
Synchronisation avec Mutexes
Lorsque plusieurs threads accèdent simultanément aux données partagées, il existe un risque de course de données — situations où le résultat dépend de l'ordre imprévisible de l'exécution des threads. Pour éviter cela, pthreads fournit des mutexes. Un mutex assure qu'un seul thread à la fois peut exécuter une section critique de code. Le motif typique est :
- Initialiser un mutex avec (ou statiquement avec ).
- Verrouillez le mutex avant d'accéder aux données partagées avec .
- Déverrouillez le mutex après la section critique avec .
- Détruisez le mutex quand il n'est plus nécessaire avec .
L'exemple suivant montre un compteur partagé incrémenté par deux fils avec une protection appropriée du mutex:
#include <pthread.h>
#include <stdio.h>
int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void* increment(void* arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Final counter value: %d\n", counter); // Guaranteed to be 2000000
pthread_mutex_destroy(&lock);
return 0;
}
Sans le mutex, la valeur finale du compteur serait probablement inférieure à 2 000 000 en raison des conditions de course. Le mutex sérialise les opérations d'accroissement, assurant la sécurité des fils. Cependant, un verrouillage excessif peut dégrader les performances; l'art de la programmation multithreaded est de minimiser la taille et la durée des sections critiques.
Types Mutex et gestion des erreurs
POSIX définit plusieurs types de mutex contrôlés par l'attribut type:
- Normal (): Un simple mutex qui ne détecte pas l'impasse. La tentative de le verrouiller à nouveau à partir du même fil donne lieu à deadlock.
- Error-checking (): Fournit une détection d'erreur; si le même thread essaie de reverrouiller un mutex déjà détenu, il retourne .
- Récursifs (): Permet au fil propriétaire de verrouiller le mutex plusieurs fois sans blocage. Chaque verrou doit être jumelé à un déverrouillage.
- Défaut ([): L'implémentation peut se mapper à l'un des éléments ci-dessus. Sur Linux (glibc), elle est équivalente à .
Dans le code de production, vous devez gérer les erreurs potentielles (p. ex. ] pour un mutex invalide, si le thread n'est pas propriétaire du mutex).
Variables de condition
Les variables de condition permettent aux threads d'attendre qu'une condition spécifique devienne vraie. Ils sont toujours utilisés avec un mutex. Le motif typique est : un thread verrouille le mutex, vérifie un prédicat (une variable partagée), et si le prédicat est faux, il appelle qui libère atomiquement le mutex et met le thread à dormir. Quand un autre thread signale la condition (avec ou ), le thread d'attente réquisitionne le mutex et revérifie le prédicat.
Voici un exemple classique de producteur-consommateur utilisant une variable de condition unique et un mutex:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
void* producer(void* arg) {
sleep(1); // Simulate work
pthread_mutex_lock(&mutex);
data_ready = 1;
printf("Producer: data ready\n");
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
return NULL;
}
void* consumer(void* arg) {
pthread_mutex_lock(&mutex);
while (!data_ready) {
pthread_cond_wait(&cond, &mutex);
}
printf("Consumer: processing data\n");
pthread_mutex_unlock(&mutex);
return NULL;
}
int main(void) {
pthread_t prod, cons;
pthread_create(&cons, NULL, consumer, NULL);
pthread_create(&prod, NULL, producer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
Notez la boucle : une variable de condition peut souffrir de spurious wakeups (un fil peut revenir de sans être réellement signalé).
Diffusion par rapport au signal
Utilisez quand un seul fil d'attente doit se réveiller (p. ex., une seule ressource devient disponible); utilisez lorsque tous les fils d'attente doivent se réveiller (p. ex., un drapeau d'arrêt). L'utilisation incorrecte du signal peut entraîner une perte de réveil et la famine du fil.
Verrouillages de lecture
Pour les structures de données qui sont lues fréquemment mais qui sont rarement écrites, un verrou de lecture () peut améliorer la concurrence. Plusieurs lecteurs peuvent maintenir le verrou simultanément, mais un auteur nécessite un accès exclusif. L'API est similaire aux mutexes : , , et . Les verrous de lecture-écriture sont particulièrement utiles dans des scénarios comme les tables de mise en cache ou de configuration qui sont rarement mises à jour.
Exemple :
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
void* reader(void* arg) {
pthread_rwlock_rdlock(&rwlock);
// read shared data
pthread_rwlock_unlock(&rwlock);
return NULL;
}
void* writer(void* arg) {
pthread_rwlock_wrlock(&rwlock);
// modify shared data
pthread_rwlock_unlock(&rwlock);
return NULL;
}
Sachez que les serrures de lecture peuvent être moins efficaces que les mutexes lorsque la section critique est très courte, et elles peuvent causer la famine des écrivains si les lecteurs arrivent en permanence.
Pièges communs et pratiques exemplaires
La programmation multithreadée en C est puissante mais sujette à erreur. Ci-dessous sont les problèmes fréquents et comment les éviter.
Courses de données et d'effacements
Une course de données se produit lorsque deux threads accèdent à la même position de mémoire sans synchronisation et au moins une écriture. C11 a ajouté la qualificatif , mais pthreads ne fournit pas directement d'opérations atomiques; vous devez utiliser des serrures. A deadlock se produit lorsque deux threads ou plus attendent l'un l'autre pour libérer un verrou. Pour éviter les impasses:
- Toujours acquérir des serrures dans un ordre global cohérent.
- Utilisez ou si vous ne pouvez pas garantir la commande.
- Garder les sections critiques aussi courtes que possible.
Sécurité des fonctions de la bibliothèque
Par exemple, utilise un état statique interne. Utilisez les versions réentrantes (p. ex. ) ou protégez les appels avec un mutex. Les fonctions comme ne sont pas non plus sans danger; préférez avec une graine par fil.
Taille de la pile et limites des ressources
Chaque thread a sa propre pile. La taille par défaut de la pile peut être grande (par exemple, 8 Mo sur Linux). La création de milliers de threads avec des threads par défaut peut épuiser la mémoire. Utilisez pour régler les tailles de la pile lorsque vous connaissez les exigences d'un thread. Soyez également conscient des limites par processus : limite le nombre de threads qu'un seul processus peut créer.
Annulation et nettoyage des fils
Vous pouvez annuler un fil avec . L'annulation peut être asynchrone[ (immédiat) ou avancé (jusqu'à ce que le fil atteigne un point d'annulation comme ou ). L'annulation différée est plus sûre car elle permet au fil de libérer des verrous et de nettoyer les ressources. Utilisez et ] pour enregistrer les gestionnaires de nettoyage. Évitez d'annuler les fils sauf si cela est absolument nécessaire; il est souvent préférable d'utiliser un drapeau partagé que le fil vérifie périodiquement.
Sujets avancés : Pools de fils et files d'attente
La création et la destruction de threads pour chaque petite tâche sont coûteuses. Un thread pool pré-crée un nombre fixe de threads de travail qui restent en attente jusqu'à ce que les tâches soient soumises. Les tâches sont généralement stockées dans une file d'attente de travail (une structure de données synchronisée). Le thread pool est une pierre angulaire des applications de serveur haute performance.
Débogue et réglage des performances
Les bugs multithreadés sont notoirement difficiles à reproduire et à corriger. Utilisez des outils comme Valgrind=s Helgrind ou ThreadSanitizer[ (facile par compilation avec ) pour détecter les courses de données et les impasses. GDB prend en charge le débogage multithreaded avec des commandes comme et . Pour le profilage des performances, Linux=s ou peuvent aider à identifier les goulots d'étranglement.
Conclusion
POSIX Threads (pthreads) provide a robust, standard interface for multithreading in C. By mastering thread creation, mutexes, condition variables, and read‑write locks, you can build concurrent applications that are both efficient and correct. Start with simple examples, always handle errors, and gradually incorporate advanced patterns like thread pools. For further reading, consult the IEEE POSIX specification, the Linux pthreads man page, and the book Programming with POSIX Threads by David R. Butenhof. With careful design and testing, pthreads can dramatically improve the performance and responsiveness of your C programs while remaining portable across Unix‑like systems.