API du pool de threads Win32 — de la concurrence sans créer de threads, avec CreateThreadpoolWork
· Mis à jour le: · Go Komura · Windows, Multithreading, C++, Développement Windows, Win32 API, Amélioration des performances
Historique des révisions (première version, publiée le 22 Aug 2026)
- Première publication
Citer cet article(DOI (archive enregistrée): 10.5281/zenodo.22176802)
Les DOI ci-dessous renvoient à des versions déjà archivées et peuvent différer du texte actuel. Pour citer le texte actuel, utilisez l’URL de cette page.
Go Komura (2026). API du pool de threads Win32 — de la concurrence sans créer de threads, avec CreateThreadpoolWork. KomuraSoft LLC. https://comcomponent.com/fr/blog/win32-thread-pool-api/
- DOI (archive enregistrée)
- 10.5281/zenodo.22176802
- DOI (dernière version enregistrée)
- 10.5281/zenodo.22176803
« Un CreateThread par client. » « Un pour le minuteur. » « Un pour attendre un événement. » Dans le code natif Windows, on a tendance à multiplier les threads pour de petits travaux. Chaque thread consomme une pile et un objet noyau, et les créer et les détruire a aussi un coût.
Ce qui absorbe ce décalage, c’est l’API du pool de threads Win32, standard de l’OS. L’application remet le traitement qu’elle veut exécuter sous forme de callback et laisse la gestion des threads worker à l’OS. « Ne pas créer de threads » ne signifie pas que les threads deviennent inutiles. Cela signifie que l’application ne crée et ne détruit pas elle-même un thread à chaque travail.1
Cet article s’adresse aux développeurs qui écrivent des applications, des services et des DLL Windows en C/C++. Il suit l’ordre choix de l’outil → sélection de l’objet → mise en œuvre de work → précautions sur les callbacks et l’arrêt. Le sujet est la famille CreateThreadpoolWork refondue sous Windows Vista. Le code est un extrait qui montre le squelette de l’usage ; les parties propres à l’application, telles que la file, sont omises.
1. D’abord la conclusion
Si vous traitez un grand nombre de travaux de courte durée, gérez des travaux plutôt que des threads. C’est toutefois l’application qui conçoit quand un travail s’arrête et quand il peut être libéré.
La décision s’articule autour de trois axes.
- Choisir l’outil. Si les outils du C++ standard ou de .NET suffisent, utilisez-les. Le tour de cette API arrive lorsque vous voulez unifier minuteurs, attentes et achèvement d’E/S sous Win32, ou scinder les pools.
- Remettre le travail par unités de travaux. Utilisez work, timer, wait et io de la nouvelle API, et laissez sur un thread dédié les travaux qui ont besoin d’un état propre au thread, telle une priorité de thread ou une STA COM.
- Construire l’arrêt comme un ensemble. La forme de base est « cesser de soumettre → attendre l’achèvement → fermer ». Dans un callback, ne bloquez pas longtemps, n’attendez pas de façon synchrone un travail du même pool, et rétablissez l’état du thread.
Si vous voulez d’abord faire tourner quelque chose, commencez par l’exemple work du chapitre 4, puis vérifiez la discipline des callbacks au chapitre 5. L’arrêt groupé de plusieurs objets est traité au chapitre 6, le déchargement d’une DLL au chapitre 7.
2. Choix — pool, thread dédié, bibliothèque standard
2.1 Un pool convient aux travaux courts, nombreux, centrés sur l’attente
Un pool de threads est un ensemble de threads worker gérés par l’OS. Les workers exécutent les callbacks les uns après les autres, et l’OS ajuste leur nombre selon la charge. En remettant le travail que vous faisiez en créant et en démontant vos propres threads, vous réduisez à la fois le code de gestion et le coût de création et de destruction.1
La documentation officielle cite comme candidates les applications qui émettent en parallèle un grand nombre de petits éléments de travail, celles qui créent et détruisent fréquemment des threads de courte durée, celles qui traitent en parallèle des travaux indépendants en arrière-plan, et celles qui ont des threads dédiés à l’attente d’objets noyau ou d’événements. La recherche, les E/S réseau et le regroupement des threads qui ne font qu’attendre sont typiques.1
En revanche, un travail qui a besoin d’un changement de priorité de thread, d’une STA COM, ou qui continue pendant toute la durée de vie du processus, reste sur un thread dédié. Le critère est si le traitement suffit à s’exécuter, ou si le thread lui-même a besoin d’une « personnalité ».
flowchart TB
accTitle: Choix entre thread dédié et pool
accDescr: Vérifier d'abord si le thread a besoin d'une personnalité telle qu'une priorité ou une STA, et s'il s'exécute longtemps ; seuls les travaux courts, nombreux ou d'attente qui ne correspondent à ni l'un ni l'autre vont sur le pool de threads
q1{"Personnalité nécessaire, p. ex. priorité ou STA ?"} -->|"Oui"| ded["Garder sur un thread dédié"]
q1 -->|"Non"| q2{"S'exécute longtemps ?"}
q2 -->|"Oui"| ded
q2 -->|"Non"| pool["Mettre sur le pool de threads"]
pool -.-> ex["Travaux courts, attentes, minuteurs, achèvements d'E/S"]
Figure 1 : Seuls les travaux qui « n’ont pas besoin de personnalité et se terminent vite » vont sur le pool. Tout le reste reste sur un thread dédié comme auparavant.
2.2 Se demander d’abord si le C++ standard ou .NET suffit
Si la granularité est couverte par std::async ou std::thread en C++, la bibliothèque standard est le premier candidat. Elle est portable, et le comportement de std::async et de future se traite sur la base de la norme.2
Les raisons d’utiliser directement le pool de threads Win32 sont des exigences telles que vouloir un mécanisme de callback unifié qui inclut timer, wait et io ; vouloir des pools ou des nombres de threads distincts par type de travail ; ne pas vouloir détenir ses propres threads dans une DLL ou un composant COM. Séparez la question de savoir si vous voulez simplement du parallélisme de celle de savoir si vous avez besoin d’un contrôle propre à Windows.
flowchart TB
accTitle: Décider avec les outils de quelle couche écrire
accDescr: Si async ou thread du C++ standard suffisent, les utiliser ; utiliser directement le pool de threads Win32 lorsqu'il faut unifier minuteurs, attentes et achèvement d'E/S, scinder les pools ou contrôler le nombre de threads, ou éviter de détenir des threads dans une DLL ou un composant COM
q1{"Les outils du C++ standard suffisent-ils ?"} -->|"Oui"| std["std::async / std::thread"]
q1 -->|"Non"| q2{"De quoi a-t-on besoin ?"}
q2 -->|"Unifier timer, wait et io"| tp["Pool de threads Win32"]
q2 -->|"Scinder les pools / contrôler les nombres"| tp
q2 -->|"Éviter ses propres threads dans une DLL"| tp
Figure 2 : Dans le doute, commencez par la bibliothèque standard ; le tour de cette API arrive lorsqu’apparaît une exigence qu’elle ne peut pas exprimer.
En .NET, ThreadPool et Task jouent le même rôle, et l’achèvement des E/S est lié à IOCP. Pour une explication détaillée de la relation, voir l’article sur IOCP et le pool de threads .NET. Il n’est pas nécessaire de descendre jusqu’à l’API Win32 là où les outils de la couche au-dessus suffisent.
2.3 Dans le code neuf, utiliser la nouvelle API à partir de Vista
Il existe deux générations d’API de pool de threads Win32 : l’ancienne API qui se poursuit depuis Windows 2000, telles que QueueUserWorkItem et RegisterWaitForSingleObject, et la famille CreateThreadpoolWork entièrement refondue sous Windows Vista.
La nouvelle API a unifié les types de threads worker et a fourni une file unique de minuteurs, des threads persistants dédiés, plusieurs pools indépendants dans un processus, et des groupes de nettoyage. La documentation officielle cite aussi comme avantages la simplicité, la fiabilité, les performances et la flexibilité.13
L’ancienne API a aussi une contrainte structurelle : il n’y a aucun moyen d’annuler un travail une fois qu’il a été mis en file. Utilisez la nouvelle API dans le code neuf, et lorsque vous passez en revue le code existant, partez de la correspondance suivante.3
flowchart TB
accTitle: Correspondance entre l'ancienne API de pool de threads et la nouvelle API
accDescr: QueueUserWorkItem de l'ancienne API est remplacé par l'objet work de la nouvelle API, les files de minuteurs par timer, les attentes enregistrées par wait, et BindIoCompletionCallback par io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Files de minuteurs"] --> n2["timer"]
o5["Attentes enregistrées"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
Figure 3 : La cible de migration depuis l’ancienne API est fixée un pour un. Un inventaire du code existant peut partir de cette correspondance.
3. Fonctionnement — quatre objets aux conditions de déclenchement différentes
3.1 Choisir selon ce qui doit déclencher le callback
Au centre de la nouvelle API se trouvent les quatre types suivants. Choisissez non seulement selon « ce qu’il faut traiter », mais selon ce qui doit déclencher le callback.4
| Objet | Fonction de création | Condition de déclenchement du callback |
|---|---|---|
| work | CreateThreadpoolWork |
Lorsqu’il est soumis avec SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer |
Lorsque l’instant ou la période indiqués arrivent |
| wait | CreateThreadpoolWait |
Lorsqu’un objet noyau passe à l’état signalé |
| io | CreateThreadpoolIo |
Lorsque l’E/S asynchrone sur le handle associé s’achève |
Les conditions de déclenchement diffèrent, mais ce sont les workers du même pool qui exécutent. Au lieu d’écrire le traitement périodique, la réaction aux événements et le traitement d’achèvement d’E/S chacun sur son thread dédié, vous pouvez les aligner sur un mécanisme de callback commun.
flowchart TB
accTitle: Séparer les conditions de déclenchement de l'exécution des callbacks
accDescr: Les workers du pool exécutent les callbacks que des objets aux conditions de déclenchement ont rendus prêts, et un worker qui a fini sert aussi au callback suivant, de sorte que l'application ne crée pas un thread par travail
app["L'application indique le travail et sa condition de déclenchement"] --> obj["L'objet attend la condition"]
obj --> ready["Le callback devient exécutable"]
ready --> workers["Les workers du pool l'exécutent"]
workers --> done["Terminer et rendre le worker"]
done --> next["Réutilisé aussi pour le callback suivant"]
Figure 4 : Séparer le mécanisme qui attend le déclencheur des workers qui exécutent le traitement réduit la gestion des threads par travail.
3.2 Les « threads qui ne font que dormir et attendre » peuvent aussi être remplacés
Le bénéfice du pool ne se limite pas au travail qui utilise le processeur. Les minuteurs sont regroupés dans une file unique, et l’attente de plusieurs handles sur un petit nombre de threads d’attente.1
Par exemple, si vous avez cinq threads qui ne font que dormir pour « s’exécuter lorsque l’événement est signalé », vous pouvez les remplacer par cinq objets wait. Au lieu de détenir un thread qui dort de son côté pour chacun, vous exécutez le traitement au signal sous forme de callback.
flowchart TB
accTitle: Remplacer les threads qui ne font qu'attendre par des objets wait
accDescr: Les threads qui ne font qu'attendre, un par événement, sont regroupés sur les threads d'attente du pool lorsqu'on les remplace par des objets wait, et le callback ne s'exécute qu'au signal
old2["5 threads qui ne font qu'attendre, dormant chacun de leur côté"] -.-> waste["Consomme 5 piles et 5 threads"]
new2["5 objets wait"] --> agg["Regroupés sur les threads d'attente du pool"]
agg --> cb2["Callback uniquement au signal"]
Figure 5 : Les « threads qui ne font que dormir et attendre » peuvent être éliminés en les transformant en objets wait. C’est le premier pas évident d’une migration vers le pool.
4. Bases de mise en œuvre — créer un work, le soumettre, s’arrêter proprement
4.1 D’abord un aller-retour de la création à l’arrêt
Parcourez l’usage une fois avec l’objet le plus basique, work. CreateThreadpoolWork lie le callback à un context, et SubmitThreadpoolWork demande l’exécution. Si le troisième argument est NULL, le pool par défaut du processus est utilisé. De nombreux usages se contentent de ce pool par défaut.56
Ce qui suit est un extrait qui montre la procédure, pas un programme complet qui compile tel quel. WORK_QUEUE, ITEM, Enqueue, Dequeue et ProcessItem représentent le traitement côté application. Implémentez à part l’exclusion mutuelle de la file, l’arrêt du côté qui soumet, et le traitement des échecs, et si la création du work échoue, n’enchaînez pas sur la soumission, l’attente et la libération.
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// Le context est fixé à la création. Les données par élément passent par une file synchronisée
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // Extraire un élément sous exclusion mutuelle
ProcessItem(item);
}
// 1) Création (lier le callback au contexte partagé, c'est-à-dire la file)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* traitement d'échec avec GetLastError */ }
// 2) Soumettre une fois pour chaque élément enfilé (faire correspondre le nombre d'éléments et de soumissions)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) Arrêter le côté qui soumet, puis attendre l'achèvement (TRUE tente aussi d'annuler ce qui n'a pas encore démarré)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) Fermer
CloseThreadpoolWork(work);
flowchart TB
accTitle: Cycle de vie d'un objet work
accDescr: Créer avec CreateThreadpoolWork ; soumettre avec SubmitThreadpoolWork exécute les callbacks en parallèle. À l'arrêt, cesser d'abord les nouvelles soumissions, attendre l'achèvement de tous les callbacks avec WaitForThreadpoolWorkCallbacks, puis fermer avec CloseThreadpoolWork
c["Créer avec CreateThreadpoolWork"] --> s["Soumettre avec SubmitThreadpoolWork (répétable)"]
s --> run["Les callbacks s'exécutent en parallèle"]
run --> stop3["Cesser les nouvelles soumissions"]
stop3 --> w["Attendre l'achèvement avec WaitForThreadpoolWorkCallbacks"]
w --> cl["Fermer avec CloseThreadpoolWork"]
Figure 6 : L’ordre d’arrêt est « cesser de soumettre → attendre l’achèvement → fermer ». Sauter une étape donne un accès après libération ou une course.
4.2 Un work peut être réutilisé, mais le context ne change pas à chaque soumission
Le même objet work peut être soumis plusieurs fois, même avant que le callback précédent soit terminé. Chaque soumission exécute le callback, et plusieurs instances s’exécutent en parallèle. Le nombre de threads réellement utilisés peut être ajusté par le pool pour l’efficacité.7
Ce qu’il faut distinguer ici, c’est le work et les données de chaque élément de travail. Le context passé au callback est fixé à la création. Pour traiter N éléments de données différentes avec un seul work, faites d’une file synchronisée le context, comme dans l’exemple ci-dessus. Soumettez une fois pour chaque élément enfilé, et faites extraire un élément de la file par le callback. Un dessin qui crée un objet work par élément convient aussi.5
flowchart TB
accTitle: Recevoir les données par élément via un context fixe
accDescr: Le context est fixé à une file partagée à la création du work ; le côté qui soumet soumet une fois pour chaque élément enfilé, et chaque callback qui s'exécute en parallèle extrait un élément à la fois de la file sous exclusion mutuelle
create["Fixer le context à la création du work"] -.-> queue["File partagée avec exclusion mutuelle"]
item["Enfiler un élément"] --> queue
item --> submit["Soumettre une fois par élément"]
submit --> callbacks["Les callbacks s'exécutent en parallèle"]
callbacks --> dequeue["Extraire un élément chacun"]
queue --> dequeue
dequeue --> process["Traiter l'élément extrait"]
Figure 7 : Ce qui est fixé, c’est le context qui pointe vers la file ; les données par élément passent par la file synchronisée.
4.3 À l’arrêt, cesser de soumettre avant d’attendre
L’ordre d’arrêt sûr est « cesser les nouvelles soumissions → attendre l’achèvement avec WaitForThreadpoolWorkCallbacks → fermer avec CloseThreadpoolWork ». La mémoire à laquelle le callback se réfère ne doit pas non plus être libérée avant que l’achèvement soit confirmé. Si vous libérez la cible de la référence alors qu’il reste un traitement en cours ou en attente, vous obtenez un accès après libération.6
Penser « j’ai appelé l’attente d’achèvement, donc c’est sûr » ne suffit pas. Si un autre thread peut encore appeler SubmitThreadpoolWork, une soumission après l’attente entre en course avec Close. Pour que l’attente soit une coupure dans l’arrêt, arrêter d’abord le côté qui soumet est un préalable.
Si le deuxième argument de WaitForThreadpoolWorkCallbacks est FALSE, il attend l’achèvement ; s’il est TRUE, il demande aussi l’annulation des callbacks qui n’ont pas encore démarré. Cela ne signifie pas que le traitement déjà en cours peut être laissé en plan. La façon d’arrêter plusieurs objets ensemble est traitée au chapitre 6.
5. Discipline des callbacks — ne pas monopoliser ni contaminer un thread emprunté
5.1 Déclarer un travail long, et vérifier aussi la valeur de retour
Le pool ajuste son nombre de threads en partant du principe que les callbacks reviennent rapidement. Si vous poursuivez un traitement long ou une longue attente sans rien indiquer, l’exécution des autres callbacks est retardée. Lorsque le travail peut durer, indiquez cette possibilité au pool avec CallbackMayRunLong ou déplacez-le sur un thread dédié.8
Toutefois, appeler CallbackMayRunLong ne suffit pas. La fonction renvoie FALSE lorsqu’elle ne peut pas mettre un worker à disposition des autres callbacks. Plutôt que d’ignorer la valeur de retour et de continuer à bloquer, penchez dans ce cas vers ne pas encombrer le pool : fractionner le traitement, le déplacer sur un thread dédié, etc.8
flowchart TB
accTitle: Décider comment traiter un callback de longue durée
accDescr: Un travail qui a besoin d'un traitement long ou d'une longue attente est soit déplacé sur un thread dédié, soit annoncé au pool avec CallbackMayRunLong ; si FALSE revient parce qu'aucun autre worker n'a pu être mis à disposition, on évite de bloquer en fractionnant ou en déplaçant vers un thread dédié
long["Traitement long ou attente longue nécessaires"] --> choice{"Où l'exécuter"}
choice -->|"Dédié"| dedicated["Déplacer sur un thread dédié"]
choice -->|"Sur le pool"| notify["Notifier avec CallbackMayRunLong"]
notify --> result{"Un autre worker a-t-il été obtenu ?"}
result -->|"TRUE"| run["Exécuter le traitement long"]
result -->|"FALSE"| split["Fractionner ou déplacer vers un dédié"]
Figure 8 : Annoncer un traitement long n’est un ensemble que lorsqu’on vérifie la valeur de retour et qu’on décide de l’action suivante.
5.2 Ne pas attendre de façon synchrone, depuis un worker, un travail du même pool
Un dessin dans lequel le callback A soumet le travail B au même pool et attend son achèvement avec WaitForThreadpoolWorkCallbacks ou analogue demande de la prudence. Une fois que chaque worker « attend le travail d’un autre worker », il ne reste plus de worker libre pour exécuter B, et vous obtenez un interblocage par famine du pool.
Le remède n’est pas de réserver un worker pour attendre, mais de passer à une forme de continuation dans laquelle le callback d’achèvement de B soumet le travail suivant. N’écrivez pas les dépendances comme des attentes qui occupent un worker.
flowchart TB
accTitle: Structure d'un interblocage par famine du pool
accDescr: Si tous les threads worker attendent de façon synchrone l'achèvement d'un autre travail soumis au même pool, aucun worker libre n'existe pour exécuter ce travail, et tout le monde attend indéfiniment dans un interblocage
w1["Worker 1 : en attente de l'achèvement du travail X"] --> q["Travaux X et Y en attente d'exécution"]
w2["Worker 2 : en attente de l'achèvement du travail Y"] --> q
q -.-> none["Aucun worker libre pour les exécuter"]
none -.-> dead["Tout le monde attend indéfiniment (interblocage par famine)"]
Figure 9 : Attendre de façon synchrone un worker depuis un worker, et plus personne n’exécute le travail attendu.
5.3 Rétablir l’état du thread avant de revenir
Un thread worker sert aussi au callback suivant, sans rapport. Laisser la priorité changée, laisser un état d’initialisation COM, laisser une valeur dans le TLS, ou oublier de libérer un verrou, se reporte sur le travail suivant. Il ne faut pas supposer que la fonction que vous soumettez au pool, ni le traitement qu’elle appelle, s’exécute sur un thread dédié.9
Il existe aussi des API qui lient le nettoyage à la fin du callback. Par exemple, LeaveCriticalSectionWhenCallbackReturns demande au pool de libérer la section critique après le retour du callback.4
flowchart TB
accTitle: Ne pas emporter l'état du thread vers le callback suivant
accDescr: Parce qu'un autre callback réutilise le même worker, revenir en laissant un état de priorité, COM, TLS ou verrou contamine le travail suivant ; nettoyer et rétablir l'état empêche ce report
first["Le callback A emprunte un worker"] --> cleanup{"Nettoyé avant de revenir ?"}
cleanup -->|"Non"| dirty["Réutilisé avec l'état laissé"]
dirty --> impact["Affecte B, sans rapport"]
cleanup -->|"Oui"| clean["Worker rendu dans son état d'origine"]
clean --> next["Le callback suivant B l'utilise"]
Figure 10 : Le thread est emprunté, donc vous êtes responsable non seulement du résultat du traitement, mais de l’état du thread au moment où vous le rendez.
5.4 Ne pas laisser d’exceptions non gérées s’échapper du worker
Une exception non gérée sur un thread worker peut emporter tout le processus. Comme pour la fonction de thread d’un thread dédié, appliquez la politique de capturer les exceptions à l’entrée du callback et de les consigner. Remettre l’exécution au pool ne supprime pas le besoin de traiter les échecs survenus dans le travail.
6. Séparer la configuration — pools personnalisés et groupes de nettoyage
6.1 Utiliser un pool personnalisé pour isoler les types de travail
Lorsque le pool par défaut ne suffit plus, vous pouvez créer un pool indépendant avec CreateThreadpool. SetThreadpoolThreadMaximum et SetThreadpoolThreadMinimum fixent les bornes supérieure et inférieure du nombre de threads worker.10
Le but typique est l’isolation. Pour que « un traitement par lots qui peut être lent » n’épuise pas les workers de « un travail qui doit répondre tout de suite », vous scindez les pools et donnez à chacun un budget de threads. Il ne s’agit pas simplement d’ajouter des threads ; il s’agit de séparer quel travail utilise quels workers.
6.2 Lier la cible d’exécution et le nettoyage par un environnement de callback
Ce qui indique sur quel pool s’exécuter, c’est TP_CALLBACK_ENVIRON. Vous initialisez cet environnement de callback, indiquez le pool avec SetThreadpoolCallbackPool, et le passez à CreateThreadpoolWork et analogues. Le troisième argument, qui était NULL dans l’exemple du chapitre 4, est l’endroit où va l’environnement.5
Par le même environnement, un groupe de nettoyage peut aussi être attaché. Le pool personnalisé est la cible d’exécution ; le groupe de nettoyage est l’unité de nettoyage. Voir ces deux rôles séparément rend la configuration plus facile à suivre.
flowchart TB
accTitle: Lier la configuration par un environnement de callback
accDescr: L'environnement de callback pointe vers un pool personnalisé et un groupe de nettoyage ; les objets work et timer créés avec cet environnement s'exécutent sur ce pool, et l'opération groupée du groupe de nettoyage rassemble l'attente d'achèvement et la libération
env["Environnement de callback (TP_CALLBACK_ENVIRON)"] --> cp["Pool personnalisé (contrôle le nombre de threads)"]
env --> cg["Groupe de nettoyage"]
env --> obj["Passé à la création de work / timer / wait / io"]
cg -.-> close["Attendre l'achèvement et libérer en bloc"]
Figure 11 : Un environnement de callback est le mécanisme qui injecte à la création de l’objet « sur quel pool il s’exécute et qui nettoie ».
6.3 Rassembler l’attente d’achèvement et la libération de plusieurs objets
Lorsque work, timer et d’autres objets se multiplient dans un module, l’arrêt devient une liste de « attendre chacun, fermer chacun ». Si vous créez un groupe avec CreateThreadpoolCleanupGroup et faites des objets créés via l’environnement de callback des membres, un seul CloseThreadpoolCleanupGroupMembers rassemble l’attente d’achèvement et la libération de tous les objets membres.46
Ici aussi, le but est de ne pas laisser un callback en cours. Choisissez entre arrêter les work individuellement, comme au chapitre 4, et arrêter au niveau du groupe, selon le nombre d’objets que vous gérez.
7. Utilisation depuis une DLL — ne pas décharger avant les callbacks
7.1 Attendre l’achèvement dans une fonction d’arrêt explicite, pas dans DllMain
Le plus dangereux dans une DLL, c’est que la DLL soit déchargée alors que son code de callback peut encore s’exécuter. Si du code déchargé s’exécute, vous obtenez une violation d’accès.
La règle de base est de cesser de soumettre, attendre l’achèvement des callbacks, et fermer les objets dans la fonction d’arrêt explicite de la DLL avant de décharger. Qu’il s’agisse des fonctions d’attente individuelles ou du groupe de nettoyage du chapitre 6, cette vérification d’achèvement ne doit pas être omise.6
N’effectuez pas cette attente à l’intérieur de DllMain. En raison de sa relation avec le verrou du chargeur, elle provoque un autre interblocage. Voir l’article sur DllMain et le verrou du chargeur pour les détails.
7.2 Apparier FreeLibraryWhenCallbackReturns à une référence prise avant de soumettre
Pour la situation « ce callback est le dernier travail, donc je veux relâcher la référence de la DLL après son retour », il y a FreeLibraryWhenCallbackReturns.4
Toutefois, cette API seule n’empêche pas un déchargement avant que le callback commence. Ce qu’elle fait, c’est relâcher une référence de module lorsque le callback en cours revient. Utilisez-la en paire : prenez une référence de module pour ce traitement avec GetModuleHandleEx avant de soumettre, et relâchez-la depuis le callback avec cette API.
flowchart TB
accTitle: Fermer la DLL dans une fonction d'arrêt versus rendre une référence depuis le callback
accDescr: La forme de base est une fonction d'arrêt autre que DllMain qui cesse les soumissions, attend l'achèvement et libère avant que la DLL soit déchargée ; dans un dessin où le dernier callback rend la référence, prendre la référence avec GetModuleHandleEx avant de soumettre et l'apparier à la libération après retour via FreeLibraryWhenCallbackReturns
shutdown["Fonction d'arrêt explicite"] --> stop["Cesser de soumettre, attendre, libérer"]
stop --> unload["Puis décharger la DLL"]
note["Ne pas attendre dans DllMain"] -.-> shutdown
acquire["Prendre une référence de module avant de soumettre"] --> submit["Soumettre le callback"]
submit --> callback["Planifier la libération dans le callback"]
api["FreeLibraryWhenCallbackReturns"] -.-> callback
callback --> returned["Relâcher une référence après le retour"]
Figure 12 : Ne confondez pas l’attente d’achèvement dans la fonction d’arrêt avec le relâchement de la référence prise pour le callback ; dans les deux cas, concevez d’abord la durée de vie de la DLL.
8. Résumé — commencer par work, et remplacer avec l’arrêt
Le pool de threads Win32 est le fondement qui fait passer la concurrence en code natif de « créer des threads » à « remettre des travaux sous forme de callbacks ». Vous pouvez regrouper les travaux de courte durée et les threads qui ne font qu’attendre, et laisser la gestion des threads à l’OS.
L’adoption peut être progressive. D’abord, avec work, faire de la création, de la soumission, de l’arrêt des soumissions, de l’attente d’achèvement et de la libération un ensemble. Ensuite, remplacer les threads qui ne font qu’attendre par wait, et les threads qui ne font que minuter par timer. Envisagez l’intégration io et la scission des pools lorsqu’elles deviennent nécessaires.
flowchart TB
accTitle: Migrer le code existant vers le pool de threads par étapes
accDescr: Revoir les threads autogérés existants, garder le travail qui convient à la bibliothèque standard ou à un thread dédié, déplacer le travail adapté au pool vers work en incluant l'arrêt des soumissions et l'attente d'achèvement, et remplacer progressivement les attentes par wait et les minuteurs par timer
inventory["Revoir les threads autogérés existants"] --> choose{"Adapté au pool ?"}
choose -->|"Non"| keep["Choisir bibliothèque standard ou dédié"]
choose -->|"Oui"| work["Passer à work, arrêt compris dans l'ensemble"]
work --> more["Attentes vers wait, minuteurs vers timer"]
more --> optional["Intégration E/S, scission de pools selon le besoin"]
Figure 13 : Réduire les threads ne suffit pas ; aligner l’arrêt à chaque étape est la base d’une migration progressive.
Ce que vous gardez jusqu’au bout, ce sont trois disciplines : ne pas bloquer longtemps, ne pas attendre de façon synchrone sur le même pool, ne pas contaminer l’état du thread. Dans une DLL, empêchez en plus la course avec le déchargement. Laissez au C++ standard ou à .NET ce qu’ils peuvent traiter, et utilisez cette API là où une intégration ou un contrôle propres à Windows sont nécessaires.
Articles connexes
- Les profondeurs de l’E/S Windows (partie 3) — Les ports d’achèvement d’E/S (IOCP) et le pool de threads .NET : le sous-sol d’async/await
- Bonnes pratiques de multithreading en pratique — édition C++
- Bonnes pratiques du multithreading en pratique — édition langage C
- Réveils parasites — pourquoi les variables de condition se réveillent « sans avoir été notifiées » et comment attendre correctement sous Windows
- DllMain et le verrou du chargeur — la vraie raison pour laquelle on vous dit de « ne rien faire dans l’initialisation d’une DLL »
- Pourquoi préférer l’attente sur événement à Sleep(1) sous Windows
Domaines de conseil associés
KomuraSoft LLC prend en charge la conception de migration depuis du code natif dont les threads ont proliféré vers le pool de threads, les revues de conception du traitement concurrent dans des applications et DLL C++, et l’investigation des causes de blocages et de plantages dus à la famine du pool ou aux callbacks. Vous pouvez nous consulter à partir d’un inventaire du code existant.
- Développement d’applications Windows
- Conseil technique et revue de conception
- Investigation de bugs et analyse des causes
- Contact
Références
-
Microsoft Learn, Thread Pools. Sur le fait qu’un pool de threads est une collection de threads worker qui exécutent efficacement des callbacks asynchrones pour le compte de l’application ; sur les types d’applications auxquels il convient (émettre en parallèle un grand nombre de petits éléments de travail, créer et détruire fréquemment des threads de courte durée, traiter des travaux indépendants en parallèle, attentes exclusives sur des objets noyau, etc.) ; et sur la refonte complète sous Vista (unification des types de threads worker, une seule file de minuteurs, threads persistants dédiés, groupes de nettoyage, plusieurs pools dans un processus, et la nouvelle API). ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, <future>. Sur le fait que l’exécution asynchrone par tâche via std::async et future est fournie comme bibliothèque standard, de sorte que la concurrence peut s’écrire sans gérer les threads directement. ↩
-
Microsoft Learn, Thread Pooling. Sur la structure de l’ancienne API de pool de threads (QueueUserWorkItem, files de minuteurs, attentes enregistrées, BindIoCompletionCallback) ; sur le fait qu’il n’y a aucun moyen d’annuler un travail une fois qu’il est mis en file ; et sur le fait que la nouvelle API de pool de threads introduite sous Vista est explicitement présentée comme plus simple et supérieure en fiabilité, performances et flexibilité. ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. Sur la liste de fonctions qui inclut les quatre fonctions de création d’objets CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait et CreateThreadpoolIo ; les groupes de nettoyage (CreateThreadpoolCleanupGroup) ; et le nettoyage lié à l’achèvement du callback (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns, etc.). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). Sur la création d’un objet work à partir d’une fonction de callback et d’un pointeur de contexte ; et sur le fait que le troisième argument, TP_CALLBACK_ENVIRON, peut spécifier l’environnement d’exécution du callback (le pool auquel il appartient, etc.), NULL signifiant qu’il s’exécute dans l’environnement par défaut. ↩ ↩2 ↩3
-
Microsoft Learn, Using the Thread Pool Functions. Sur la procédure de base consistant à créer avec CreateThreadpoolWork, soumettre avec SubmitThreadpoolWork, attendre l’achèvement avec WaitForThreadpoolWorkCallbacks et fermer avec CloseThreadpoolWork ; et sur un exemple de configuration qui combine un pool personnalisé avec un environnement de callback et un groupe de nettoyage. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). Sur le fait de pouvoir soumettre le même objet work plusieurs fois sans attendre qu’un callback précédent s’achève, de sorte que les callbacks s’exécutent en parallèle ; et sur le fait que le pool peut ajuster (limiter) le nombre de threads pour l’efficacité. ↩
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). Sur le fait de notifier au pool que le callback courant peut s’exécuter longtemps, afin que le pool s’en serve pour décider s’il faut obtenir des threads pour les autres callbacks ; et sur le fait d’envisager un thread dédié pour un callback de longue durée lorsque c’est possible. ↩ ↩2
-
Microsoft Learn, Thread Pooling. Sur le fait que les éléments de travail soumis à un pool de threads, et les fonctions qu’ils appellent, doivent être sûrs pour le pool de threads ; sur le fait de ne pas supposer que le thread d’exécution est un thread dédié et persistant ; et sur le fait d’éviter l’usage du TLS et des appels asynchrones qui exigent un thread persistant. ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). Sur le fait de pouvoir fixer une borne supérieure au nombre de threads worker pour un pool créé avec CreateThreadpool (la borne inférieure est SetThreadpoolThreadMinimum). ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
DllMain et le verrou du chargeur — la vraie raison pour laquelle on vous dit de « ne rien faire dans l'initialisation d'une DLL »
Pourquoi il ne faut pas appeler LoadLibrary ni synchroniser avec d'autres threads depuis DllMain. À partir des sources primaires, cet art...
Pourquoi les arguments se cassent — Les règles des arguments de ligne de commande Windows
Windows passe à CreateProcess une seule chaîne que le destinataire découpe. Traite les règles de CommandLineToArgvW, du CRT et de .NET, A...
Ce qui reste après la mort du parent — garder les processus enfants dans un Job Object
Pourquoi les aides du SDK survivent à une IU tuée et gardent la caméra ou le port COM. Concevoir la durée de vie des processus enfants av...
Tubes nommés en pratique — l'IPC standard de Windows, de la conception à la sécurité
Guide pratique des tubes nommés, mécanisme standard de communication inter-processus sous Windows. Cet article organise, à partir des sou...
Ce qu'est vraiment « Ne répond pas » — comment Windows juge qu'une application est bloquée, et comment concevoir des applications qui ne le sont pas
Le « Ne répond pas » de Windows est un mécanisme dans lequel l'OS juge qu'une fenêtre n'a pas récupéré de message pendant 5 secondes et l...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Qu'est-ce qu'un pool de threads a de mieux que de créer ses propres threads avec CreateThread ?
- L'efficacité lorsqu'il faut traiter un grand nombre de travaux de courte durée, et moins de code de gestion des threads. Créer et détruire un thread a un coût qu'on ne peut pas ignorer. Une application qui répète « CreateThread pour chaque travail et le détruire une fois fini », ou qui détient de nombreux threads qui ne font que dormir en attendant un événement, peut réduire son nombre de threads et ses commutations de contexte en passant à un pool. La documentation officielle cite aussi comme candidates au pool les applications qui émettent en parallèle un grand nombre de petits éléments de travail, celles qui créent de nombreux threads de courte durée, et celles qui ont des threads dédiés uniquement à l'attente d'objets noyau. À l'inverse, un travail pour lequel « le thread lui-même a besoin d'une personnalité » — changement de priorité, STA COM, traitement dédié de longue durée — doit rester sur un thread dédié comme auparavant.
- En quoi cela diffère-t-il des anciennes fonctions de pool de threads telles que QueueUserWorkItem ?
- Le pool de threads a été entièrement refondu sous Windows Vista. La famille actuelle threadpoolapiset (CreateThreadpoolWork et analogues) est la nouvelle API ; QueueUserWorkItem, RegisterWaitForSingleObject et analogues sont l'ancienne API (héritée). La nouvelle API unifie les types de threads worker, permet de créer plusieurs pools indépendants dans un processus, et fournit des mécanismes tels que la libération en bloc via un groupe de nettoyage et la libération d'un verrou ou le déchargement d'une DLL liés à l'achèvement du callback. La documentation officielle indique aussi que la nouvelle API est plus simple et supérieure en fiabilité, performances et flexibilité. L'ancienne API a aussi des contraintes structurelles, telles que « il n'y a aucun moyen d'annuler un travail une fois qu'il est mis en file ». Dans le code neuf, utilisez donc la nouvelle API.
- Y a-t-il des choses à ne pas faire à l'intérieur d'un callback ?
- Il y en a trois principales. Première : bloquer longtemps ou exécuter un traitement long avec les réglages par défaut. Le pool ajuste son nombre de threads en partant du principe que les callbacks se terminent rapidement ; pour un traitement long, déclarez-le avec CallbackMayRunLong ou utilisez un thread dédié. Deuxième : attendre de façon synchrone l'achèvement d'un autre travail soumis au même pool. Si chaque worker finit par « attendre un autre worker », vous obtenez un interblocage par famine du pool. Troisième : dépendre de la personnalité du thread. Les threads worker sont partagés entre callbacks ; revenir avec une priorité de thread ou un état d'initialisation COM modifié, ou laisser un état dans le TLS, contamine le callback suivant. Pour le nettoyage à la fin (libérer un verrou ou décharger une DLL), des mécanismes dédiés tels que LeaveCriticalSectionWhenCallbackReturns et FreeLibraryWhenCallbackReturns sont fournis.
- Y a-t-il des points à surveiller lorsqu'on utilise le pool de threads dans une DLL ?
- Le plus grand danger est « la DLL est déchargée alors qu'un callback peut encore s'exécuter ». Si le callback s'exécute après le déchargement, vous obtenez une violation d'accès. Dans son traitement d'arrêt, la DLL doit attendre de façon fiable l'achèvement des callbacks qu'elle a émis — avec une fonction d'attente telle que WaitForThreadpoolWorkCallbacks, ou CloseThreadpoolCleanupGroupMembers sur un groupe de nettoyage — avant de fermer les objets. Attendre cela à l'intérieur de DllMain peut toutefois interbloquer par interaction avec le verrou du chargeur ; la règle est de le faire dans une fonction d'arrêt explicite, pas dans DllMain. Pour le cas où le callback lui-même est « le dernier travail » et veut libérer la DLL, une API dédiée, FreeLibraryWhenCallbackReturns, est fournie.
- Maintenant que C++ a std::async et .NET a ThreadPool, y a-t-il encore des occasions d'utiliser cette API directement ?
- Oui. Le critère est « l'outil de cette couche suffit-il ». Si la granularité de concurrence dont vous avez besoin en C++ est couverte par std::async ou std::thread, la bibliothèque standard est le premier candidat, y compris du point de vue de la portabilité. En revanche, vouloir unifier minuteurs, attentes d'objets noyau et achèvements d'E/S asynchrones dans un seul mécanisme de callback ; vouloir scinder les pools et contrôler le nombre de threads par type de travail ; ne pas vouloir détenir ses propres threads à l'intérieur d'une DLL ou d'un composant COM — ces exigences sont le domaine du pool de threads Win32. La relation avec le ThreadPool de .NET et IOCP est couverte dans un article connexe, et tant que vous écrivez en natif, connaître ce mécanisme qui se situe à la couche en dessous n'est pas perdu.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.