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

· Mis à jour le: · · Windows, Win32, E/S, IOCP, Asynchrone, Pool de threads, .NET, CSharp

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. PostQueuedCompletionStatus permet 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 await sur 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é.

Modèle IOCP (E/S asynchrone)File d'achèvement(les notifications d'achèvement de toutes les connexions s'y rassemblent)Worker 1Worker 2Les workers sont peu nombreux, de l'ordre du nombre de CPUUn thread par connexion (E/S synchrone)Thread 1 : en attente du read de la connexion 1Thread 2 : en attente du read de la connexion 2Thread 3 : en attente du read de la connexion 3… autant de threads que de connexionsla plupart ne font que dormir en attente d'E/S

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

Groupe de threads de travailPort d'achèvement d'E/SHandles associés (autant qu'on veut)contrôleAttend avec GetQueuedCompletionStatusAttend avec GetQueuedCompletionStatusFile de paquets d'achèvement (FIFO)paquet = octets transférés +CompletionKey + pointeur OVERLAPPEDContrôle de concurrencenombre de threads exécutables ≦ plafondFichierSocketTube nommé

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

Thread de travailFile du port (FIFO)Noyau (achèvement d'IRP)Thread de travailFile du port (FIFO)Noyau (achèvement d'IRP)En attente sur GetQueuedCompletionStatusTraite l'achèvement d'après le paquet(exécution de la continuation, émission de l'E/S suivante, etc.)S'il reste des paquets dans la file,le suivant est reçu sans attendreEmpile un paquet d'achèvement(octets / CompletionKey / OVERLAPPED)Réveille un thread en attente et le lui remetTraitement fini, GetQueuedCompletionStatus à nouveau

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

  1. Recevoir un paquet avec GetQueuedCompletionStatus.
  2. Traiter l’achèvement de l’opération reçue.
  3. 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.

Threads en attente (pile LIFO)P1, P2 et P3, s'il est libre,vont d'abord au thread Aseulement quand A est occupése réveille rarementThread A (exécuté à l'instant, encore chaud)Thread B (dort depuis un moment)Thread C (dort depuis longtemps)File : P1 → P2 → P3 (FIFO)

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 GetQueuedCompletionStatus et 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.

inférieurplafondUn paquet arrive dans la fileLe nombre de threads exécutables est-ilinférieur à la valeur de concurrenceRéveiller un thread en attente pour le faire traiterN'en réveiller aucun, laisser le paquet dans la file(un thread en cours d'exécution viendra le chercher)Un thread en cours d'exécutionest entré en attente pour une autre raisonRéveiller un thread en attente pour comblerexactement la baisse du nombre exécutable

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, OVERLAPPED par 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.

Lieu d'exécution de la continuationThread d'achèvement d'E/SIOCP du pool de threadsNoyau(émission d'IRP jusqu'à l'achèvement)Thread appelant(thread UI, etc.)Lieu d'exécution de la continuationThread d'achèvement d'E/SIOCP du pool de threadsNoyau(émission d'IRP jusqu'à l'achèvement)Thread appelant(thread UI, etc.)await enregistre une continuation sur le Task non achevéet lâche le thread (UI : vers le traitement du message suivant)Le périphérique est au travailpendant ce temps, aucun thread n'attend nulle partFixe le résultat (octets, état),achève le Task, planifie la continuationLe code de la suite de l'await s'exécuteReadAsync émet une lecture asynchrone(en y joignant l'équivalent d'OVERLAPPED)ERROR_IO_PENDING (retour immédiat)Empile un paquet d'achèvementRéveille un thread en LIFO et le lui remetEnvoie vers le contexte capturé(vers le thread UI / sinon exécution sur le pool de threads)

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

ouinonoui (thread UI WPF/WinForms, etc.)non (console / ASP.NET Core, etc.)Le Task s'est achevé, on veut exécuter la continuationAu moment de l'await, avait-on capturéun SynchronizationContext ouun TaskScheduler non par défautAvait-on misConfigureAwait(false)Renvoyer vers la cible capturéeex. : s'exécuter sur la boucle de messages du thread UIou sur ce TaskSchedulerAucune obligation de revenir à un endroit préciscontinuer de façon synchrone sur le thread qui a achevé,ou s'exécuter sur un thread du pool

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 await sur le thread UI de WPF/WinForms capture le SynchronizationContext, et la suite revient sur le thread UI. C’est pourquoi toucher un contrôle juste après l’await n’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 : un await sur un TaskScheduler non 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.

  1. Voir avec ThreadPool.GetAvailableThreads les places libres des workers et des threads d’achèvement d’E/S.4
  2. 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
  • PostQueuedCompletionStatus permet 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

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 ».

Références

  1. 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

  2. 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

  3. 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

  4. 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

  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

  6. 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

  7. 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

  8. 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

  9. 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 récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

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.

Retour au blog