Historique des révisions (première version, publiée le 29 Jul 2026)
- Première publication
Citer cet article(DOI (archive enregistrée): 10.5281/zenodo.22175294)
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). 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. KomuraSoft LLC. https://comcomponent.com/fr/blog/windows-iocp-dotnet-threadpool/
- DOI (archive enregistrée)
- 10.5281/zenodo.22175294
- DOI (dernière version enregistrée)
- 10.5281/zenodo.22175295
Comment rassembler les achèvements d’E/S pour qu’un petit nombre de threads gère un grand nombre de connexions ? Pendant qu’on attend sur un await, qui travaille, et sur quel thread le code après l’achèvement reprend-il ?
Cette partie suit le chemin, depuis la façon dont un port d’achèvement d’E/S (IOCP) rassemble les notifications d’achèvement, jusqu’à la façon dont .NET reçoit ces notifications et exécute la suite d’un await. Une fois séparés « recevoir l’achèvement » et « décider où la suite s’exécute », ConfigureAwait(false) et la famine du pool de threads se comprennent dans le même flux.
La fois précédente (partie 2), nous avons vu l’émission des E/S asynchrones et les quatre voies pour en recevoir l’achèvement. Parmi elles, le mécanisme que nous prenons ici pour recevoir un grand nombre d’E/S simultanées avec peu de threads est l’IOCP.
Ceci est la partie 3 de la série « Les profondeurs de l’E/S Windows ». La structure d’ensemble se trouve au début de la partie 1.
Lire à partir de ce que l’on veut savoir
Si vous lisez dans l’ordre, le chapitre 2 établit les limites d’une conception qui prépare un thread par connexion, les chapitres 3 et 4 couvrent Win32, et le chapitre 5 la correspondance avec .NET. En pleine investigation, le guide suivant mène à l’explication dont vous avez besoin.
| Ce que vous voulez savoir ou ce qui vous bloque | Où lire |
|---|---|
| Pourquoi des milliers de connexions simultanées n’exigent pas autant de threads | Chapitre 2 : Nombre de connexions et de threads, Chapitre 3 : File d’achèvement et contrôle des threads |
| Identifier quelle E/S, sur quel handle, s’est achevée | Section 3.1 : CompletionKey et OVERLAPPED |
| L’obtention d’achèvement a renvoyé FALSE ; peut-on faire le nettoyage | Section 3.2 : Distinguer une E/S en échec d’un échec d’obtention |
| Le rapport entre FIFO, LIFO et la valeur de concurrence | Sections 3.3 et 3.4 : Choix du thread en attente et nombre exécutable |
| Comment traiter notification de fin, retrait groupé et achèvement synchrone | Chapitre 4 : API par usage |
| Suivre un await ReadAsync de l’émission à la reprise | Section 5.1 : Un aller-retour d’await |
| La portée de « l’attente d’E/S ne consomme pas de thread » | Section 5.2 : Le thread qui attend et le thread qui traite |
| L’UI se fige, ou on ne peut plus la toucher, même avec ConfigureAwait(false) | Section 5.3 : Séparer la destination de la continuation du travail CPU |
| Le traitement asynchrone dans son ensemble ralentit quand la charge augmente | Section 5.4 : Attentes synchrones et famine du pool de threads |
1. D’abord la conclusion
Ce dont l’IOCP s’occupe
- L’IOCP est un mécanisme qui unifie « file de notifications d’achèvement » et « contrôle du nombre de threads ». Les paquets d’achèvement sont empilés en FIFO dans la file, et les threads de travail les retirent avec
GetQueuedCompletionStatus(chapitre 3).1 - Les threads sont réveillés en LIFO. Le thread « encore chaud » qui travaillait à l’instant ramasse aussi le paquet suivant, donc tant que la file n’est pas vide, les changements de contexte n’ont presque pas lieu (section 3.3).1
- La valeur de concurrence est le plafond du « nombre de threads exécutables ». Le point de départ recommandé est le nombre de CPU (0 pour le nombre de processeurs). Si un thread en cours d’exécution se bloque, un thread en attente est réveillé pour combler le trou (section 3.4).12
Points clés pour choisir l’API
- Le port sert aussi à des notifications maison.
PostQueuedCompletionStatuspermet d’empiler des paquets sans rapport avec une E/S, donc les demandes de travail aux workers et les instructions de fin passent par la même file (chapitre 4).3 - Pour une nouvelle implémentation serveur, l’API du pool de threads Windows (
CreateThreadpoolIo) est recommandée plutôt que l’IOCP brut. L’intérieur reste de l’IOCP, la gestion des threads est prise en charge (chapitre 4).1
Ce qu’il faut distinguer avec .NET et await
- Le pool de threads .NET est une structure à deux étages, threads de travail et threads d’achèvement d’E/S, et les handles d’E/S asynchrones sont liés au pool (à son IOCP). Pendant l’attente d’E/S d’un
await, aucun thread n’existe ; seule la continuation après achèvement monte sur un thread (chapitre 5).456 - La destination de la continuation est décidée par le « contexte capturé ». Un
awaitsur le thread UI y revient ; s’il n’y a rien à capturer, la suite continue sur le pool de threads (ou sur le thread qui a achevé).ConfigureAwait(false)est l’instruction d’arrêter la capture, pas une garantie d’aller sur le pool de threads (section 5.3).6
Dans le diagramme, un trait continu marque une relation qui vaut toujours et un trait pointillé une relation conditionnelle (les conditions figurent dans l’explication de chaque relation sur la page de détail). La liste complète des relations (32 au total, avec preuve et niveau de certitude) et les définitions des concepts principaux sont rassemblées sur la page de détail de la carte des connaissances (en japonais). Données : JSON-LD / Turtle
2. Où la conception « un thread par connexion » se casse
D’abord, le problème que l’IOCP a cherché à résoudre. Un serveur naïf s’écrit « un thread par connexion ». Lire en E/S synchrone, traiter, écrire : une conception lisible.
Le problème apparaît quand les connexions se multiplient. Comparez, sur la figure 1, la conception qui augmente aussi le nombre de threads avec le nombre de connexions en attente, et celle qui ne traite, avec un petit nombre de threads, que le travail achevé.
flowchart TB
subgraph A["Un thread par connexion (E/S synchrone)"]
T1["Thread 1 : en attente du read de la connexion 1"]
T2["Thread 2 : en attente du read de la connexion 2"]
T3["Thread 3 : en attente du read de la connexion 3"]
TN["… autant de threads que de connexions<br/>la plupart ne font que dormir en attente d'E/S"]
end
subgraph B["Modèle IOCP (E/S asynchrone)"]
Q["File d'achèvement<br/>(les notifications d'achèvement de toutes les connexions s'y rassemblent)"]
W1["Worker 1"]
W2["Worker 2"]
WN["Les workers sont peu nombreux, de l'ordre du nombre de CPU"]
Q --> W1
Q --> W2
Q --> WN
end
Figure 1 : À gauche, les threads croissent avec le nombre de connexions. À droite, on ne traite, avec un petit nombre de threads, que « ce qui s’est passé ».
Même un thread qui ne fait qu’attendre consomme des ressources
Chacun consomme une pile (1 Mo réservé par défaut) et un objet noyau, et plus ils sont nombreux, plus la charge du scheduler et des changements de contexte s’accumule. Des milliers de connexions = des milliers de threads, même si la plupart « ne font que dormir en attendant de pouvoir lire », ça coûte cher.
Augmenter les threads exécutables n’augmente pas les CPU
Les threads qui peuvent courir en même temps s’arrêtent physiquement au nombre de CPU. Rendre exécutables davantage de threads n’ajoute que des pertes de commutation.
Jusqu’à la partie 2, nous avons vu comment recevoir une notification d’achèvement d’E/S par « événement » ou « APC ». Mais la voie événement se complique avec la limite de 64 de WaitForMultipleObjects et la conception de l’attente, et l’APC est lié au thread émetteur. L’IOCP est conçu dès le départ pour la forme « beaucoup d’E/S × peu de threads ».1
3. Conception de l’IOCP — unification de la file et du contrôle des threads
3.1. Les deux visages de CreateIoCompletionPort
Malgré son nom, CreateIoCompletionPort fait deux travaux. La création d’un nouveau port, et l’association d’un handle à un port existant.2
flowchart LR
subgraph SRC["Handles associés (autant qu'on veut)"]
H1["Fichier"]
H2["Socket"]
H3["Tube nommé"]
end
subgraph PORT["Port d'achèvement d'E/S"]
Q["File de paquets d'achèvement (FIFO)<br/>paquet = octets transférés +<br/>CompletionKey + pointeur OVERLAPPED"]
C["Contrôle de concurrence<br/>nombre de threads exécutables ≦ plafond"]
end
subgraph W["Groupe de threads de travail"]
G1["Attend avec GetQueuedCompletionStatus"]
G2["Attend avec GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.contrôle.-> W
Figure 2 : Composition d’un IOCP. Les achèvements d’un grand nombre de handles se rassemblent dans une seule file, et le nombre de threads du côté qui les retire est contrôlé aussi.
Le CompletionKey passé à l’association est une valeur libre pour dire au worker « c’est un achèvement de ce handle ». Y mettre le pointeur de l’objet connexion est la pratique courante.2
Dans un paquet d’achèvement, on identifie l’opération en combinant les trois informations suivantes.27
| Information reçue | Ce qu’elle identifie ou confirme |
|---|---|
| CompletionKey | De quel handle / quelle connexion vient l’achèvement |
| Pointeur OVERLAPPED | Quelle opération de cette connexion s’est achevée |
| Octets transférés | Quelle quantité de données a été transférée |
C’est la correspondance pour récupérer, à l’achèvement, le « bordereau d’opération » vu dans la partie 2.
La cible n’est pas limitée aux « fichiers ». Sockets, tubes nommés, mailslots : n’importe quel handle capable d’overlapped I/O peut être associé.1 La conception « tout a l’air d’un fichier » de la partie 1 joue aussi ici.
3.2. Le voyage du paquet d’achèvement
sequenceDiagram
participant DRV as Noyau (achèvement d'IRP)
participant Q as File du port (FIFO)
participant W as Thread de travail
Note over W: En attente sur GetQueuedCompletionStatus
DRV->>Q: Empile un paquet d'achèvement<br/>(octets / CompletionKey / OVERLAPPED)
Q->>W: Réveille un thread en attente et le lui remet
Note over W: Traite l'achèvement d'après le paquet<br/>(exécution de la continuation, émission de l'E/S suivante, etc.)
W->>Q: Traitement fini, GetQueuedCompletionStatus à nouveau
Note over Q: S'il reste des paquets dans la file,<br/>le suivant est reçu sans attendre
Figure 3 : Les paquets d’achèvement s’empilent en FIFO, et le worker tourne en boucle : retirer, traiter.
Quand une E/S asynchrone s’achève, un paquet d’achèvement est empilé dans la file du port en ordre FIFO. Le worker répète la boucle suivante.17
- Recevoir un paquet avec
GetQueuedCompletionStatus. - Traiter l’achèvement de l’opération reçue.
- Une fois le traitement fini, appeler à nouveau
GetQueuedCompletionStatus.
Il existe aussi GetQueuedCompletionStatusEx, qui retire plusieurs paquets d’un coup, et réduit le nombre d’appels en E/S à haute fréquence.8
Ne pas quitter la boucle au seul vu de FALSE
Même si GetQueuedCompletionStatus renvoie FALSE, il se peut que l’on ait bien retiré le paquet d’achèvement d’une E/S en échec. Combinez le jugement avec le pointeur OVERLAPPED.7
| Valeur de retour | OVERLAPPED | Sens et traitement |
|---|---|---|
FALSE |
Non NULL | On a obtenu l’achèvement d’une E/S en échec. Traitement d’erreur et nettoyage du bordereau et du tampon nécessaires |
FALSE |
NULL | On n’a pas pu obtenir de paquet. Délai d’attente dépassé, fermeture du port, etc. |
Une opération en échec a aussi besoin de nettoyage. Traiter seulement par if (!GetQueuedCompletionStatus(...)) break; laisse passer les E/S en échec et les fuit. La gestion de durée de vie des bordereaux et tampons vue dans la partie 2 n’est pas l’affaire des seuls achèvements normaux.
Voir l’ordre de jugement dans la boucle worker
Le squelette suivant juge d’abord les deux cas du tableau ci-dessus, puis traite le paquet de fin et l’achèvement normal.
/* Squelette de boucle worker IOCP (C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* On n'a pas pu retirer de paquet (port fermé, etc.). Seule condition où l'on peut sortir */
break;
}
if (!ok) {
/* ov != NULL → on a retiré le « paquet d'achèvement d'une E/S en échec ».
Le nettoyage (traitement d'erreur, libération du bordereau et du tampon) est nécessaire, donc on traite sans sortir */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* Paquet de fin empilé avec PostQueuedCompletionStatus (chapitre 4) */
break;
}
handle_completed_io(key, ov, bytes); /* Traitement d'achèvement normal. Le garder court (section 3.4) */
}
Le point est de ne pas ranger ok == FALSE dans une seule branche, mais de le couper en deux selon que ov est NULL. Avec un délai (INFINITE autre que), le jugement est le même : un délai écoulé apparaît comme ok == FALSE et ov == NULL.
Par ailleurs, le premier appel d’un thread à GetQueuedCompletionStatus associe ce thread à ce port (un thread n’est associé qu’à un port à la fois).1 L’image « une équipe de workers attitrée s’attache au port » est la plus exacte.
3.3. Les threads sont réveillés en LIFO
Ici, séparez l’ordre d’entrée des paquets dans la file et l’ordre de réveil des threads en attente.1
| Cible | Ordre | Ce qu’il faut regarder |
|---|---|---|
| Paquets d’achèvement | FIFO | L’ordre d’empilement des notifications d’achèvement dans la file |
| Threads en attente | LIFO | On réveille d’abord le thread entré en attente en dernier |
Parce que l’ordre diffère entre paquets et threads, le thread qui travaillait à l’instant ramasse aussi le paquet suivant.
flowchart TB
Q["File : P1 → P2 → P3 (FIFO)"]
subgraph TH["Threads en attente (pile LIFO)"]
A["Thread A (exécuté à l'instant, encore chaud)"]
B["Thread B (dort depuis un moment)"]
C["Thread C (dort depuis longtemps)"]
end
Q -->|"P1, P2 et P3, s'il est libre,<br/>vont d'abord au thread A"| A
B -.->|"seulement quand A est occupé"| Q
C -.->|"se réveille rarement"| Q
Figure 4 : Libération LIFO. Plus on est occupé, plus le même thread continue de tourner ; les threads oisifs peuvent rester endormis.
Cette conception a deux bénéfices.
- Pas de changement de contexte. Tant qu’il reste des paquets dans la file, le thread qui vient de finir un traitement appelle
GetQueuedCompletionStatuset reçoit le paquet suivant sans attendre, et continue de courir. La documentation indique explicitement, dans un scénario de valeur de concurrence 1, qu’« aucun changement de thread ne se produit ».1 - Le cache reste chaud. Le même thread continue de tourner, donc pile et état d’ordonnancement ont plus de chances de rester dans le cache CPU. Les threads endormis se maintiennent à bas coût comme réserve pour un pic de charge.
3.4. Valeur de concurrence — compter le « exécutable »
Le NumberOfConcurrentThreads à la création du port est la valeur de concurrence. On compte les threads exécutables (runnable) associés à ce port. Tant que le plafond est atteint, aucun thread supplémentaire ne peut recevoir de paquet.1
Passer 0 utilise le nombre de processeurs du système. C’est aussi ce que la documentation cite comme meilleur maximum d’ensemble, le nombre de CPU, donc on part de là.21
Ce n’est pas un plafond sur le total, threads en attente compris. Voyez sur la figure 5 le cas où quelqu’un entre en attente.
flowchart TB
P["Un paquet arrive dans la file"]
Q{"Le nombre de threads exécutables est-il<br/>inférieur à la valeur de concurrence"}
RUN["Réveiller un thread en attente pour le faire traiter"]
HOLD["N'en réveiller aucun, laisser le paquet dans la file<br/>(un thread en cours d'exécution viendra le chercher)"]
BLK["Un thread en cours d'exécution<br/>est entré en attente pour une autre raison"]
COMP["Réveiller un thread en attente pour combler<br/>exactement la baisse du nombre exécutable"]
P --> Q
Q -->|inférieur| RUN
Q -->|plafond| HOLD
BLK --> COMP
Figure 5 : Contrôle de concurrence. Le plafond est le « nombre exécutable », donc si quelqu’un se bloque, le système complète automatiquement.
Un worker entré en attente est compensé par un autre thread en attente
Si un worker en cours d’exécution entre dans une attente (verrou, défaut de page, E/S synchrone écrite par inadvertance), le nombre exécutable diminue, donc le système réveille un thread en attente pour combler le trou.1 C’est pourquoi on ne crée pas « exactement le nombre de CPU » de workers, mais on laisse attendre un peu plus de threads que la valeur de concurrence. Si le traitement mélange de longs calculs, on peut aussi relever la valeur de concurrence elle-même ; la position de la documentation est d’ajuster au final par le profilage.1
Il y a aussi un dépassement temporaire du plafond, donc garder le traitement d’achèvement court
Le complément n’est toutefois pas universel. Si le thread bloqué se réveille plus tard, le nombre exécutable dépasse le plafond pendant cet instant (la documentation mentionne aussi ce dépassement).1 Garder le traitement d’achèvement court est le grand principe, et il joue sous la même forme en .NET à la section 5.4.
4. Boîte à outils — les API qui portent le port
4.1. Faire circuler demandes de travail et instructions de fin dans la même file
PostQueuedCompletionStatus — on peut empiler un paquet d’achèvement maison, sans émettre d’E/S.3 Demande de travail à un worker, instruction d’arrêt (empiler autant de paquets de fin — vulgairement appelés paquets « poison » — que de workers), notification depuis un autre thread : pouvoir traiter dans la même file, dans la même boucle, les achèvements d’E/S et ses propres messages simplifie beaucoup la conception.
4.2. Recevoir groupés les achèvements d’E/S à haute fréquence
GetQueuedCompletionStatusEx — retire plusieurs paquets d’achèvement d’un coup. Utile quand le surcoût d’un appel par paquet se fait sentir, en E/S à haute fréquence.8
4.3. Omettre la notification en cas d’achèvement synchrone
SetFileCompletionNotificationModes — pour le cas « émis en asynchrone, achevé de façon synchrone » vu au chapitre 5 de la partie 2, on peut choisir un mode qui n’empile pas de paquet dans le port (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). Le résultat d’un achèvement synchrone est déjà connu sur place, donc retraverser la file n’est que du gaspillage — c’est cette accélération.9
4.4. Confier la création et la gestion des threads
API du pool de threads Windows — CreateThreadpoolIo / StartThreadpoolIo utilisent l’IOCP en interne, tout en prenant en charge création et gestion des threads. Microsoft recommande, pour une nouvelle application serveur, d’envisager d’abord celle-ci, et de n’utiliser l’IOCP brut que lorsqu’on veut contrôler explicitement la valeur de concurrence et la gestion des threads.1 Et le pool de threads .NET n’est précisément que l’implémentation, comme runtime .NET, de cette « IOCP + automatisation de la gestion des threads ».
4.5. Trois gestions de durée de vie qui ne changent pas, quelle que soit l’API
- Ne pas bloquer longtemps à l’intérieur d’un worker. Le complément de la section 3.4 ne fait qu’atténuer la dégradation.
- Identifier séparément l’unité handle et l’unité opération. CompletionKey est par handle,
OVERLAPPEDpar opération. Le « bordereau » de la partie 2 ne doit pas être libéré avant l’achèvement. - Ne pas fermer un handle tant qu’il reste des E/S non achevées. Le comportement de cleanup (chapitre 6 de la partie 1) et la façon d’annuler (chapitre 6 de la partie 2) s’appliquent tels quels.
5. Le pool de threads .NET — une structure à deux étages bâtie sur l’IOCP
À partir d’ici, on fait correspondre les notifications d’achèvement Win32 et le code .NET. L’ordre de lecture est thread qui reçoit l’achèvement → un aller-retour d’await → temps d’attente → lieu d’exécution de la continuation.
Le pool de threads .NET fournit des threads aux deux rôles suivants.4
| Type | Rôle vu dans cet article |
|---|---|
| Thread de travail | Exécute Task.Run et les continuations |
| Thread d’achèvement d’E/S | Reçoit l’achèvement des E/S asynchrones |
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) aussi renvoie ces deux nombres séparément. Ci-dessous, on suit en distinguant le « côté qui reçoit l’achèvement » et le « côté qui exécute le travail ».4
Sous Windows, le pool de threads possède son propre port d’achèvement d’E/S. L’API de bas niveau qui associe un handle OS à ce port est ThreadPoolBoundHandle.BindHandle. Les E/S asynchrones vers un handle lié se traitent combinées à NativeOverlapped. C’est le mécanisme côté .NET correspondant à OVERLAPPED de la partie 2.5
L’ancien ThreadPool.BindHandle reste pour le même rôle, mais pour du code nouveau c’est ThreadPoolBoundHandle.BindHandle. Quand FileStream ou Socket ouvre un handle en mode asynchrone, ce genre de liaison se fait en interne.5
La correspondance avec Win32, séparée en émission et achèvement, se présente ainsi.
- Le « handle en mode asynchrone + OVERLAPPED » de la partie 2 est le mécanisme d’émission
- L’IOCP de cet article est le mécanisme pour recevoir l’achèvement
- Les threads d’achèvement d’E/S du pool de threads .NET sont l’équipe de workers qui tourne la boucle
GetQueuedCompletionStatus
Avec cette correspondance, le dessin Win32 devient tel quel le dessin .NET.
5.1. Un aller-retour d’await ReadAsync, version complète
Le contenu de la boîte « vraie E/S asynchrone » de la figure 7 de la partie 2, on le suit cette fois jusqu’à la continuation après achèvement. Sur la figure 6, séparez le thread à l’émission, le thread qui reçoit la notification d’achèvement, et le lieu où s’exécute le code de la suite.
sequenceDiagram
participant U as Thread appelant<br/>(thread UI, etc.)
participant K as Noyau<br/>(émission d'IRP jusqu'à l'achèvement)
participant Q as IOCP du pool de threads
participant IO as Thread d'achèvement d'E/S
participant C as Lieu d'exécution de la continuation
U->>K: ReadAsync émet une lecture asynchrone<br/>(en y joignant l'équivalent d'OVERLAPPED)
K-->>U: ERROR_IO_PENDING (retour immédiat)
Note over U: await enregistre une continuation sur le Task non achevé<br/>et lâche le thread (UI : vers le traitement du message suivant)
Note over K: Le périphérique est au travail<br/>pendant ce temps, aucun thread n'attend nulle part
K->>Q: Empile un paquet d'achèvement
Q->>IO: Réveille un thread en LIFO et le lui remet
Note over IO: Fixe le résultat (octets, état),<br/>achève le Task, planifie la continuation
IO->>C: Envoie vers le contexte capturé<br/>(vers le thread UI / sinon exécution sur le pool de threads)
Note over C: Le code de la suite de l'await s'exécute
Figure 6 : Aller-retour complet d’un await. Un thread ne travaille qu’à l’« émission » et « après l’achèvement » ; le temps d’attente est à zéro thread.
5.2. Le sens exact de « l’attente d’E/S ne consomme pas de thread »
Ne pas placer de thread dédié pour le seul temps d’attente
Ce que cette figure veut souligner, c’est que entre l’émission et l’achèvement, il n’existe, ni en mode utilisateur ni dans le noyau, de thread dont le seul rôle serait d’attendre cet achèvement. L’explication async de Microsoft (async in depth) descend aussi, pour un Task lié aux E/S, jusqu’au pilote de périphérique et à l’interruption.6
En revanche, il arrive qu’à l’intérieur du noyau un pilote confie une partie du traitement à un thread de travail du système. C’est du travail pour faire avancer la requête, distinct d’un thread qui resterait bloqué à attendre. « Ne consomme pas de thread » est une explication de ce temps d’attente.
Reformulé avec l’empilement depuis la partie 1 : l’IRP séjourne dans la pile de périphériques comme structure de données, pas comme thread (partie 1) ; l’émission revient tout de suite avec ERROR_IO_PENDING (partie 2) ; l’achèvement arrive par une chaîne d’événements interruption → paquet d’achèvement (cet article). L’état « attendre » n’a pas besoin, pour se maintenir, de la ressource coûteuse qu’est un thread.
Distinguer du traitement CPU, et de l’asynchrone de façade
C’est pourquoi une application qui utilise correctement async/await peut maintenir un état « 10 000 E/S en vol en même temps » avec une quinzaine de threads. Inversement, cette propriété n’appartient qu’aux Task liés aux E/S. Un traitement CPU enveloppé dans Task.Run occupe évidemment un thread de travail, et l’« asynchrone de façade » du chapitre 7 de la partie 2 endort aussi un thread en coulisse.
5.3. Où la continuation s’exécute
La dernière flèche de la figure 6, c’est « une fois l’achèvement reçu, où exécuter la continuation ». On sépare si l’on renvoie vers un contexte et où l’on sort le traitement qui consomme du CPU.6
flowchart TB
A["Le Task s'est achevé, on veut exécuter la continuation"]
Q1{"Au moment de l'await, avait-on capturé<br/>un SynchronizationContext ou<br/>un TaskScheduler non par défaut"}
Q2{"Avait-on mis<br/>ConfigureAwait(false)"}
UI["Renvoyer vers la cible capturée<br/>ex. : s'exécuter sur la boucle de messages du thread UI<br/>ou sur ce TaskScheduler"]
TP["Aucune obligation de revenir à un endroit précis<br/>continuer de façon synchrone sur le thread qui a achevé,<br/>ou s'exécuter sur un thread du pool"]
A --> Q2
Q2 -->|"oui"| TP
Q2 -->|"non"| Q1
Q1 -->|"oui (thread UI WPF/WinForms, etc.)"| UI
Q1 -->|"non (console / ASP.NET Core, etc.)"| TP
Figure 7 : Destination de la continuation. « On peut toucher l’UI tel quel après un await », c’est parce qu’on a renvoyé vers le contexte capturé.
Contexte capturé, et cas où l’on continue de façon synchrone
- Un
awaitsur le thread UI de WPF/WinForms capture leSynchronizationContext, et la suite revient sur le thread UI. C’est pourquoi toucher un contrôle juste après l’awaitn’est pas une violation de thread. Le côté pratique de cette conception a été traité dans « WPF/WinForms : async et le thread UI récapitulés en une fiche ». - Ce qui est capturé n’est pas seulement le
SynchronizationContext: unawaitsur unTaskSchedulernon par défaut vise aussi ce scheduler. Là où il n’y a ni l’un ni l’autre (console, ASP.NET Core, pool de threads), il n’y a pas d’obligation de revenir à un endroit précis, donc la continuation s’exécute sur le pool de threads, ou continue de façon synchrone, tel quel, sur le thread qui a achevé le Task. ConfigureAwait(false)est l’explicitation de « on n’a pas à revenir », pas une garantie de « on passe forcément sur le pool de threads ». Un await sur un Task déjà achevé (l’achèvement synchrone vu dans la partie 2 en fait partie) ne produit pas d’attente et continue tel quel sur le thread courant. Le choix dans le code de bibliothèque se trouve dans « Tableau de décision pratique pour C# async/await ».
Mauvais exemple : croire que ConfigureAwait(false) a déplacé l’exécution
On vérifie ce dernier point dans le code. L’exemple suivant a été écrit en prenant ConfigureAwait(false) pour « une instruction d’aller sur le pool de threads ».
// Mauvais exemple : le malentendu « j'ai mis ConfigureAwait(false), donc la suite s'exécute sur le pool de threads »
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// Attendu : on est sur le pool de threads, donc l'UI ne se fige pas
// Réel : si le Task était déjà achevé au moment de l'await, il n'y a pas d'attente,
// on continue sur le thread UI → ce traitement lourd fige l'UI
var rows = ParseHeavy(csv);
// De plus, si l'achèvement a eu lieu de façon asynchrone, la suite n'est PAS sur le thread UI
resultLabel.Text = $"{rows.Count} lignes"; // → peut devenir une exception de violation de thread
}
Ce que dit ConfigureAwait(false), c’est seulement « on n’a pas à renvoyer vers le contexte capturé ». Il ne spécifie pas « où ça s’exécute », donc on peut continuer sur le thread UI, ou continuer sur un thread d’achèvement d’E/S ou du pool. Les deux sont possibles, c’est la raison pour laquelle ce code est cassé.
Bon exemple : spécifier séparément le retour vers l’UI et le lieu du traitement lourd
On sépare le code UI et le code de bibliothèque, et on écrit les intentions à part. L’await pour revenir à l’UI et le Task.Run pour sortir un traitement CPU vers le pool de threads sont des rôles distincts.
// Bon exemple : indiquer séparément « revenir / ne pas revenir à l'UI » et « où faire le traitement lourd »
private async void OnLoadClick(object sender, EventArgs e)
{
// Dans le code UI, on laisse capturer (la suite revient sur le thread UI)
string csv = await File.ReadAllTextAsync(path);
// Si l'on veut sortir un traitement CPU vers le pool de threads, on l'explicite avec Task.Run
var rows = await Task.Run(() => ParseHeavy(csv));
// Ici, c'est à coup sûr le thread UI. On peut toucher les contrôles en sécurité
resultLabel.Text = $"{rows.Count} lignes";
}
// Côté bibliothèque (code sans UI), on déclare au contraire qu'« il n'est pas nécessaire de revenir »
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // ne dépend pas du contexte de l'appelant
}
Le jugement se sépare en trois.
| Objectif | Choix dans ce code |
|---|---|
| Toucher l’UI après l’await | Dans le code UI, laisser capturer le contexte |
| Sortir un traitement CPU lourd vers le pool de threads | L’expliciter avec Task.Run |
| Dans une bibliothèque, pas besoin de revenir au contexte de l’appelant | Arrêter la capture avec ConfigureAwait(false) |
ConfigureAwait(false) ne spécifie pas le thread d’exécution. Traitez-le comme un outil côté bibliothèque, qui fonctionne sans dépendre du contexte de l’appelant, distinct du placement du traitement UI et du traitement CPU.
5.4. L’identité de l’engorgement — la famine du pool de threads
Une attente synchrone bouche le thread qui exécute la continuation
Attendre de façon synchrone à l’intérieur d’une continuation ou d’un worker laisse ce thread bouché. E/S synchrone, Task.Result/Wait(), longue attente de verrou : c’est ce schéma.
Le complément propre à l’IOCP (section 3.4) agit tout de suite tant qu’il reste des threads de réserve en attente. Mais au-delà, une fois la réserve épuisée, on entre dans la zone où le pool de threads n’injecte de nouveaux threads que lentement.
Si, à l’instant où la charge monte, « on veut exécuter une continuation, mais il n’y a pas de thread pour l’exécuter », c’est la famine (starvation), et l’application tout entière s’alourdit.
Croiser le nombre de places libres et l’endroit réel du blocage
L’entrée de l’investigation a deux portes.
- Voir avec
ThreadPool.GetAvailableThreadsles places libres des workers et des threads d’achèvement d’E/S.4 - Saisir, par trace d’événements, la réalité du pool de threads et des blocages.
La procédure de trace est rassemblée dans « Identifier précisément les lenteurs avec PerfView et dotnet-trace ».
Le principe de prévention est de suivre le chemin async jusqu’au bout en async, et de ne pas y mêler de sync-over-async. Même si l’on lâche le thread pendant l’attente d’E/S, attendre de façon synchrone dans la continuation rebouche un thread à cet endroit.
6. Résumé
- L’IOCP est un mécanisme qui unifie file d’achèvement (FIFO) et contrôle du nombre de threads. Il rassemble les achèvements d’un grand nombre de handles sur un seul port, et les traite dans une boucle
GetQueuedCompletionStatus.17 - Les threads sont libérés en LIFO, donc plus on est occupé, plus le même thread continue de tourner, et changements de contexte comme défauts de cache sont minimisés.1
- La valeur de concurrence est le plafond du « nombre de threads exécutables », le point de départ est le nombre de CPU (spécifier 0). Un thread en cours d’exécution qui se bloque est compensé par un thread en attente, mais garder le traitement d’achèvement court est le grand principe.12
PostQueuedCompletionStatuspermet aussi de faire circuler des paquets maison. Pour une nouvelle implémentation, l’API du pool de threads Windows (IOCP en interne) est le premier candidat.31- Le pool de threads .NET est une structure à deux étages, threads de travail + threads d’achèvement d’E/S, et les handles asynchrones sont liés à l’IOCP du pool. Pendant l’attente d’E/S d’un
await, aucun thread n’existe ; seule la continuation après achèvement monte sur un thread.456 - La destination de la continuation dépend du contexte capturé (retour au thread UI / poursuite sur le pool de threads).
ConfigureAwait(false)est l’instruction d’arrêter cette capture.6 - L’identité de l’engorgement est presque toujours la famine du pool de threads par introduction d’attentes synchrones. Faites tenir le chemin async jusqu’au bout en async.
La suite est la partie 4, « Cache Manager — quand votre WriteFile atteint-il vraiment le disque ? ». Dans la partie 2 nous avions écrit « si c’est dans le cache, l’achèvement est synchrone », et le cache a encore pointé plusieurs fois dans cette partie. La prochaine fois, on affronte le cache lui-même — écriture différée, lecture anticipée, FILE_FLAG_NO_BUFFERING, et les conditions dans lesquelles « des données que l’on croyait écrites disparaissent à la coupure de courant ».
Articles connexes
- Les profondeurs de l’E/S Windows (partie 1) — Chaque lecture et écriture devient un IRP : la vue d’ensemble du système d’E/S
- Les profondeurs de l’E/S Windows (partie 2) — E/S synchrones et asynchrones : ce que signifie vraiment OVERLAPPED
- Tableau de décision pratique pour C# async/await - Task.Run et ConfigureAwait
- WPF/WinForms : async et le thread UI récapitulés en une fiche
- Le malentendu selon lequel TCP renvoie les données dans les mêmes unités que Send — concevoir la réception comme un flux d’octets
- Identifier précisément les lenteurs avec PerfView et dotnet-trace — Introduction pratique à l’analyse des performances .NET
- Guide pratique pour se rapprocher autant que possible du temps réel souple sur un Windows ordinaire
Domaines de conseil associés
KomuraSoft LLC prend en charge la conception d’applications et de serveurs Windows qui gèrent un grand nombre de connexions et d’E/S simultanées, et l’investigation des causes de problèmes de performance du type « le pool de threads s’engorge » ou « on a passé en asynchrone, et ça n’accélère pas ».
- Développement d’applications Windows
- Investigation de bugs et analyse de cause racine
- Développement d’applications Windows temps réel souple
- Contact
Références
-
Microsoft Learn, I/O Completion Ports. Sur le fait qu’un port d’achèvement d’E/S fournit un modèle de threading efficace pour traiter un grand nombre de requêtes d’E/S asynchrones sur un système multiprocesseur ; que, à l’achèvement d’une E/S asynchrone, un paquet d’achèvement est empilé en ordre FIFO dans la file du port ; que la cible n’est pas limitée aux fichiers sur disque, mais à tout handle prenant en charge l’overlapped I/O, sockets, tubes nommés, mailslots, etc. ; que les threads en attente sur le port sont libérés en ordre LIFO, et que, avec une valeur de concurrence de 1 et une file remplie, aucun changement de thread ne se produit ; qu’un thread s’associe à ce port au premier GetQueuedCompletionStatus et ne peut être associé qu’à un port à la fois ; que la valeur de concurrence limite le nombre de threads exécutables, le meilleur maximum d’ensemble étant le nombre de CPU ; que, si un thread en cours d’exécution entre en attente pour une autre raison, un thread en attente peut traiter un paquet d’achèvement (et que le plafond peut être dépassé temporairement quand le thread bloqué se réveille) ; et que, pour une nouvelle application serveur, il faut d’abord envisager l’API du pool de threads Windows (CreateThreadpoolIo, etc., qui utilise l’IOCP en interne). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn, CreateIoCompletionPort function. Sur le fait que CreateIoCompletionPort assure à la fois la création d’un nouveau port d’achèvement d’E/S et l’association d’un handle à un port existant ; que l’on peut spécifier un CompletionKey (valeur définie par l’utilisateur) à l’association, inclus dans le paquet d’achèvement ; et que NumberOfConcurrentThreads est le plafond du nombre de threads pouvant traiter des paquets d’achèvement en parallèle, 0 utilisant le nombre de processeurs du système. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PostQueuedCompletionStatus function. Sur le fait que PostQueuedCompletionStatus peut empiler dans la file d’un port d’achèvement d’E/S un paquet d’achèvement propre à l’application, sans démarrer d’E/S asynchrone, ce qui permet au port de servir aussi de point d’entrée pour la communication depuis d’autres threads du processus, en plus de la réception des achèvements d’E/S. ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. Sur le fait que le pool de threads .NET fournit des threads de travail et des threads pour l’achèvement des E/S asynchrones ; que ThreadPool.GetAvailableThreads permet d’obtenir séparément le nombre disponible de threads de travail et de threads d’achèvement d’E/S ; et qu’il ne faut pas effectuer de longs blocages sur un thread du pool. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. Sur le fait que ThreadPoolBoundHandle.BindHandle renvoie un ThreadPoolBoundHandle qui lie un handle du système d’exploitation au pool de threads système (à son port d’achèvement d’E/S) ; que les E/S asynchrones de bas niveau vers le handle lié se font combinées à NativeOverlapped ; et que le traitement d’achèvement des E/S asynchrones passe alors par le pool de threads. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Async in depth (.NET). Sur le fait que, pour un Task lié aux E/S, une fois l’appel passé à l’OS, il n’existe pas de thread dédié pour en attendre l’achèvement (le fameux « There is no thread ») ; que l’achèvement est notifié via le pilote de périphérique et une interruption, puis que la continuation enregistrée s’exécute ; qu’await capture par défaut le contexte courant (SynchronizationContext, etc.) et y exécute la continuation, et que, s’il n’y a pas de contexte à capturer, elle s’exécute sur le pool de threads ; et que ConfigureAwait(false) permet de désactiver cette capture. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. Sur le fait que GetQueuedCompletionStatus retire un paquet d’achèvement de la file du port (et attend s’il n’y en a pas) ; que le résultat retiré donne le nombre d’octets transférés, le CompletionKey et le pointeur OVERLAPPED ; et que, même si la valeur de retour est FALSE, un pointeur OVERLAPPED non NULL signifie que l’on a retiré le paquet d’achèvement d’une opération d’E/S en échec, et que OVERLAPPED NULL seul signifie que l’on n’a pas pu retirer de paquet (délai d’attente, etc.). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. Sur le fait que GetQueuedCompletionStatusEx peut retirer plusieurs paquets d’achèvement d’un coup, et que le nombre d’entrées retirées est renvoyé. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. Sur le fait que FILE_SKIP_COMPLETION_PORT_ON_SUCCESS permet de choisir un comportement qui n’empile pas de paquet dans le port d’achèvement lorsque l’E/S réussit immédiatement et que le résultat est déjà déterminé sur place. ↩
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Les profondeurs de l'E/S Windows (partie 2) — E/S synchrone et asynchrone : ce que signifie vraiment OVERLAPPED
Deuxième partie d'une série qui explique en schémas l'E/S synchrone et l'E/S asynchrone (Overlapped I/O) de Windows. Elle structure le se...
Les profondeurs de l'E/S Windows (partie 4) — Cache Manager : quand votre WriteFile atteint-il vraiment le disque ?
Quatrième partie de la série qui explique le Cache Manager de Windows à l'aide de schémas. Cache implémenté comme un mappage de fichiers,...
Les profondeurs de l'E/S Windows (partie 1) — Chaque lecture et écriture devient un IRP : la vue d'ensemble du système d'E/S
Premier volet d'une série qui explique le système d'E/S de Windows en partant de la base. Nous cartographions l'espace de noms de l'Objec...
Les profondeurs de l'E/S Windows (partie 6, dernière partie) — Fonctionnement des minifilters et enquête de latence avec Procmon
Explique comment les minifilters surveillent et contrôlent les E/S fichiers : FltMgr, altitudes, rappels pre/post, fltmc, repérage des op...
Les profondeurs de l'E/S Windows (partie 5) — Structure interne de NTFS : comprendre le système de fichiers à travers la MFT
Cinquième partie de la série qui explique la structure interne de NTFS à l'aide de schémas. MFT et enregistrements de fichiers, flux de d...
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 que le port d'achèvement d'E/S (IOCP) ?
- C'est un mécanisme du noyau Windows qui regroupe dans une seule file les notifications d'achèvement d'un grand nombre d'E/S asynchrones, tout en contrôlant du même coup le nombre de threads qui peuvent s'exécuter simultanément pour les traiter. On crée un port avec CreateIoCompletionPort, et en y associant des handles de fichiers, de sockets, etc., un paquet d'achèvement est mis dans la file FIFO du port chaque fois qu'une E/S asynchrone s'achève. Les threads de travail retirent les paquets de la file avec GetQueuedCompletionStatus pour les traiter. Le point clé est que ce n'est pas une simple file de notifications : c'est aussi un mécanisme d'ordonnancement qui maintient le nombre de threads exécutables au plus à la valeur de concurrence. Cela permet de traiter efficacement un grand nombre d'E/S simultanées avec peu de threads, et c'est le socle des implémentations de serveurs Windows et du pool de threads .NET.
- Quelle valeur de concurrence (nombre d'exécutions simultanées) faut-il donner à l'IOCP ?
- La documentation Microsoft indique que, globalement, la meilleure valeur maximale est le nombre de CPU de la machine. Passer 0 à NumberOfConcurrentThreads de CreateIoCompletionPort fait utiliser le nombre de processeurs du système : en cas de doute, 0 est le point de départ. Cette valeur est une limite sur le nombre de threads en état exécutable, pas sur le nombre de threads en attente. Si un thread en cours d'exécution entre en attente pour une raison quelconque, le système réveille un autre thread en attente pour combler le vide ; si le traitement mélange de longs calculs ou des blocages, on peut donc aussi choisir d'augmenter la valeur de concurrence pour traiter davantage de paquets à la fois. La recommandation finale est d'ajuster cela en le combinant avec du profilage.
- Pourquoi peut-on affirmer que l'attente d'E/S d'async/await ne consomme pas de thread ?
- Parce qu'entre l'émission d'une E/S et son achèvement, il n'existe nulle part de thread dédié pour s'occuper de cette opération. Comme nous l'avons vu dans les parties 1 et 2 de la série, la requête émise circule dans la pile de périphériques sous forme d'IRP, et l'appel revient immédiatement avec ERROR_IO_PENDING. L'await se contente alors d'enregistrer une continuation sur le Task non achevé et de lâcher le thread. Pendant que le périphérique travaille en tant que matériel, il n'existe, ni en mode utilisateur ni dans le noyau, aucun thread qui ne fait qu'attendre. À l'achèvement, un paquet d'achèvement est mis dans l'IOCP du pool de threads, et c'est seulement là qu'un thread d'achèvement d'E/S s'active brièvement pour planifier la continuation enregistrée. Autrement dit, un thread n'est utilisé qu'au moment de l'émission et lors du traitement postérieur à l'achèvement ; le temps d'attente lui-même se déroule avec zéro thread.
- Sur quel thread s'exécute la suite (la continuation) d'un await ?
- Par défaut, le SynchronizationContext (ou le TaskScheduler) présent au moment de l'await est capturé, et la continuation lui est renvoyée. Si l'on fait await sur le thread d'interface de WPF ou WinForms, la suite s'exécute sur ce thread d'interface, d'où le fait qu'on puisse toucher directement des contrôles juste après un await. S'il n'y a aucun contexte à capturer (application console, ASP.NET Core, code s'exécutant déjà sur le pool de threads, etc.), la continuation s'exécute sur un thread du pool de threads, ou continue directement sur le thread qui a achevé le Task. ConfigureAwait(false) désactive cette capture, mais ce n'est pas une garantie que l'on passe forcément sur le pool de threads : c'est une instruction disant qu'il n'est pas nécessaire de revenir à un endroit précis. Si l'on fait await sur un Task déjà achevé, aucune attente ne survient et l'exécution continue de façon synchrone sur le thread courant. ConfigureAwait(false) est recommandé dans le code de bibliothèque pour éviter des allers-retours inutiles vers le thread d'interface, et pour couper court à toute dépendance à un contexte particulier ou à un risque d'interblocage.
- Que se passe-t-il si l'on bloque longtemps un thread de travail de l'IOCP ou un thread d'achèvement d'E/S de .NET ?
- Cela ne casse rien immédiatement, mais les performances se dégradent en sortant du cadre prévu par le mécanisme. Quand un thread en cours d'exécution entre en attente, l'IOCP réveille un thread en attente pour combler le vide, mais ce remplacement gonfle d'autant le nombre d'exécutions simultanées et augmente les changements de contexte. Si le blocage devient habituel, les paquets s'accumulent dans la file et tout le traitement d'achèvement prend du retard. C'est la même chose en .NET : attendre de façon synchrone (une E/S synchrone, un Task.Result, etc.) à l'intérieur d'un thread d'achèvement d'E/S ou d'une continuation provoque une famine (starvation) du pool de threads. Le principe est de garder le traitement d'achèvement et les continuations courts, et d'extraire le travail lourd ailleurs. ThreadPool.GetAvailableThreads permet d'observer les threads de travail et d'achèvement d'E/S disponibles, ce qui sert à diagnostiquer un engorgement.
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.