L'API du pool de threads Win32 — de la concurrence sans créer de threads, via CreateThreadpoolWork
· Go Komura · Windows, Multithreading, C++, Développement Windows, Win32 API, Amélioration des performances
« Un CreateThread par client. » « Un pour le minuteur. » « Un pour attendre un événement. » — Dans le code natif Windows, les threads ont tendance à proliférer ainsi. Chaque thread consomme une pile et un objet noyau, et les créer et les détruire a aussi un coût. Le travail est fin, pourtant le thread est lourd — le pool de threads est ce que l’OS fournit pour absorber ce décalage.
La commodité du ThreadPool et de Task.Run de .NET est bien connue, mais en réalité le natif Win32 a aussi une API de pool de threads bien conçue, standard de l’OS. Entièrement refondue sous Windows Vista, cette API est le fondement de la concurrence native : elle peut traiter travaux, minuteurs, attentes et E/S asynchrones via un mécanisme de callback unifié. Destiné aux développeurs qui écrivent des applications, des services et des DLL Windows en C/C++, cet article explique la structure et l’usage de cette API, et les pièges dans lesquels il est facile de tomber, à partir des sources primaires.
1. La conclusion, d’abord
- Pour émettre un grand nombre de travaux de courte durée, et pour remplacer les threads qui ne font qu’attendre, un pool de threads bat votre propre
CreateThread. Vous laissez la gestion des threads à l’OS et pouvez réduire le nombre de threads et les bascules de contexte.1 - Ce que vous devez utiliser est la nouvelle API (la famille
CreateThreadpoolWork). La refonte Vista l’a rendue plus simple, plus fiable et plus performante que l’ancienne API (la familleQueueUserWorkItem), et vous pouvez aussi créer plusieurs pools indépendants dans un processus.12 - Il y a quatre types d’objets. work, auquel vous soumettez des travaux ; timer, qui se déclenche à une heure ou une période ; wait, qui se déclenche lorsqu’un objet noyau est signalé ; et io, qui se déclenche lorsqu’une E/S asynchrone s’achève. Tous empruntent le même mécanisme de callback.3
- L’arrêt, c’est « attendre, puis fermer ». Une discipline consistant à ne pas laisser derrière soi un callback en cours — attendre l’achèvement avec la famille
WaitForThreadpoolWorkCallbacks, ou le traiter en bloc via un groupe de nettoyage — est requise.4 - À l’intérieur d’un callback : ne pas bloquer longtemps (si vous le faites,
CallbackMayRunLong) ; ne pas attendre de façon synchrone l’achèvement sur le même pool ; ne pas salir l’état du thread. Ces trois-là sont des règles de fer.56 - Usage depuis une DLL : surveiller une course au déchargement. Attendre l’achèvement dans une fonction d’arrêt explicite, et connaître les API dédiées telles que
FreeLibraryWhenCallbackReturns.3
2. Pourquoi un pool, et quand un pool
L’idée d’un pool de threads est simple. Plutôt que de créer un thread par travail, vous jetez des travaux (callbacks) à un groupe de threads worker que l’OS gère. Les workers exécutent les travaux l’un après l’autre, et l’OS ajuste le nombre à la charge.
La documentation officielle liste des types concrets d’applications où un pool paie.1
- Applications qui émettent un grand nombre de petits éléments de travail en parallèle (recherche, E/S réseau, etc.)
- Applications qui créent et détruisent fréquemment des threads de courte durée
- Applications qui traitent des travaux indépendants en parallèle en arrière-plan
- Applications qui détiennent des threads dédiés à l’attente d’objets noyau ou d’événements
Le dernier point est facile à manquer. Si vous avez cinq threads qui n’existent que pour dormir afin de « s’exécuter lorsque l’événement est signalé », ceux-ci peuvent être remplacés par cinq objets wait sur le pool, et l’attente est agrégée sur les threads d’attente du pool.
À l’inverse, il y a aussi des travaux qui ne conviennent pas à un pool. Un travail qui a besoin d’un changement de priorité de thread, qui exige COM STA, qui continue à tourner pendant toute la durée de vie du processus — un travail qui a besoin d’une « personnalité » sur le thread se tient sur un thread dédié. Un thread worker est une ressource partagée ; on l’emprunte.
flowchart TB
accTitle: Choisir entre un thread dédié et un pool
accDescr: Vérifier d'abord si une personnalité de thread telle que la priorité ou STA est nécessaire, et s'il s'exécute longtemps ; seuls les travaux de courte durée, à fort volume ou de type attente qui ne correspondent à ni l'un ni l'autre vont sur le pool de threads
q1{"Personnalité (priorité, STA) ?"} -->|"Oui"| ded["Garder un thread dédié"]
q1 -->|"Non"| q2{"S'exécute-t-il longtemps ?"}
q2 -->|"Oui"| ded
q2 -->|"Non"| pool["Le mettre sur le pool"]
pool -.-> ex["Travail court, attentes, minuteurs, E/S"]
Figure 1 : le seul travail que vous pouvez mettre sur un pool est un travail qui « n’a pas besoin de personnalité et se termine vite ». Tout le reste reste sur un thread dédié comme auparavant.
Il y a aussi un morceau d’histoire à figer. L’API du pool de threads a deux générations. L’ancienne API qui continue depuis Windows 2000 (QueueUserWorkItem, RegisterWaitForSingleObject, etc.), et la nouvelle API entièrement refondue sous Vista (la famille CreateThreadpoolWork). La nouvelle API unifie les types de threads worker, fournit des threads persistants dédiés, plusieurs pools dans un processus, des groupes de nettoyage, et plus encore, et la documentation officielle affirme en toutes lettres qu’elle est « plus simple, plus fiable, plus performante et plus flexible ».1 L’ancienne API a aussi des contraintes structurelles telles que « vous ne pouvez pas annuler un travail une fois qu’il est mis en file ».2 À partir d’ici, cet article ne traite que la nouvelle API.
flowchart TB
accTitle: Correspondance entre l'ancienne API de pool et la nouvelle
accDescr: QueueUserWorkItem de l'ancienne API correspond à l'objet work de la nouvelle, les files de minuteurs à timer, les attentes enregistrées à wait, et BindIoCompletionCallback à io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Files de minuteurs"] --> n2["timer"]
o5["Attentes enregistrées"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
Figure 2 : la cible de migration depuis l’ancienne API est un-à-un. Un inventaire du code existant peut partir de cette correspondance.
3. Les quatre objets — work, timer, wait et io
Au centre de la nouvelle API se trouvent quatre types d’objets dont les conditions de déclenchement du callback diffèrent.3
| Objet | Fonction de création | Quand le callback se déclenche |
|---|---|---|
| work | CreateThreadpoolWork | Lorsqu’il est soumis avec SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer | Lorsque l’heure ou la période spécifiée arrive |
| wait | CreateThreadpoolWait | Lorsqu’un objet noyau devient signalé |
| io | CreateThreadpoolIo | Lorsque l’E/S asynchrone sur le handle associé s’achève |
flowchart TB
accTitle: Les quatre objets du pool et le mécanisme de callback
accDescr: work se déclenche sur une soumission explicite, timer sur le temps, wait sur un signal d'objet noyau, et io sur l'achèvement d'une E/S asynchrone ; tous s'exécutent comme callbacks sur le même groupe de threads worker
kind{"Quel objet ?"}
kind --> w["work(à la soumission)"]
kind --> more{"Timer, wait ou io ?"}
more --> t["timer(heure / période)"]
more --> rest{"Wait ou io ?"}
rest --> wt["wait(au signal)"]
rest --> io["io(achèvement E/S)"]
w --> pool["Les workers exécutent le callback"]
t --> pool
wt --> pool
io --> pool
Figure 3 : les conditions de déclenchement diffèrent, mais les quatre sont unifiés dans un mécanisme où « les workers du même pool exécutent le callback ».
Cette unification est une force pratique. Au lieu d’écrire le traitement périodique, la réponse aux événements et le traitement d’achèvement d’E/S chacun sur un thread dédié, vous pouvez les aligner sur un seul style de callback. Les minuteurs sont rassemblés dans une seule file de minuteurs pour tout le pool, et les attentes sont agrégées sur un petit nombre de threads d’attente — les threads qui « ne font que dormir » disparaissent du processus.1
flowchart TB
accTitle: Remplacer les threads d'attente par des objets wait
accDescr: Les threads qui ne font qu'attendre et qui dormaient un par événement deviennent des objets wait et sont agrégés sur les threads d'attente du pool, de sorte que le callback ne s'exécute que lorsqu'il est signalé
old2["5 threads d'attente dédiés"] -.-> waste["Consomme 5 piles et 5 threads"]
new2["5 objets wait"] --> agg["Agrégés sur les threads d'attente"]
agg --> cb2["Le callback seulement au signal"]
Figure 4 : les threads qui « ne font que dormir et attendre » peuvent être retirés en les transformant en objets wait. C’est un premier geste clair pour une migration vers le pool.
4. Le motif de base — un aller-retour avec un objet work
Nous parcourons les manières une fois avec l’objet work, qui est le plus fréquemment utilisé.4
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// The context is fixed at creation time. Per-item data is passed through a synchronised queue
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // Take one item under exclusive control
ProcessItem(item);
}
// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }
// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) Close
CloseThreadpoolWork(work);
Il y a deux points à retenir. Premier : vous pouvez SubmitThreadpoolWork le même objet work plus d’une fois. Chaque soumission exécute le callback (en parallèle).7 Toutefois, le contexte passé au callback est fixé à la création, donc lorsque vous écrivez « N éléments du même type de travail » avec un seul objet work, vous mettez une file synchronisée dans le contexte comme dans le code ci-dessus et prenez un élément par soumission (une conception qui crée un objet work par élément convient aussi). Second : toujours attendre l’achèvement avant de fermer. Fermer l’objet alors qu’un callback en cours ou en file reste, ou libérer la mémoire à laquelle le callback se réfère, c’est du use-after-free tel quel. Pour que cette attente soit une fermeture sûre, arrêter d’abord le soumetteur est un prérequis — dans une structure où un autre thread peut encore Submit en parallèle de l’attente, une soumission après l’attente entre en course avec Close. Passer TRUE comme second argument de WaitForThreadpoolWorkCallbacks tente aussi d’annuler les soumissions qui n’ont pas encore commencé.
flowchart TB
accTitle: Cycle de vie d'un objet work
accDescr: Créer avec CreateThreadpoolWork ; soumettre avec SubmitThreadpoolWork et les callbacks s'exécutent en parallèle. À l'arrêt, d'abord stopper les nouvelles soumissions, attendre que chaque callback s'achève avec WaitForThreadpoolWorkCallbacks, puis fermer avec CloseThreadpoolWork
c["Créer avec CreateThreadpoolWork"] --> s["Soumettre(peut se répéter)"]
s --> run["Callbacks en parallèle"]
run --> stop3["Stopper les nouvelles soumissions"]
stop3 --> w["Attendre avec WaitForThreadpoolWorkCallbacks"]
w --> cl["Fermer avec CloseThreadpoolWork"]
Figure 5 : l’ordre d’arrêt est « stopper les soumissions → attendre l’achèvement → fermer ». En sauter un et vous obtenez un use-after-free ou une course.
Par défaut, les callbacks s’exécutent sur le pool par défaut du processus. Pour de nombreux usages, cela suffit. Le chapitre suivant est pour lorsque vous voulez scinder les pools.
5. Pools personnalisés et groupes de nettoyage
Scinder le pool. Vous pouvez créer un pool indépendant avec CreateThreadpool et régler les bornes supérieure et inférieure du nombre de threads avec SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum.8 L’usage typique est l’isolation. Pour que « un travail de type lot qui peut être lent » ne mange pas les workers de « un travail qui doit répondre immédiatement », vous scindez les pools et donnez à chacun son propre budget de threads.
Les lier avec un environnement de callback. Sur quel pool le travail s’exécute se spécifie en initialisant un TP_CALLBACK_ENVIRON (environnement de callback), en le pointant vers le pool avec SetThreadpoolCallbackPool, et en le passant comme troisième argument de CreateThreadpoolWork et analogues.9
Les plier avec un groupe de nettoyage. Dans un module qui crée de nombreux objets, le traitement d’arrêt tend à devenir une récitation de « les attendre tous, les fermer tous ». Si vous créez un groupe avec CreateThreadpoolCleanupGroup et attachez chaque objet via l’environnement de callback, un seul CloseThreadpoolCleanupGroupMembers effectue l’attente d’achèvement et la libération de chaque objet membre ensemble.34
flowchart TB
accTitle: Lier la configuration via un environnement de callback
accDescr: L'environnement de callback pointe vers un pool personnalisé et un groupe de nettoyage ; les work et timer créés avec cet environnement s'exécutent sur ce pool, et une opération en bloc sur le groupe de nettoyage rassemble l'attente d'achèvement et la libération
env["Environnement(TP_CALLBACK_ENVIRON)"] --> cp["Pool perso(contrôle du nombre)"]
env --> cg["Groupe de nettoyage"]
env --> obj["Passé à la création work / timer / wait / io"]
cg -.-> close["Attendre et libérer d'un coup"]
Figure 6 : un environnement de callback est le mécanisme qui injecte « sur quel pool cela s’exécute, et qui nettoie » au moment de la création de l’objet.
6. Pièges — la discipline à l’intérieur d’un callback
Presque tous les bugs de pool de threads viennent de « faire ce que l’on veut sur un thread emprunté ».
Bloquer longtemps. Le pool ajuste son nombre de threads en partant du principe que les callbacks reviennent rapidement. Faire un travail long ou une longue attente avec les réglages par défaut retarde l’exécution des autres callbacks. Un callback qui peut tourner longtemps doit déclarer « celui-ci tournera longtemps » avec CallbackMayRunLong (le pool le prend comme un indice pour ajouter un thread), ou être envoyé dès le départ vers un thread dédié. Notez que CallbackMayRunLong renvoie FALSE lorsqu’il ne peut pas préparer un worker pour les autres callbacks. Si vous continuez à bloquer sans vérifier la valeur de retour, vous encombrez encore le pool, donc lorsqu’elle est FALSE, tombez du côté qui ne bloque pas — scinder le travail, l’envoyer vers un thread dédié, etc.5
Attendre de façon synchrone l’achèvement sur le même pool. Une forme où, à l’intérieur du callback A, vous attendez avec WaitForThreadpoolWorkCallbacks ou analogue l’achèvement du travail B soumis au même pool devient un interblocage par famine du pool dès que chaque worker « attend un autre worker ». Réécrivez une dépendance entre travaux non comme une attente mais comme une continuation qui « soumet le suivant depuis le callback d’achèvement de B ».
flowchart TB
accTitle: La structure d'un interblocage par famine du pool
accDescr: Si chaque thread worker attend de façon synchrone l'achèvement d'un autre travail soumis au même pool, il ne reste aucun worker libre pour exécuter ce travail, et tout le monde attend indéfiniment
w1["Worker 1 : attend le travail X"] --> q["Travaux X et Y en attente"]
w2["Worker 2 : attend le travail Y"] --> q
q -.-> none["Plus de worker libre"]
none -.-> dead["Tout le monde attend(famine)"]
Figure 7 : si vous attendez de façon synchrone un worker depuis l’intérieur d’un worker, il ne reste personne pour exécuter le travail attendu.
Salir l’état du thread. Un thread worker est réutilisé pour le callback suivant. Un changement de priorité de thread, un état d’initialisation COM, une valeur laissée dans le TLS, un verrou que vous avez oublié de quitter — tout cela devient une contamination du callback suivant (sans rapport). « Une fonction que vous jetez à un pool ne doit pas dépendre de la personnalité du thread » est une mise en garde officielle depuis l’ère de l’ancienne API.6 Il existe des mécanismes dédiés pour le nettoyage ; par exemple, LeaveCriticalSectionWhenCallbackReturns peut demander au pool de « libérer ce verrou lorsque ce callback revient ».3
Une course avec le déchargement de DLL. Si la DLL qui contient le code est déchargée alors qu’un callback s’exécute, vous obtenez une violation d’accès. La forme de base est d’attendre à fond l’achèvement dans la fonction d’arrêt de la DLL ; FreeLibraryWhenCallbackReturns est fourni pour la situation « ce callback est le dernier travail, et lorsqu’il se termine je veux que la DLL soit libérée y compris moi-même ». Cette API, toutefois, ne fait que « lâcher une référence lorsque le callback en cours revient » ; elle n’empêche pas un déchargement avant que le callback n’ait commencé. Vous l’utilisez en paire : prenez une référence de module de votre cru avec GetModuleHandleEx avant de soumettre, et faites lâcher cette référence au callback avec cette API.3 Et vous ne devez pas faire cette attente d’achèvement à l’intérieur de DllMain — comme indiqué dans « DllMain et le verrou du chargeur », attendre un autre thread à l’intérieur de DllMain est un motif d’interblocage.
flowchart TB
accTitle: Se préparer à une course entre déchargement de DLL et callback
accDescr: Un déchargement de la DLL pendant qu'un callback s'exécute devient une violation d'accès, donc la forme de base est d'attendre l'achèvement dans une fonction d'arrêt explicite puis de fermer ; lorsque le dernier callback lui-même libère la DLL, utiliser FreeLibraryWhenCallbackReturns
risk["Unload pendant callback"] -.-> av["Access violation"]
g1["Attendre et fermer"] --> safe["Unload sûr"]
g1 -.-> g1N["Dans shutdown"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g2 -.-> g2n["dernier callback"]
g1 -.-> ng["Pas dans DllMain"]
av ~~~ g1
Figure 8 : la forme de base est de faire « attendre, puis fermer » dans une fonction d’arrêt explicite. Attendre à l’intérieur de DllMain invite un autre interblocage.
Exceptions et plantages à l’intérieur du travail soumis. Une exception non gérée sur un thread worker emporte le processus. Appliquez une politique consistant à attraper les exceptions de façon exhaustive à l’entrée du callback et à les journaliser, de la même manière que pour la fonction de thread d’un thread dédié.
7. Comment cela se situe par rapport à la bibliothèque standard et à .NET — à quelle couche vous écrivez
Enfin, nous rangeons comment cela se situe par rapport aux autres outils.
- Si
std::async/std::threaden C++ suffisent, ils sont le premier candidat. Ils sont portables, le code est court, et même la sémantique defutureest tranchée par le standard.10 - Les raisons d’utiliser le pool de threads Win32 directement sont lorsque vous (1) voulez un mécanisme de callback unifié qui inclut timer, wait et io, (2) voulez une scission de pools ou un contrôle du nombre de threads, ou (3) ne voulez pas détenir vos propres threads à l’intérieur d’une DLL ou d’un composant COM.
- Côté .NET,
ThreadPooletTaskjouent le même rôle, et l’achèvement des E/S est lié à IOCP. Cette structure de sous-sol est expliquée dans « IOCP et le pool de threads .NET ».
flowchart TB
accTitle: Décider avec quels outils de quelle couche écrire
accDescr: Si async ou thread du C++ standard suffisent, les utiliser ; utiliser le pool de threads Win32 directement lorsque vous avez besoin de l'intégration des minuteurs, attentes et achèvements d'E/S, d'une scission de pools ou d'un contrôle du nombre de threads, ou que vous ne voulez pas vos propres threads à l'intérieur d'une DLL ou d'un composant COM
q1{"Les outils C++ standard suffisent ?"} -->|"Oui"| std["std::async / std::thread"]
q1 -->|"Non"| q2{"De quoi avez-vous besoin ?"}
q2 -->|"Intégration timer, wait et io"| tp["Pool de threads Win32"]
q2 -->|"Scission / contrôle du nombre"| tp
q2 -->|"Éviter ses threads dans une DLL"| tp
Figure 9 : en cas de doute, commencez par la bibliothèque standard ; le tour de cette API arrive lorsqu’une exigence qu’elle ne peut pas exprimer apparaît.
Autrement dit, cette API est le fondement de la concurrence au moment où vous avez décidé d’« écrire en natif ». Un nettoyage réaliste est étagé : comme cible de migration depuis une prolifération de vos propres CreateThread, commencez par introduire l’objet work, puis remplacez les threads qui ne font qu’attendre par wait et les threads de minuteur par timer.
8. Résumé
- Pour émettre un grand nombre de travaux de courte durée, et pour nettoyer les threads qui ne font qu’attendre ou minuter, le pool de threads standard de l’OS plutôt que vos propres threads. Ce que vous utilisez est la nouvelle API à partir de Vista.
- Au centre se trouvent les quatre objets work, timer, wait et io. Les conditions de déclenchement diffèrent ; ils sont unifiés sur le même groupe de workers et le même style de callback.
- Les manières sont « créer → soumettre → attendre l’achèvement → fermer ». Plusieurs soumissions s’exécutent en parallèle. Un groupe de nettoyage peut regrouper le traitement d’arrêt.
- Les trois règles de fer d’un callback : ne pas bloquer longtemps (si vous le faites,
CallbackMayRunLong) ; ne pas attendre de façon synchrone sur le même pool ; ne pas salir l’état du thread. - Usage depuis une DLL : surveiller une course au déchargement. Attendre l’achèvement dans une fonction d’arrêt explicite ; ne pas le faire dans
DllMain. - Là où le C++ standard ou .NET suffisent, utilisez-les. Le tour de cette API arrive lorsque vous avez besoin de l’intégration timer/wait/io ou du contrôle de pool.
L’API du pool de threads est, parmi les API Win32, du côté des plus récentes et des mieux conçues. Une fois que vous avez fini le passage de l’idée de « créer un thread » à l’idée de « jeter un callback », la concurrence en code natif devient considérablement plus claire à écrire.
Articles connexes
- Les profondeurs de l’I/O 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++ : éliminer les accidents structurellement avec RAII et jthread
- Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l’API Win32
- Spurious Wakeups — Why Condition Variables Wake “Without Being Notified” and How to Wait Correctly on Windows
- DllMain and the Loader Lock — The Real Reason You’re Told to “Do Nothing in DLL Initialization”
- 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 de cause racine des blocages et plantages causés par la famine du pool ou les callbacks. Vous êtes les bienvenus pour 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 d’une application ; sur les types d’applications auxquels il convient (émettre un grand nombre de petits éléments de travail en parallèle, 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, Thread Pooling. Sur la structure des API plus anciennes 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 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 ↩5 ↩6
-
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
-
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 puisse s’en servir pour décider s’il faut sécuriser un thread 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. ↩ ↩2
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). Sur le fait de pouvoir soumettre le même objet work plus d’une 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, 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). ↩
-
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. ↩
-
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 vous pouvez écrire de la concurrence sans gérer les threads directement. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
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...
Bonnes pratiques du multithreading en pratique — édition langage C — écrire en toute sécurité à la manière de l'API Win32
Le multithreading en C avec Win32 repose sur des règles éprouvées : création de threads avec _beginthreadex, verrous SRW et variables de ...
Bonnes pratiques de multithreading en pratique — édition C++ : éliminer les accidents structurellement avec RAII et jthread
En C++, le multithreading est un monde où une course de données devient un comportement indéfini. Cet article couvre le piège du destruct...
Que représente réellement la « mémoire utilisée » sous Windows ── Bien lire Working Set, Private Bytes, Commit et le fichier d'échange
La « mémoire » du Gestionnaire des tâches, Working Set, Private Bytes et Commit ne représentent pas la même valeur. Cet article explique ...
Bonnes pratiques de multithreading en pratique — édition .NET : ce qu'il faut décider avant d'ajouter des threads
Un ensemble de bonnes pratiques de conception pour .NET/C# afin d'éviter que « démarrer un thread » ne fasse planter ou geler l'applicati...
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é lorsque vous avez un grand nombre de travaux de courte durée à faire, et une réduction du code de gestion des threads. Créer et détruire un thread a un coût qu'on ne peut pas ignorer, donc 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 n'existent que pour dormir en attendant un événement, peut réduire son nombre de threads et ses bascules de contexte en passant à un pool. La documentation officielle liste aussi comme candidates au pool les applications qui émettent un grand nombre de petits éléments de travail en parallèle, les applications qui créent de nombreux threads de courte durée, et les applications qui ont des threads dédiés uniquement à l'attente d'objets noyau. À l'inverse, un travail qui « a besoin d'une personnalité propre sur le thread » — un changement de priorité, COM STA, un traitement dédié de longue durée — doit toujours être détenu 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. Les API actuelles de la famille threadpoolapiset (CreateThreadpoolWork et analogues) sont 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 de verrou ou le déchargement de DLL liés à l'achèvement du callback. La documentation officielle dit 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 », donc utilisez la nouvelle API dans le code neuf.
- Y a-t-il des choses à ne pas faire à l'intérieur d'un callback ?
- Il y en a trois grandes. Première : bloquer longtemps ou faire un travail 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, donc pour un travail qui prendra longtemps vous le déclarez avec CallbackMayRunLong ou vous utilisez un thread dédié. Deuxième : attendre de façon synchrone l'achèvement d'un autre travail que vous avez 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, donc 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 depuis une DLL ?
- Le plus grand danger est « la DLL est déchargée alors qu'un callback tourne encore ». Si le callback s'exécute après le déchargement, vous obtenez une violation d'accès. Le côté DLL doit, dans son traitement d'arrêt, attendre de façon fiable l'achèvement des callbacks qu'il a émis — avec une fonction d'attente telle que WaitForThreadpoolWorkCallbacks, ou CloseThreadpoolCleanupGroupMembers sur un groupe de nettoyage — et seulement ensuite fermer les objets. Attendre cela à l'intérieur de DllMain, toutefois, peut interbloquer par interaction avec le verrou du chargeur, donc la règle est de le faire dans une fonction d'arrêt explicite, pas dans DllMain. Pour la situation où le callback lui-même veut libérer la DLL parce que « ce travail est le dernier », une API dédiée, FreeLibraryWhenCallbackReturns, est fournie.
- Maintenant que nous avons std::async en C++ et le ThreadPool de .NET, y a-t-il encore des occasions d'utiliser cette API directement ?
- Il y en a. 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 ce que le pool de threads Win32 couvre. 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.