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. Nous y avions cité, sans plus, le nom de « la vraie solution pour recevoir un grand nombre d’E/S simultanées avec peu de threads » : c’est le port d’achèvement d’E/S (IOCP).
Pourquoi un serveur web peut-il gérer des milliers de connexions simultanées avec une dizaine de threads seulement ? Pourquoi peut-on affirmer catégoriquement que l’attente d’E/S d’async/await « ne consomme pas de thread » ? Pourquoi la suite d’un await s’exécute-t-elle tantôt sur le thread d’interface, tantôt sur le pool de threads ? Les réponses à ces trois questions aboutissent toutes à une seule et même conception : l’IOCP. Cette partie est, de toute la série, celle qui concerne le plus directement les développeurs .NET.
C’est la troisième partie de la série « Les profondeurs de l’I/O Windows ». Le plan d’ensemble se trouve au début de la partie 1.
Comme l’article est long, voici d’abord où trouver la réponse à chaque question.
- Question 1 : pourquoi une dizaine de threads suffit à gérer des milliers de connexions simultanées → réponse au chapitre 3 (la conception de l’IOCP, qui unifie la file d’achèvement et le contrôle du nombre de threads).
- Question 2 : pourquoi on peut affirmer que l’attente d’E/S « ne consomme pas de thread » → réponse aux sections 5.1 et 5.2 (la vue d’ensemble d’un aller-retour d’await, et le sens précis du fait qu’aucun thread n’existe pendant l’attente).
- Question 3 : ce qui détermine le thread d’exécution de la suite d’un
await→ réponse à la section 5.3 (le contexte capturé et le vrai sens deConfigureAwait(false)).
1. La conclusion, d’abord
- L’IOCP est un mécanisme qui unifie « la file de notifications d’achèvement » et « le contrôle du nombre de threads ». Les paquets d’achèvement sont mis dans la file en FIFO, et les threads de travail les retirent avec
GetQueuedCompletionStatus(chapitre 3).1 - Les threads sont réveillés en LIFO. Le thread « chaud », qui travaillait encore juste avant, récupère aussi le paquet suivant, si bien que tant que la file reste alimentée, presque aucun changement de contexte ne survient (section 3.3).1
- La valeur de concurrence est la limite du « nombre de threads exécutables ». Le point de départ recommandé est le nombre de CPU (0 fait utiliser le nombre de processeurs). Quand un thread en cours d’exécution se bloque, un thread en attente est réveillé pour combler le vide (section 3.4).12
- Le port peut aussi servir à ses propres notifications.
PostQueuedCompletionStatuspermet de mettre en file des paquets sans rapport avec une E/S, ce qui permet de faire transiter par la même file des demandes de travail pour les threads de travail ou des instructions d’arrêt (chapitre 4).3 - Pour une nouvelle implémentation de serveur, l’API du pool de threads Windows (
CreateThreadpoolIo) est recommandée plutôt que l’IOCP brut. En interne, c’est toujours l’IOCP, mais la gestion des threads est prise en charge à votre place (chapitre 4).1 - Le pool de threads .NET a deux étages, threads de travail et threads d’achèvement d’E/S, et les handles d’E/S asynchrone sont liés à l’IOCP du pool de threads. Aucun thread n’existe pendant l’attente d’E/S d’un
await; seule la continuation, après l’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 d’interface renvoie la suite vers ce thread d’interface ; s’il n’y a rien à capturer, elle continue sur le pool de threads (ou sur le thread qui a achevé l’opération).ConfigureAwait(false)est une instruction pour arrêter cette capture, pas une garantie de passage sur le pool de threads (section 5.3).6
2. Où s’effondre la conception « à coups de threads »
Commençons par identifier le problème que l’IOCP cherche à résoudre. Un serveur naïf peut s’écrire avec « un thread par connexion » : lire en E/S synchrone, traiter, écrire. C’est une conception facile à comprendre, mais elle se heurte à deux murs quand les connexions se multiplient.
flowchart TB
subgraph A["Un thread par connexion (E/S synchrone)"]
T1["Thread 1 : attend le read de la connexion 1"]
T2["Thread 2 : attend le read de la connexion 2"]
T3["Thread 3 : attend le read de la connexion 3"]
TN["… le nombre de threads croît avec le nombre de connexions<br/>la plupart dorment en attendant une E/S"]
end
subgraph B["Le modèle IOCP (E/S asynchrone)"]
Q["File d'achèvement<br/>(les notifications de toutes les connexions y convergent)"]
W1["Travailleur 1"]
W2["Travailleur 2"]
WN["Peu de travailleurs, de l'ordre du nombre de CPU"]
Q --> W1
Q --> W2
Q --> WN
end
Figure 1 : à gauche, le nombre de threads croît proportionnellement au nombre de connexions. À droite, seuls les « événements survenus » sont traités, par un petit nombre de threads
- Un thread n’est pas gratuit. Chacun consomme une pile (1 Mo réservé par défaut) et un objet noyau, et plus leur nombre augmente, plus la charge sur le planificateur et les changements de contexte s’accumule. Des milliers de connexions égalent des milliers de threads, et même si la plupart d’entre eux ne font que « dormir en attendant de pouvoir lire », cela coûte cher.
- Vouloir exécuter plus de threads que de CPU n’a pas de sens. Le nombre de threads pouvant courir simultanément est physiquement limité au nombre de CPU. Rendre exécutable un nombre de threads supérieur ne fait qu’augmenter la perte due aux changements de contexte.
Jusqu’à la partie 2, nous avons vu comment recevoir la notification d’achèvement d’une E/S par « événement » ou par « APC ». Mais la méthode par événement se heurte à la limite de 64 de WaitForMultipleObjects et complique la conception de l’attente, tandis que l’APC reste liée au thread émetteur. L’IOCP, lui, a été conçu dès le départ pour la forme beaucoup d’E/S × peu de threads.1
3. La conception de l’IOCP — l’unification de la file et du contrôle des threads
3.1. Les deux visages de CreateIoCompletionPort
Contrairement à ce que son nom suggère, CreateIoCompletionPort fait deux choses : créer un nouveau port, et associer un handle à un port existant.2
flowchart LR
subgraph SRC["Handles associés (autant que voulu)"]
H1["Fichier"]
H2["Socket"]
H3["Tube nommé"]
end
subgraph PORT["Port d'achèvement d'E/S"]
Q["File des paquets d'achèvement (FIFO)<br/>Paquet = octets transférés +<br/>CompletionKey + pointeur OVERLAPPED"]
C["Contrôle de concurrence<br/>threads exécutables ≦ limite"]
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 : la structure de l’IOCP. L’achèvement d’un grand nombre de handles converge vers une seule file, dont le nombre de threads consommateurs est lui aussi contrôlé
La CompletionKey passée lors de l’association est une valeur libre servant à indiquer au travailleur « voici un achèvement provenant de ce handle » (y placer un pointeur vers l’objet de connexion est la pratique classique). Le paquet d’achèvement arrive avec la CompletionKey, le pointeur OVERLAPPED de cette opération, et le nombre d’octets transférés. Quelle connexion (CompletionKey), quelle opération (OVERLAPPED), et jusqu’où elle a avancé (le nombre d’octets) — c’est ici que se récupère le « bordereau d’opération » vu dans la partie 2.27
La cible ne se limite pas aux « fichiers ». Socket, tube nommé, boîte aux lettres (mailslot) — n’importe quel handle capable de parler l’E/S overlapped peut être associé.1 La conception « tout ressemble à un fichier » vue dans la partie 1 porte ses fruits ici aussi.
3.2. Le voyage d’un paquet d’achèvement
sequenceDiagram
participant DRV as Noyau (achèvement de l'IRP)
participant Q as File du port (FIFO)
participant W as Thread de travail
Note over W: Attend avec GetQueuedCompletionStatus
DRV->>Q: Met en file le paquet d'achèvement<br/>(octets / CompletionKey / OVERLAPPED)
Q->>W: Réveille un thread en attente et le lui remet
Note over W: Examine le paquet et traite l'achèvement<br/>(exécution de la continuation, émission de la prochaine E/S, etc.)
W->>Q: Termine le traitement puis rappelle GetQueuedCompletionStatus
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 sont mis en file en FIFO, et les travailleurs bouclent en les retirant puis en les traitant
Quand une E/S asynchrone s’achève, le paquet d’achèvement est mis dans la file du port en ordre FIFO. Le travailleur appelle GetQueuedCompletionStatus pour recevoir un paquet, puis le rappelle une fois le traitement terminé — cette boucle est le squelette de la programmation IOCP.17 Il existe aussi GetQueuedCompletionStatusEx, qui retire plusieurs paquets à la fois, ce qui réduit le nombre d’appels pour des E/S à haute fréquence.8
Réglons d’abord le bug classique de la boucle de travailleur. Même si GetQueuedCompletionStatus retourne FALSE, si le pointeur OVERLAPPED revient non NULL, cela signifie qu’« on a réussi à retirer le paquet d’achèvement d’une E/S qui a échoué ».7 Une opération échouée a elle aussi besoin d’un nettoyage (traitement de l’erreur, et libération du bordereau et du tampon vus dans la partie 2), donc ce paquet doit être traité. On ne peut dire qu’« on n’a pas réussi à retirer le paquet lui-même » (expiration de délai, fermeture du port, etc.) que lorsque OVERLAPPED est NULL. Écrire paresseusement if (!GetQueuedCompletionStatus(...)) break; laisse échapper toutes les E/S échouées et les fait fuir.
Voici le squelette sous une forme que vous pouvez recopier tel quel. L’ordre des tests correspond directement à l’explication ci-dessus.
/* Squelette de la boucle de travailleur 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) {
/* Aucun paquet n'a pu être retiré (port fermé, etc.). C'est la seule condition de sortie légitime */
break;
}
if (!ok) {
/* ov != NULL -> on a réussi à retirer « le paquet d'achèvement d'une E/S en échec ».
Un nettoyage (traitement de l'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 d'arrêt mis en file avec PostQueuedCompletionStatus (chapitre 4) */
break;
}
handle_completed_io(key, ov, bytes); /* Traitement d'achèvement normal. À garder court (section 3.4) */
}
Le point essentiel est de ne pas régler ok == FALSE en une seule branche, mais de la diviser en deux selon que ov est NULL ou non. Le test reste le même si l’on utilise un délai d’expiration (autre chose qu’INFINITE) : l’expiration apparaît alors comme ok == FALSE avec ov == NULL.
Notez que dès qu’un thread appelle GetQueuedCompletionStatus pour la première fois, il s’associe à ce port (un thread ne peut être associé qu’à un seul port à la fois).1 Retenez l’image précise d’« une équipe de travailleurs dédiée attachée au port ».
3.3. Les threads sont réveillés en LIFO
C’est ici que se révèle l’élégance de la conception de l’IOCP. Les paquets sont mis en file en FIFO, mais les threads en attente sont réveillés en LIFO. Autrement dit, c’est le thread qui a travaillé le plus récemment qui récupère aussi le paquet suivant.1
flowchart TB
Q["File : P1 -> P2 -> P3 (FIFO)"]
subgraph TH["Threads en attente (pile LIFO)"]
A["Thread A (a couru jusqu'à l'instant d'avant, chaud)"]
B["Thread B (dort depuis un moment)"]
C["Thread C (dort depuis longtemps)"]
end
Q -->|"P1, P2 et P3 vont d'abord<br/>au thread A s'il est libre"| A
B -.->|"Seulement si A est occupé"| Q
C -.->|"Se réveille rarement"| Q
Figure 4 : la libération en LIFO. Plus l’activité est intense, plus le même thread continue de tourner, tandis que les threads inactifs peuvent rester endormis
Cette conception apporte deux avantages.
- Aucun changement de contexte ne survient. Tant qu’il reste des paquets dans la file, un thread qui a fini son traitement et rappelle
GetQueuedCompletionStatusreçoit immédiatement le paquet suivant, sans attendre, et continue de tourner. La documentation précise explicitement que, dans un scénario avec une valeur de concurrence de 1, « aucun changement de thread ne se produit ».1 - Le cache reste chaud et exploitable. Comme le même thread continue de tourner, il est plus probable que la pile et l’état d’ordonnancement restent présents dans le cache du CPU. Les threads endormis, eux, sont maintenus à faible coût comme réserve pour les pics de charge.
3.4. La valeur de concurrence — compter ce qui est « exécutable »
Le NumberOfConcurrentThreads fourni à la création du port est la valeur de concurrence. C’est la limite du « nombre de threads exécutables (runnable) associés à ce port » ; tant que la limite est atteinte, aucun thread supplémentaire ne peut recevoir de paquet.1 Passer 0 fait utiliser le nombre de processeurs du système, et la documentation indique elle-même que globalement, la meilleure valeur maximale est le nombre de CPU.21
Ce qui est astucieux, c’est que ce nombre ne compte pas les « threads réveillés », mais les threads « exécutables ».
flowchart TB
P["Un paquet arrive dans la file"]
Q{"Le nombre de threads exécutables<br/>est-il inférieur à la valeur de concurrence ?"}
RUN["Réveille un thread en attente pour le traiter"]
HOLD["Ne réveille personne, laisse 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/>entre en attente pour une autre raison"]
COMP["Réveille un thread en attente pour combler<br/>exactement la baisse du nombre exécutable"]
P --> Q
Q -->|Inférieur| RUN
Q -->|À la limite| HOLD
BLK --> COMP
Figure 5 : le contrôle de concurrence. La limite porte sur « le nombre exécutable », donc dès que quelqu’un se bloque, elle se comble automatiquement
Quand un travailleur en cours d’exécution entre dans une attente quelconque (un verrou, un défaut de page, une E/S synchrone écrite par inadvertance), le nombre exécutable diminue, et le système réveille un thread en attente pour combler le vide.1 C’est pourquoi la pratique classique n’est pas de créer « exactement le nombre de CPU » de travailleurs, mais de garder davantage de threads en attente que la valeur de concurrence. Si le traitement mélange de longs calculs, on peut aussi choisir d’augmenter la valeur de concurrence elle-même ; la position de la documentation est qu’il faut in fine ajuster cela par du profilage.1
Ce remplacement n’est cependant pas une solution miracle. Si le thread bloqué se réveille plus tard, le nombre exécutable dépasse la limite le temps de cet instant (la documentation mentionne elle-même ce dépassement).1 Le grand principe reste de garder le traitement d’achèvement court, un principe qui vaudra sous la même forme pour .NET au chapitre 6.
4. La boîte à outils — les API qui soutiennent le port
PostQueuedCompletionStatus— permet de mettre en file son propre paquet d’achèvement sans émettre d’E/S.3 Demande de travail pour les travailleurs, instruction d’arrêt (mettre en file autant de paquets de fin — familièrement appelés paquets « pilule empoisonnée » — qu’il y a de travailleurs), notification venant d’un autre thread — pouvoir traiter l’achèvement d’une E/S et ses propres messages dans la même file et la même boucle simplifie énormément la conception.GetQueuedCompletionStatusEx— retire plusieurs paquets d’achèvement à la fois. Efficace pour des E/S à haute fréquence, où le surcoût d’un appel par paquet finit par se faire sentir.8SetFileCompletionNotificationModes— pour le cas, vu au chapitre 5 de la partie 2, où « émis de façon asynchrone mais achevé de façon synchrone », permet de choisir un mode qui ne met pas de paquet dans le port (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). C’est une accélération fondée sur l’idée que, puisque le résultat d’un achèvement synchrone est déjà connu sur place, repasser par la file ne fait que gaspiller du temps.9- L’API du pool de threads Windows —
CreateThreadpoolIo/StartThreadpoolIoutilisent l’IOCP en interne tout en prenant en charge à votre place la création et la gestion des threads. Microsoft recommande de considérer d’abord cette voie pour une nouvelle application serveur, et de n’utiliser l’IOCP brut que lorsqu’on veut contrôler explicitement la valeur de concurrence ou la gestion des threads.1 Et le pool de threads .NET est justement une implémentation, au niveau du runtime .NET, de cette « automatisation de l’IOCP et de la gestion des threads ».
Trois pièges, pour finir. (1) Ne bloquez pas longtemps à l’intérieur d’un travailleur (le remplacement de la section 3.4 ne fait qu’atténuer la dégradation). (2) L’identification d’un paquet d’achèvement se fait à deux niveaux — la CompletionKey (par handle) et OVERLAPPED (par opération) : la gestion de la durée de vie du « bordereau » de la partie 2 (ne pas le libérer avant l’achèvement) reste ici aussi vitale. (3) Ne fermez pas un handle en laissant des E/S non achevées : le comportement du cleanup (partie 1, chapitre 6) et les bonnes pratiques d’annulation (partie 2, chapitre 6) s’appliquent tels quels.
5. Le pool de threads .NET — deux étages bâtis sur l’IOCP
C’est ici que commence le vrai sujet : « le sous-sol d’async/await ».
Le pool de threads .NET compte deux types de threads : les threads de travail, qui exécutent Task.Run et les continuations, et les threads d’achèvement d’E/S, qui reçoivent l’achèvement des E/S asynchrones. Si ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) retourne deux nombres distincts, c’est parce que l’intérieur a effectivement deux étages.4
Et sous Windows, le pool de threads possède son propre port d’achèvement d’E/S. L’API bas niveau actuelle pour associer un handle de l’OS à ce port est ThreadPoolBoundHandle.BindHandle ; l’E/S asynchrone vers un handle ainsi lié se manipule avec NativeOverlapped (rien de moins que le visage côté .NET de l’OVERLAPPED de la partie 2) (l’ancien ThreadPool.BindHandle, qui joue le même rôle, existe toujours, mais préférez celui-ci pour du code nouveau). Quand FileStream ou Socket ouvre un handle en mode asynchrone, ce type de liaison a lieu en interne.5 Autrement dit :
- « un handle en mode asynchrone accompagné d’OVERLAPPED », vu dans la partie 2, est le mécanisme d’émission
- l’IOCP de cet article est le mécanisme qui reçoit l’achèvement
- et les threads d’achèvement d’E/S du pool de threads .NET sont l’équipe de travailleurs qui fait tourner la boucle
GetQueuedCompletionStatus
— cette correspondance fait que l’image du Win32 devient telle quelle l’image du .NET.
5.1. Un aller-retour d’await ReadAsync — la version complète
Dans la figure 7 de la partie 2, nous nous étions contentés d’écrire « véritable E/S asynchrone » dans une boîte fermée. Cette fois, nous l’ouvrons jusqu’au bout.
sequenceDiagram
participant U as Thread appelant<br/>(thread d'interface, etc.)
participant K as Noyau<br/>(émission de l'IRP -> 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/>(accompagnée de l'équivalent d'un OVERLAPPED)
K-->>U: ERROR_IO_PENDING (revient immédiatement)
Note over U: await enregistre une continuation sur le Task non achevé<br/>et lâche le thread (pour l'UI, retour au traitement du message suivant)
Note over K: Le périphérique travaille<br/>pendant ce temps, aucun thread n'attend nulle part
K->>Q: Met le paquet d'achèvement en file
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 et planifie la continuation
IO->>C: L'envoie vers le contexte capturé<br/>(vers le thread d'interface, ou exécution sur le pool de threads à défaut)
Note over C: Le code de la suite de l'await s'exécute
Figure 6 : l’ensemble d’un aller-retour d’await. Un thread ne travaille qu’à « l’émission » et « après l’achèvement » ; le temps d’attente se déroule avec zéro thread
5.2. Le sens précis de « l’attente d’E/S ne consomme pas de thread »
Ce que ce schéma cherche à souligner, c’est qu’entre l’émission et l’achèvement, aucun thread dont le seul rôle serait d’attendre cet achèvement n’existe, ni en mode utilisateur ni dans le noyau. L’explication officielle de Microsoft sur async (async in depth) va jusqu’aux pilotes de périphérique et aux interruptions pour expliquer que, pour un Task lié à une E/S, « il n’existe nulle part de thread dont le seul rôle serait d’attendre son achèvement ».6 Il arrive certes qu’à l’intérieur du noyau, un pilote confie une partie du traitement à un thread de travail du système. Mais il s’agit d’un travail court destiné à faire avancer la requête, et non d’un thread qui reste à attendre en bloquant jusqu’à l’achèvement — c’est cela, précisément, qui est garanti.
Pour le reformuler à partir de ce que nous avons accumulé depuis la partie 1 : l’IRP séjourne dans la pile de périphériques non pas comme un thread mais comme une structure de données (partie 1), l’émission revient immédiatement avec ERROR_IO_PENDING (partie 2), et l’achèvement arrive via une chaîne d’événements — interruption puis paquet d’achèvement (cet article). La conception est ainsi faite qu’elle n’a pas besoin de la ressource coûteuse qu’est un thread pour maintenir l’état d’« attente ».
C’est pourquoi une application qui utilise correctement async/await peut maintenir un état où « 10 000 E/S sont en vol simultanément » avec seulement une dizaine de threads. À l’inverse, cette propriété n’appartient qu’aux Task liés à une E/S. Un traitement CPU enveloppé dans Task.Run occupe naturellement un thread de travail, et l’« asynchrone de façade » du chapitre 7 de la partie 2 fait lui aussi dormir un thread en coulisses.
5.3. Où s’exécute la continuation
La dernière flèche de la figure 6 — « où envoyer la continuation » — obéit à une règle bien précise.6
flowchart TB
A["Le Task s'achève, on veut exécuter la continuation"]
Q1{"Au moment de l'await, un SynchronizationContext<br/>ou un TaskScheduler non par défaut<br/>avaient-ils été capturés ?"}
Q2{"ConfigureAwait(false)<br/>avait-il été ajouté ?"}
UI["Renvoyée vers le contexte capturé<br/>ex. exécutée sur la boucle de messages<br/>du thread d'interface, ou sur ce TaskScheduler"]
TP["Aucune obligation de revenir à un endroit précis<br/>continue de façon synchrone sur le thread qui a achevé<br/>ou s'exécute sur un thread du pool de threads"]
A --> Q2
Q2 -->|"Oui"| TP
Q2 -->|"Non"| Q1
Q1 -->|"Oui (thread d'interface WPF/WinForms, etc.)"| UI
Q1 -->|"Non (console/ASP.NET Core, etc.)"| TP
Figure 7 : la destination de la continuation. Pouvoir toucher directement l’UI juste après un await tient au fait qu’on la renvoie vers le contexte capturé
- Un
awaitsur le thread d’interface de WPF ou WinForms capture leSynchronizationContext, et la suite revient sur ce thread d’interface. C’est pour cela que toucher un contrôle juste après unawaitne provoque pas de 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: si l’awaita lieu sur unTaskSchedulernon par défaut, ce planificateur est également concerné. Là où ni l’un ni l’autre n’existe (console, ASP.NET Core, code déjà sur le pool de threads), il n’y a aucune obligation de revenir à un endroit précis : la continuation s’exécute alors sur le pool de threads, ou continue directement, de façon synchrone, sur le thread qui a achevé le Task. ConfigureAwait(false)déclare explicitement qu’« il n’est pas nécessaire de revenir », mais ce n’est pas une garantie qu’« on passe forcément sur le pool de threads ». Si l’on faitawaitsur un Task déjà achevé (ce qui inclut l’achèvement synchrone vu dans la partie 2), aucune attente ne survient et l’exécution continue directement sur le thread courant. Pour les nuances d’usage dans du code de bibliothèque, reportez-vous à « Tableau de décision pratique pour C# async/await - Task.Run et ConfigureAwait ».
Ce dernier point donne lieu à beaucoup de malentendus, alors comparons-le en code. D’abord, du code écrit en croyant que ConfigureAwait(false) est « une instruction de passer sur le pool de threads ».
// Mauvais exemple : le malentendu « comme ConfigureAwait(false) est là, la suite tourne 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 ici, donc l'UI ne se fige pas
// Réalité : si le Task était déjà achevé au moment de l'await, aucune attente ne survient,
// et l'exécution continue sur le thread d'interface -> ce traitement lourd fige l'UI
var rows = ParseHeavy(csv);
// Et de plus, si l'achèvement a été asynchrone, la suite n'est pas forcément sur le thread d'interface
resultLabel.Text = $"{rows.Count} éléments"; // -> peut devenir une exception de violation de thread
}
ConfigureAwait(false) dit uniquement qu’« il n’est pas nécessaire de revenir vers le contexte capturé ». Cela ne précise pas « où » l’exécution se poursuit : elle peut continuer sur le thread d’interface, ou sur un thread d’achèvement d’E/S ou du pool de threads. C’est parce que les deux sont possibles que ce code est cassé.
En exprimant l’intention séparément, cela donne ceci.
// Bon exemple : indiquer séparément « revenir ou non vers l'UI » et « où exécuter le traitement lourd »
private async void OnLoadClick(object sender, EventArgs e)
{
// Dans le code d'UI, laisser la capture active (la suite revient sur le thread d'interface)
string csv = await File.ReadAllTextAsync(path);
// Si l'on veut envoyer un traitement CPU sur le pool de threads, l'expliciter avec Task.Run
var rows = await Task.Run(() => ParseHeavy(csv));
// Ici, c'est assurément le thread d'interface. On peut toucher les contrôles en toute sécurité
resultLabel.Text = $"{rows.Count} éléments";
}
// Côté bibliothèque (code sans UI), à l'inverse, déclarer 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 critère de décision est simple : dans le code qui touche l’UI, laissez la capture active, et explicitez avec Task.Run quand vous voulez changer de destination. Considérez ConfigureAwait(false) comme un outil côté bibliothèque servant à déclarer que « ça fonctionne sans risque, quel que soit le contexte de l’appelant ».
5.4. La vraie nature d’un engorgement — la famine du pool de threads
Pour finir, un seul cas d’engorgement de ce sous-sol. Attendre de façon synchrone (une E/S synchrone, Task.Result/Wait(), une longue attente de verrou) à l’intérieur d’une continuation ou d’un travailleur laisse ce thread bloqué. Le remplacement propre à l’IOCP (section 3.4) fonctionne immédiatement tant qu’il reste des threads de réserve en attente. Mais une fois la réserve épuisée, on entre dans une zone où le pool de threads n’injecte de nouveaux threads que lentement. Dès que la charge monte, une famine (starvation) survient — « on veut exécuter une continuation, mais il n’y a aucun thread pour le faire » — et toute l’application se met à traîner.
Il y a deux points d’entrée pour l’investigation. Observer les threads de travail/d’achèvement d’E/S disponibles avec ThreadPool.GetAvailableThreads.4 Et cerner la réalité du pool de threads et des blocages par une trace d’événements — la marche à suivre est réunie dans « Identifier précisément les lenteurs avec PerfView et dotnet-trace — Introduction pratique à l’analyse des performances .NET ». La prévention est simple : garder le chemin d’async entièrement asynchrone jusqu’au bout (ne jamais y mêler du sync-over-async), et c’est tout.
6. Résumé
- L’IOCP est un mécanisme qui unifie la file d’achèvement (FIFO) et le contrôle du nombre de threads. Il regroupe dans un seul port l’achèvement d’un grand nombre de handles, et le traite via une boucle de
GetQueuedCompletionStatus.17 - Les threads sont libérés en LIFO, si bien que plus l’activité est intense, plus le même thread continue de tourner, ce qui minimise les changements de contexte et les défauts de cache.1
- La valeur de concurrence est la limite du « nombre de threads exécutables », avec le nombre de CPU (0) comme point de départ. Un thread en cours d’exécution qui se bloque est remplacé par un thread en attente, mais le grand principe reste de garder le traitement d’achèvement court.12
PostQueuedCompletionStatuspermet aussi de faire transiter ses propres paquets. Pour une nouvelle implémentation, l’API du pool de threads Windows (l’IOCP en interne) est le premier choix.31- Le pool de threads .NET a deux étages, threads de travail plus threads d’achèvement d’E/S, et les handles asynchrones se lient à l’IOCP du pool. Aucun thread n’existe pendant l’attente d’E/S d’un
await; seule la continuation, après l’achèvement, monte sur un thread.456 - La destination de la continuation dépend du contexte capturé (retour au thread d’interface, ou poursuite sur le pool de threads).
ConfigureAwait(false)est l’instruction qui arrête cette capture.6 - La vraie nature d’un engorgement est presque toujours une famine du pool de threads causée par l’intrusion d’une attente synchrone. Gardez le chemin d’async entièrement asynchrone, jusqu’au bout.
La suite est la partie 4, « Le gestionnaire de cache — quand votre WriteFile atteint-il réellement le disque ? ». Dans la partie 2, nous avions écrit que les données en cache s’achèvent de façon synchrone, et l’ombre du cache a plusieurs fois plané sur cet article aussi. La prochaine fois, nous affrontons le cache lui-même de front — l’écriture différée, la lecture anticipée, FILE_FLAG_NO_BUFFERING, et les conditions dans lesquelles « des données que vous croyiez avoir écrites disparaissent lors d’une coupure de courant ».
Articles connexes
- Les profondeurs de l’I/O Windows (partie 1) — Chaque lecture et écriture devient un IRP : la vue d’ensemble du système d’E/S
- Les profondeurs de l’I/O 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
合同会社小村ソフト (Komura Software LLC) prend en charge la conception d’applications et de serveurs Windows traitant un grand nombre de connexions et d’E/S simultanées, ainsi que l’investigation des causes de problèmes de performance tels que « le pool de threads s’engorge » ou « la mise en asynchrone n’a pas accéléré les choses ».
- Développement d’applications Windows
- Investigation de bugs et analyse des causes
- Développement d’applications Windows à temps réel souple
- Contact
Références
-
Microsoft Learn, I/O Completion Ports. Sur le fait que le 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 ; sur le fait qu’à l’achèvement d’une E/S asynchrone, un paquet d’achèvement est mis dans la file du port en ordre FIFO ; sur le fait que la cible ne se limite pas aux fichiers sur disque mais inclut tout handle prenant en charge l’E/S overlapped, comme les sockets, tubes nommés et mailslots ; sur le fait que les threads en attente sur le port sont libérés en ordre LIFO, et qu’avec une valeur de concurrence de 1 et une file alimentée, aucun changement de thread ne se produit ; sur le fait que, dès qu’un thread appelle GetQueuedCompletionStatus pour la première fois, il s’associe à ce port, et qu’il ne peut être associé qu’à un seul port à la fois ; sur le fait que la valeur de concurrence limite le nombre de threads exécutables, et que globalement la meilleure valeur maximale est le nombre de CPU ; sur le fait que, lorsqu’un thread en cours d’exécution entre en attente pour une autre raison, un thread en attente peut traiter les paquets d’achèvement (et que le nombre exécutable peut temporairement dépasser la limite quand le thread bloqué se réveille) ; et sur le fait qu’une nouvelle application serveur devrait 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 effectue à la fois la création d’un nouveau port d’achèvement d’E/S et l’association d’un handle à un port existant ; sur le fait qu’on peut indiquer, lors de l’association, une CompletionKey (valeur définie par l’utilisateur) qui figure dans le paquet d’achèvement ; et sur le fait que NumberOfConcurrentThreads est la limite du nombre de threads pouvant traiter des paquets d’achèvement en parallèle, la valeur 0 faisant utiliser le nombre de processeurs du système. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, PostQueuedCompletionStatus function. Sur le fait que PostQueuedCompletionStatus permet de mettre dans la file du 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 de réception pour la communication venant 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 ; sur le fait que ThreadPool.GetAvailableThreads permet d’obtenir séparément le nombre de threads de travail et de threads d’achèvement d’E/S disponibles ; et sur le fait qu’il ne faut pas bloquer longtemps sur un thread du pool de threads. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. Sur le fait que ThreadPoolBoundHandle.BindHandle retourne un ThreadPoolBoundHandle liant un handle du système d’exploitation au pool de threads système (à son port d’achèvement d’E/S) ; sur le fait que l’E/S asynchrone bas niveau sur un handle ainsi lié se fait en combinaison avec NativeOverlapped ; et sur le fait que le traitement de l’achèvement de l’E/S asynchrone est alors pris en charge par le pool de threads. ↩ ↩2 ↩3
-
Microsoft Learn, Async in depth (.NET). Sur le fait que, pour un Task lié à une E/S, une fois l’appel passé à l’OS, aucun thread dédié n’existe pour attendre son achèvement (le fameux « There is no thread ») ; sur le fait que l’achèvement est notifié via les pilotes de périphérique et les interruptions, et que la continuation enregistrée est alors exécutée ; sur le fait qu’await capture par défaut le contexte courant (SynchronizationContext, etc.) et y exécute la continuation, celle-ci s’exécutant sur le pool de threads en l’absence de contexte à capturer ; et sur le fait 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 d’achèvement (et attend s’il n’y en a pas) ; sur le fait que le nombre d’octets transférés, la CompletionKey et le pointeur OVERLAPPED sont obtenus en résultat ; et sur le fait que, même si la valeur de retour est FALSE, un pointeur OVERLAPPED non NULL signifie qu’« on a retiré le paquet d’achèvement d’une opération d’E/S en échec », alors qu’OVERLAPPED NULL signifie seul qu’on n’a pas pu retirer de paquet (par exemple à cause d’une expiration de délai). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. Sur le fait que GetQueuedCompletionStatusEx peut retirer plusieurs paquets d’achèvement à la fois, et que le nombre d’entrées retirées est retourné. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. Sur le fait que FILE_SKIP_COMPLETION_PORT_ON_SUCCESS permet de choisir de ne pas mettre de paquet dans le port d’achèvement quand une 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'I/O Windows (partie 2) — E/S synchrones et asynchrones : ce que signifie vraiment OVERLAPPED
Deuxième volet d'une série qui explique en schémas l'E/S synchrone et l'E/S asynchrone (E/S overlapped) de Windows. Nous y détaillons le ...
Les profondeurs de l'E/S Windows (épisode 4) — Le gestionnaire de cache : quand votre WriteFile atteint-il vraiment le disque ?
Quatrième épisode d'une série qui explique en images le gestionnaire de cache de Windows. Cet article détaille le cache implémenté comme ...
Les profondeurs de l'I/O 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'I/O Windows (partie 6, dernière partie) — Pilotes filtres et minifiltres : pourquoi Procmon et les antivirus peuvent intercepter vos E/S
Dernier volet d'une série qui explique à l'aide de schémas les pilotes de filtre et les minifiltres de Windows. Nous y détaillons le Filt...
Les profondeurs de l'I/O Windows (partie 5) — Structure interne de NTFS : comprendre le système de fichiers à travers la MFT
Cinquième volet d'une série qui explique la structure interne de NTFS à l'aide de schémas. Nous y présentons la MFT et les enregistrement...
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 sous 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 permanent, 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.