aujourd’hui on va voir un composant vraiment fondamental de Windows moderne : le Thread Pool Win32.
C’est un sujet important, parce qu’en pratique énormément de code système repose dessus, parfois sans que le développeur s’en rende meme compte. Services Windows, I/O asynchrones, timers, attente sur handles, exécution différée, callbacks réseau, composants COM, travail interne de bibliothèques système… le Thread Pool est partout.
Le problème, c’est que beaucoup de développeurs restent bloqués sur un modèle ancien : un travail = un thread = un CreateThread. Ce modèle peut marcher sur de petits exemples, mais il devient vite mauvais dès qu’on parle de charge réelle, de latence, de consommation mémoire, de context switch, de blocage, ou simplement d’architecture propre.
Le Thread Pool Win32 existe justement pour éviter ce genre d’erreurs.
Pourquoi le Thread Pool est un sujet fondamental
Comprendre le Thread Pool Win32 permet de mieux voir :
- comment Windows exécute du travail concurrent sans créer un thread pour chaque tâche
- pourquoi CreateThread n’est pas toujours une bonne solution
- comment soumettre du travail ponctuel, différé ou lié à des I/O
- comment intégrer timers, waits et callbacks dans un modèle cohérent
- comment Windows réutilise ses threads plutôt que de les recréer sans cesse
- comment structurer un code plus scalable et plus propre
Ce qu’est réellement le Thread Pool Win32
Le Thread Pool Win32 est un mécanisme fourni par Windows pour gérer automatiquement un ensemble de threads de travail réutilisables. Plutot que de créer un thread à chaque besoin, le système maintient un pool de threads, ajuste leur usage, planifie les callbacks, et réemploie les threads existants quand cela a du sens.
Le développeur ne manipule donc plus principalement des threads, mais des unités de travail et des objets associés.
Windows se charge notamment de :
- créer les threads nécessaires
- les réutiliser
- limiter les créations inutiles
- équilibrer la charge selon le contexte
- éviter une partie de la surcharge du modèle “un thread partout”
Pourquoi CreateThread devient vite une mauvaise approche
CreateThread n’est pas “interdit”. C’est une API légitime, utile dans certains cas précis. Mais elle ne doit pas devenir un réflexe universel.
Créer un thread manuellement implique plusieurs couts :
- coût de création et d’initialisation du thread
- stack dédiée pour chaque thread
- coût de scheduling supplémentaire
- augmentation des context switch
- pression sur les ressources du processus et du système
- difficulté croissante à superviser et synchroniser tout cela
Le problème n’est donc pas que CreateThread soit “mauvaise” en soi, mais qu’elle est trop bas niveau et trop coûteuse pour servir de réponse systématique à tout besoin concurrent.
Le vrai changement de modèle mental
Le Thread Pool impose un changement de logique très important.
Ancien modèle simpliste :
- j’ai une tâche
- je crée un thread
- le thread exécute la tâche
- je détruis le thread
- j’ai une tâche
- je décris un callback
- je soumets cette tâche au système
- Windows choisit quand et sur quel thread du pool l’exécuter
Les grands objets du Thread Pool Win32
Le Thread Pool Win32 repose sur plusieurs familles d’objets. Les plus importantes à connaitre sont :
- Work
- Timer
- Wait
- I/O
- Cleanup Group
- Thread Pool personnalisé
- Environment de callback
- Work : tâche ponctuelle
- Timer : callback différé ou périodique
- Wait : callback déclenché quand un handle devient signalé
- I/O : callback lié à une I/O asynchrone
Le callback : cœur du modèle
Tout passe par des callbacks.
Un callback du Thread Pool est du code exécuté par Windows sur un thread appartenant au pool. Cela implique immédiatement plusieurs conséquences :
- le callback doit être court si possible
- le callback ne doit pas bloquer longtemps sans nécessité
- le callback doit éviter les opérations qui paralysent inutilement un thread du pool
- le callback doit gérer proprement sa synchronisation si des données sont partagées
Types fondamentaux du Thread Pool
Pour lire ou écrire du code Thread Pool, il faut reconnaitre plusieurs types importants :
- PTP_WORK
- PTP_TIMER
- PTP_WAIT
- PTP_IO
- PTP_POOL
- PTP_CLEANUP_GROUP
- PTP_CALLBACK_ENVIRON
- PTP_CALLBACK_INSTANCE
- TP_WAIT_RESULT
PTP_CALLBACK_INSTANCE : à quoi ça sert
Un paramètre souvent ignoré par les débutants dans les callbacks est :
- PTP_CALLBACK_INSTANCE
- CallbackMayRunLong
- DisassociateCurrentThreadFromCallback
- LeaveCriticalSectionWhenCallbackReturns
- ReleaseMutexWhenCallbackReturns
- SetEventWhenCallbackReturns
- FreeLibraryWhenCallbackReturns
Le Work : l’objet de base
Le type le plus simple et souvent le plus utilisé pour commencer est le Work.
Il sert à exécuter une tâche ponctuelle sur un thread du pool. On l’utilise volontiers pour :
- du calcul
- du traitement mémoire
- de la compression
- du chiffrement
- du parsing
- du post-traitement
- une tâche indépendante sans nécessité de thread dédié
Code: Select all
VOID CALLBACK WorkCallback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WORK Work
)
{
int value = *(int*)Context;
printf("Work exécuté : %d\n", value);
}
Code: Select all
PTP_WORK work = CreateThreadpoolWork(
WorkCallback,
&data,
NULL
);
if (!work)
return 1;
Code: Select all
SubmitThreadpoolWork(work);
- WaitForThreadpoolWorkCallbacks
- CloseThreadpoolWork
Code: Select all
int main()
{
int data = 42;
PTP_WORK work = CreateThreadpoolWork(
WorkCallback,
&data,
NULL
);
if (!work)
return 1;
SubmitThreadpoolWork(work);
WaitForThreadpoolWorkCallbacks(work, FALSE);
CloseThreadpoolWork(work);
return 0;
}
WaitForThreadpoolWorkCallbacks
Cette API est très importante :
- WaitForThreadpoolWorkCallbacks
C’est bien plus propre que des Sleep arbitraires.
Le Timer du Thread Pool
Le Timer permet d’exécuter un callback après un délai, ou de manière périodique.
Il est très utile pour :
- des tâches planifiées
- des timeouts
- de la supervision périodique
- des retries
- des watchdogs simples
Code: Select all
VOID CALLBACK TimerCallback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_TIMER Timer
)
{
printf("Timer déclenché\n");
}
Code: Select all
PTP_TIMER timer = CreateThreadpoolTimer(
TimerCallback,
NULL,
NULL
);
if (!timer)
return 1;
Code: Select all
FILETIME ft;
ULONGLONG delay = (ULONGLONG)-50000000;
ft.dwLowDateTime = (DWORD)delay;
ft.dwHighDateTime = (DWORD)(delay >> 32);
SetThreadpoolTimer(timer, &ft, 0, 0);
Il faut aussi connaitre :
- WaitForThreadpoolTimerCallbacks
- CloseThreadpoolTimer
- SetThreadpoolTimerEx
SetThreadpoolTimer et temps relatifs
Le point qui perturbe souvent au début, c’est le format du temps. Le Thread Pool timer n’utilise pas directement un simple nombre de millisecondes pour la première date de déclenchement. On passe un FILETIME représentant une échéance absolue, ou une échéance relative si la valeur est négative.
Cela explique l’usage fréquent de ULONGLONG négatif casté dans un FILETIME.
Il faut donc comprendre au moins :
- temps relatif = valeur négative
- unité = 100 nanosecondes
- le paramètre de période, lui, est en millisecondes
Le Wait permet de déclencher un callback lorsqu’un handle devient signalé. C’est extrêmement pratique pour intégrer la synchronisation Win32 dans le modèle du Thread Pool.
On l’utilise par exemple avec :
- des events
- des processus
- des sémaphores
- des timers waitables
- d’autres objets noyau signalables
Code: Select all
VOID CALLBACK WaitCallback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WAIT Wait,
TP_WAIT_RESULT Result
)
{
printf("Objet signalé\n");
}
Code: Select all
PTP_WAIT wait = CreateThreadpoolWait(
WaitCallback,
NULL,
NULL
);
if (!wait)
return 1;
Code: Select all
SetThreadpoolWait(wait, hEvent, NULL);
Il faut également connaitre :
- WaitForThreadpoolWaitCallbacks
- CloseThreadpoolWait
Code: Select all
int main()
{
HANDLE hEvent = CreateEventW(NULL, FALSE, FALSE, NULL);
if (!hEvent)
return 1;
PTP_WAIT wait = CreateThreadpoolWait(
WaitCallback,
NULL,
NULL
);
if (!wait)
{
CloseHandle(hEvent);
return 1;
}
SetThreadpoolWait(wait, hEvent, NULL);
SetEvent(hEvent);
WaitForThreadpoolWaitCallbacks(wait, FALSE);
CloseThreadpoolWait(wait);
CloseHandle(hEvent);
return 0;
}
Le paramètre TP_WAIT_RESULT du callback wait indique le résultat de l’attente. Il est important notamment pour distinguer certains cas comme un timeout.
Cela devient utile dès qu’on utilise un délai dans SetThreadpoolWait.
Le Thread Pool I/O
L’intégration des I/O asynchrones avec le Thread Pool est un des aspects les plus puissants du modèle.
Ici, on ne parle plus d’un simple callback arbitraire, mais d’un callback déclenché quand une opération I/O overlapped associée à un objet éligible se termine.
Le type central est :
- PTP_IO
Code: Select all
VOID CALLBACK IoCallback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PVOID Overlapped,
ULONG IoResult,
ULONG_PTR BytesTransferred,
PTP_IO Io
)
{
printf("I/O terminé : %llu bytes\n", (unsigned long long)BytesTransferred);
}
Code: Select all
HANDLE file = CreateFileW(
L"test.txt",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED,
NULL
);
if (file == INVALID_HANDLE_VALUE)
return 1;
PTP_IO io = CreateThreadpoolIo(
file,
IoCallback,
NULL,
NULL
);
if (!io)
{
CloseHandle(file);
return 1;
}
- CreateThreadpoolIo
- StartThreadpoolIo
- CancelThreadpoolIo
- WaitForThreadpoolIoCallbacks
- CloseThreadpoolIo
Si l’I/O échoue immédiatement sans partir réellement, il faut souvent appeler CancelThreadpoolIo pour garder un état cohérent.
Thread Pool I/O et OVERLAPPED
Le Thread Pool I/O repose sur le modèle OVERLAPPED déjà connu des I/O asynchrones Win32.
Il faut donc connaitre ou reconnaitre :
- OVERLAPPED
- ReadFile / WriteFile avec FILE_FLAG_OVERLAPPED
- GetOverlappedResult
Autrement dit :
- l’objet I/O doit être ouvert en mode overlapped si nécessaire
- l’opération doit etre une vraie I/O asynchrone compatible
- le callback du pool sera appelé à la terminaison
Quand on dit qu’un callback de Thread Pool doit être rapide, cela ne veut pas dire “il doit obligatoirement finir en 2 microsecondes”. Cela veut dire qu’il faut éviter de monopoliser inutilement un thread du pool avec des attentes longues ou une logique qui n’a rien à faire là.
A éviter dans un callback si possible :
- Sleep de longue durée
- WaitForSingleObject infini sur une ressource externe
- boucles bloquantes longues
- I/O synchrones lentes sans raison
- verrous pris trop longtemps
API importante à connaitre ici :
- CallbackMayRunLong
Synchronisation dans les callbacks
Le fait que Windows gère les threads ne supprime pas les problèmes de synchronisation. Si plusieurs callbacks touchent les mêmes données, il faut toujours protéger ce qui doit l’être.
API et mécanismes typiques :
- InterlockedIncrement
- InterlockedDecrement
- InterlockedExchange
- InterlockedCompareExchange
- CRITICAL_SECTION
- SRWLOCK
- events
- sémaphores
Cleanup Group : très important pour gérer proprement un ensemble
Un des aspects les plus intéressants du Thread Pool Win32 est la possibilité de regrouper plusieurs objets dans une cleanup group.
Types et API importants :
- PTP_CLEANUP_GROUP
- CreateThreadpoolCleanupGroup
- CloseThreadpoolCleanupGroup
- CloseThreadpoolCleanupGroupMembers
- SetThreadpoolCallbackCleanupGroup
C’est extrêmement utile dans une architecture sérieuse.
Callback environment : personnaliser le comportement
Le Thread Pool possède aussi une notion de callback environment.
Type important :
- TP_CALLBACK_ENVIRON
- InitializeThreadpoolEnvironment
- DestroyThreadpoolEnvironment
- SetThreadpoolCallbackPool
- SetThreadpoolCallbackCleanupGroup
- SetThreadpoolCallbackRunsLong
- SetThreadpoolCallbackPriority
- SetThreadpoolCallbackLibrary
- choisir un pool personnalisé
- lier les objets à une cleanup group
- indiquer qu’un callback peut être long
- influencer certains aspects de priorité ou de cycle de vie
Créer son propre pool de threads
Il n’existe pas qu’un pool global implicite. On peut aussi créer un thread pool personnalisé.
API importantes :
- CreateThreadpool
- CloseThreadpool
- SetThreadpoolThreadMaximum
- SetThreadpoolThreadMinimum
Code: Select all
PTP_POOL pool = CreateThreadpool(NULL);
if (!pool)
return 1;
SetThreadpoolThreadMaximum(pool, 4);
if (!SetThreadpoolThreadMinimum(pool, 1))
{
CloseThreadpool(pool);
return 1;
}
Pool global contre pool personnalisé
Le pool par défaut fourni par Windows suffit dans beaucoup de cas. Mais un pool personnalisé devient utile lorsqu’on veut :
- isoler une sous-partie de l’application
- limiter la concurrence d’un groupe précis de tâches
- éviter qu’un domaine de travail perturbe un autre
- associer explicitement des objets à un environnement contrôlé
Priorité et bibliothèque dans l’environnement de callback
Il faut aussi connaitre de nom plusieurs réglages plus avancés :
- SetThreadpoolCallbackPriority
- SetThreadpoolCallbackLibrary
Callbacks et durée de vie des objets
Un problème classique du Thread Pool concerne la durée de vie du contexte passé aux callbacks.
Par exemple, si un callback reçoit un pointeur Context vers une structure locale ou détruite trop tôt, le programme peut crasher ou corrompre sa mémoire.
Il faut donc avoir une discipline stricte :
- le contexte passé au callback doit rester valide tant que le callback peut encore l’utiliser
- la fermeture de l’objet Thread Pool ne doit pas être faite naïvement sans attendre ou annuler correctement
- les cleanup groups et les fonctions WaitForThreadpool* aident beaucoup ici
Le Thread Pool propose aussi des fonctions très pratiques pour exécuter automatiquement certaines actions quand le callback se termine.
API importantes :
- FreeLibraryWhenCallbackReturns
- LeaveCriticalSectionWhenCallbackReturns
- ReleaseMutexWhenCallbackReturns
- ReleaseSemaphoreWhenCallbackReturns
- SetEventWhenCallbackReturns
DisassociateCurrentThreadFromCallback
Une autre API avancée importante est :
- DisassociateCurrentThreadFromCallback
Annulation et arrêt propre
Le Thread Pool n’est pas seulement une API de création. Il faut aussi savoir fermer et attendre proprement.
Pour chaque type d’objet, il existe en général une paire logique :
- attendre les callbacks
- fermer l’objet
- WaitForThreadpoolWorkCallbacks
- WaitForThreadpoolTimerCallbacks
- WaitForThreadpoolWaitCallbacks
- WaitForThreadpoolIoCallbacks
- CloseThreadpoolWork
- CloseThreadpoolTimer
- CloseThreadpoolWait
- CloseThreadpoolIo
Différence avec les vieux QueueUserWorkItem / BindIoCompletionCallback
Il faut aussi savoir que Windows possédait ou possède d’autres mécanismes voisins, comme :
- QueueUserWorkItem
- BindIoCompletionCallback
Quand le Thread Pool est un bon choix
Le Thread Pool est particulièrement adapté quand :
- on a des tâches ponctuelles ou fréquentes sans besoin de thread dédié
- on veut intégrer timers, waits et I/O dans un cadre homogène
- on veut éviter la prolifération de threads manuels
- on écrit un service ou un composant longue durée
- on vise une architecture plus scalable
Structures, types et API à connaitre absolument
Pour avoir déjà une base propre, il faut reconnaitre rapidement :
- PTP_WORK
- PTP_TIMER
- PTP_WAIT
- PTP_IO
- PTP_POOL
- PTP_CLEANUP_GROUP
- PTP_CALLBACK_ENVIRON
- PTP_CALLBACK_INSTANCE
- TP_WAIT_RESULT
- FILETIME
- OVERLAPPED
- CreateThreadpoolWork
- SubmitThreadpoolWork
- WaitForThreadpoolWorkCallbacks
- CloseThreadpoolWork
- CreateThreadpoolTimer
- SetThreadpoolTimer
- SetThreadpoolTimerEx
- WaitForThreadpoolTimerCallbacks
- CloseThreadpoolTimer
- CreateThreadpoolWait
- SetThreadpoolWait
- WaitForThreadpoolWaitCallbacks
- CloseThreadpoolWait
- CreateThreadpoolIo
- StartThreadpoolIo
- CancelThreadpoolIo
- WaitForThreadpoolIoCallbacks
- CloseThreadpoolIo
- CreateThreadpool
- CloseThreadpool
- SetThreadpoolThreadMaximum
- SetThreadpoolThreadMinimum
- CreateThreadpoolCleanupGroup
- CloseThreadpoolCleanupGroup
- CloseThreadpoolCleanupGroupMembers
- InitializeThreadpoolEnvironment
- DestroyThreadpoolEnvironment
- SetThreadpoolCallbackPool
- SetThreadpoolCallbackCleanupGroup
- SetThreadpoolCallbackRunsLong
- SetThreadpoolCallbackPriority
- SetThreadpoolCallbackLibrary
- CallbackMayRunLong
- DisassociateCurrentThreadFromCallback
- SetEventWhenCallbackReturns
- LeaveCriticalSectionWhenCallbackReturns
- ReleaseMutexWhenCallbackReturns
- ReleaseSemaphoreWhenCallbackReturns
- FreeLibraryWhenCallbackReturns
Comme souvent en développement système, les erreurs les plus gênantes viennent d’un mauvais modèle mental.
Parmi les fautes fréquentes :
- utiliser Sleep au lieu des fonctions WaitForThreadpool* pour “laisser le callback finir”
- passer un contexte dont la durée de vie est trop courte
- bloquer longtemps un callback sans réfléchir
- oublier de fermer les objets Thread Pool
- ne pas attendre correctement avant destruction
- croire que le Thread Pool supprime tous les problèmes de synchronisation
- oublier StartThreadpoolIo avant une I/O overlapped
- ne pas annuler proprement quand une I/O ne part pas
- ignorer les cleanup groups dans une architecture un peu sérieuse
Le Thread Pool Win32 est le modèle moderne de gestion du travail concurrent coté Windows. Il permet de confier des unités de travail au système plutôt que de créer un thread pour chaque besoin. Windows gère alors la réutilisation, l’exécution et une partie de la planification de manière bien plus efficace que le modèle naïf fondé sur CreateThread partout.
Les idées essentielles à garder sont les suivantes :
- on ne raisonne plus seulement en threads, mais en tâches et callbacks
- Work, Timer, Wait et I/O sont les quatre objets les plus importants au début
- les callbacks doivent rester aussi courts et propres que possible
- les waits corrects et la fermeture propre sont indispensables
- les cleanup groups et callback environments sont très importants dans une vraie architecture
- le Thread Pool s’intègre très bien avec les I/O overlapped et la synchronisation Win32
- c’est l’un des grands modèles recommandés pour le code concurrent moderne en Win32
Comprendre le Thread Pool Win32, c’est comprendre comment Windows moderne exécute une grande partie du travail parallèle sans tomber dans le piège “un thread pour chaque chose”. C’est aussi comprendre qu’il existe un vrai cadre unifié pour les tâches, les timers, les waits, les I/O et le nettoyage des callbacks.
Si tu maitrises déjà correctement :
- CreateThreadpoolWork et SubmitThreadpoolWork
- CreateThreadpoolTimer et SetThreadpoolTimer
- CreateThreadpoolWait et SetThreadpoolWait
- CreateThreadpoolIo, StartThreadpoolIo et WaitForThreadpoolIoCallbacks
- WaitForThreadpoolWorkCallbacks / TimerCallbacks / WaitCallbacks / IoCallbacks
- PTP_CALLBACK_ENVIRON, cleanup groups et pool personnalisé
- CallbackMayRunLong et les fonctions WhenCallbackReturns
Et surtout, tu commences à voir que le vrai sujet n’est pas seulement “comment lancer du code sur un autre thread”, mais “comment laisser Windows exécuter intelligemment le travail concurrent dans un cadre propre et scalable”.
A bientot pour le prochain cours
