Las profundidades de la E/S de Windows (parte 3) — Puertos de finalización de E/S (IOCP) y el grupo de subprocesos de .NET: el sótano de async/await

· Actualizado el: · · Windows, Win32, I/O, IOCP, Asincronía, Grupo de subprocesos, .NET, CSharp

Historial de revisiones (primera versión, publicada el 29 Jul 2026)
Primera publicación
Citar este artículo(DOI (archivo registrado): 10.5281/zenodo.22175292)

Los DOI siguientes remiten a versiones ya archivadas y pueden diferir del texto actual. Para citar el texto actual, utilice la URL de esta página.

Go Komura (2026). Las profundidades de la E/S de Windows (parte 3) — Puertos de finalización de E/S (IOCP) y el grupo de subprocesos de .NET: el sótano de async/await. KomuraSoft LLC. https://comcomponent.com/es/blog/windows-iocp-dotnet-threadpool/

DOI (archivo registrado)
10.5281/zenodo.22175292
DOI (última versión registrada)
10.5281/zenodo.22175293

¿Cómo hay que reunir las finalizaciones de E/S para tratar muchas conexiones con pocos subprocesos? Mientras se espera en un await, ¿quién trabaja, y en qué subproceso se reanuda el código posterior a la finalización?

En esta entrega se sigue el camino desde cómo un puerto de finalización de E/S (IOCP) reúne las notificaciones de finalización hasta cómo .NET recibe esas notificaciones y ejecuta la continuación de un await. Si se separa «recibir la finalización» de «decidir dónde se ejecuta la continuación», ConfigureAwait(false) y la inanición del grupo de subprocesos se entienden dentro del mismo flujo.

La vez anterior (parte 2) se vieron la emisión de la E/S asíncrona y las cuatro rutas por las que se recibe la finalización. El mecanismo que se toma aquí como el de recibir muchas E/S simultáneas con pocos subprocesos es IOCP.

Esta es la parte 3 de la serie «Las profundidades de la E/S de Windows». La estructura completa está al comienzo de la parte 1.

Empiece por lo que quiere saber

Si lee en orden, en el capítulo 2 se confirma el límite de un diseño que prepara un subproceso por conexión; en los capítulos 3 y 4, Win32; en el 5, la correspondencia con .NET. Si está en medio de una investigación, la guía siguiente lo lleva a la explicación que necesita.

Lo que quiere saber o el problema que tiene Dónde leer
Por qué miles de conexiones simultáneas no necesitan el mismo número de subprocesos Capítulo 2: Número de conexiones y de subprocesos, Capítulo 3: Cola de finalización y control de subprocesos
Quiere identificar qué E/S de qué identificador se ha completado Apartado 3.1: CompletionKey y OVERLAPPED
La obtención de la finalización ha devuelto FALSE y no sabe si puede hacer la limpieza Apartado 3.2: Distinguir una E/S fallida de un fallo de obtención
Quiere saber la relación entre FIFO, LIFO y el valor de concurrencia Apartados 3.3 y 3.4: Cómo se elige el subproceso en espera y el número ejecutable
Cómo tratar las notificaciones de fin, la obtención agrupada y la finalización síncrona Capítulo 4: API según el propósito
Quiere seguir un await ReadAsync desde la emisión hasta la reanudación Apartado 5.1: Un viaje de ida y vuelta de await
El alcance de «la espera de E/S no consume un subproceso» Apartado 5.2: El subproceso que espera y el que procesa
Aunque se ponga ConfigureAwait(false), la UI se congela o no se puede tocar Apartado 5.3: Separar el destino de la continuación del trabajo de CPU
Al subir la carga, todo el procesamiento asíncrono se vuelve lento Apartado 5.4: Espera síncrona e inanición del grupo de subprocesos

1. Primero, la conclusión

De qué se encarga IOCP

  • IOCP es un mecanismo que unifica la «cola de notificaciones de finalización» y el «control del número de subprocesos». Los paquetes de finalización se apilan en FIFO y los subprocesos trabajadores los retiran con GetQueuedCompletionStatus (capítulo 3).1
  • Los subprocesos se despiertan en LIFO. El subproceso «caliente» que acababa de trabajar recoge también el siguiente paquete, así que, mientras la cola esté llena, casi no hay cambio de contexto (apartado 3.3).1
  • El valor de concurrencia es el tope del «número de subprocesos ejecutables». El punto de partida recomendado es el número de CPU (con 0, el de procesadores). Si un subproceso en ejecución se bloquea, se despierta uno en espera para llenar el hueco (apartado 3.4).12

Lo esencial al elegir la API

  • El puerto también se puede usar para notificaciones propias. Con PostQueuedCompletionStatus se pueden apilar paquetes ajenos a la E/S, así que el encargo de trabajo a un trabajador o la orden de fin también fluyen por la misma cola (capítulo 4).3
  • En una implementación de servidor nueva, se recomienda la API del grupo de subprocesos de Windows (CreateThreadpoolIo) antes que el IOCP en crudo. Por dentro sigue siendo IOCP, pero se hace cargo de la gestión de subprocesos (capítulo 4).1

Qué distinguir en .NET y await

  • El grupo de subprocesos de .NET es una estructura de dos pisos —subprocesos trabajadores y de finalización de E/S— y el identificador de la E/S asíncrona se vincula al grupo (a su IOCP). En la espera de E/S de un await no hay subproceso; solo la continuación posterior a la finalización monta en un subproceso (capítulo 5).456
  • El destino de la continuación lo decide el «contexto capturado». Si se hace await en el subproceso de UI, la continuación vuelve a la UI; si no hay nada que capturar, continúa en el grupo de subprocesos (o en el subproceso que completó). ConfigureAwait(false) es la instrucción de dejar de capturar, no la garantía de ir al grupo de subprocesos (apartado 5.3).6

En el diagrama, una línea continua marca una relación que siempre se cumple y una línea discontinua una relación condicional (las condiciones están en la explicación de cada relación en la página de detalle). La lista completa de relaciones (32 en total, con evidencia y grado de certeza) y las definiciones de los conceptos principales están reunidas en la página de detalle del mapa de conocimiento (en japonés). Datos: JSON-LD / Turtle

2. Dónde se rompe el diseño de «a palos con subprocesos»

Primero se confirma el problema que IOCP intentó resolver. Un servidor ingenuo se puede escribir con «un subproceso por conexión». Leer con E/S síncrona, procesar y escribir: un diseño fácil de entender.

El problema aparece cuando aumentan las conexiones. Compare en la figura 1 el diseño que aumenta también los subprocesos al ritmo de las conexiones que esperan y el diseño que procesa solo el trabajo completado con unos pocos subprocesos.

Modelo IOCP (E/S asíncrona)Cola de finalización(se reúnen las notificaciones de todas las conexiones)Trabajador 1Trabajador 2Los trabajadores son unos pocos, del orden del número de CPUUn subproceso por conexión (E/S síncrona)Subproceso 1: espera el read de la conexión 1Subproceso 2: espera el read de la conexión 2Subproceso 3: espera el read de la conexión 3…los subprocesos aumentan al número de conexionesla mayoría solo duerme esperando E/S

Figura 1: A la izquierda, los subprocesos aumentan en proporción a las conexiones. A la derecha, solo se procesan los «sucesos que han ocurrido», con unos pocos subprocesos

Incluso un subproceso que solo espera consume recursos

Cada uno consume una pila (1 MB reservado por defecto) y un objeto del núcleo, y cuanto más hay, más se acumula la carga del planificador y de los cambios de contexto. Miles de conexiones = miles de subprocesos sale caro aunque la mayoría «solo duerma esperando a poder leer».

Aunque se aumenten los subprocesos ejecutables, las CPU no aumentan

Los subprocesos que pueden correr a la vez llegan, físicamente, hasta el número de CPU. Poner más subprocesos en estado ejecutable solo aumenta la pérdida por el cambio.

Hasta la parte 2 se vieron formas de recibir la notificación de finalización de E/S con un «evento» o un «APC». Sin embargo, el método de eventos se complica con el límite de 64 de WaitForMultipleObjects y el diseño de la espera, y APC queda atado al subproceso que emitió. IOCP es lo que se diseñó desde el principio para la forma muchas E/S × pocos subprocesos.1

3. El diseño de IOCP — la cola y el control de subprocesos, unificados

3.1. Las dos caras de CreateIoCompletionPort

CreateIoCompletionPort hace, a pesar del nombre, dos trabajos: crear un puerto nuevo y asociar un identificador a un puerto existente.2

Grupo de subprocesos trabajadoresPuerto de finalización de E/SIdentificadores asociados (los que se quiera)controlEspera con GetQueuedCompletionStatusEspera con GetQueuedCompletionStatusCola de paquetes de finalización (FIFO)paquete = bytes transferidos +CompletionKey + puntero OVERLAPPEDControl de concurrenciasubprocesos ejecutables ≤ topeArchivoSocketCanalización con nombre

Figura 2: Composición de IOCP. Las finalizaciones de muchos identificadores se reúnen en una cola, y también se controla el número de subprocesos del lado que las retira

El CompletionKey que se pasa al asociar es un valor libre para decirle al trabajador «esta finalización viene de este identificador». Lo habitual es meter el puntero del objeto de conexión.2

En el paquete de finalización, la operación se identifica combinando estas tres.27

Información que se recibe Qué identifica o confirma
CompletionKey De qué identificador o conexión viene la finalización
Puntero OVERLAPPED Qué operación de esa conexión se ha completado
Bytes transferidos Cuántos datos se han transferido

Es la correspondencia para recoger, al completar, el «albarán de la operación» visto en la parte 2.

El destino no se limita a un «archivo». Se puede asociar cualquier identificador que hable E/S superpuesta: un socket, una canalización con nombre, un mailslot, etc.1 El diseño de «todo se ve como un archivo» de la parte 1 también funciona aquí.

3.2. El viaje del paquete de finalización

Subproceso trabajadorCola del puerto (FIFO)Núcleo (finalización del IRP)Subproceso trabajadorCola del puerto (FIFO)Núcleo (finalización del IRP)Espera con GetQueuedCompletionStatusMira el paquete y hace el procesamiento de finalización(ejecutar la continuación, emitir la siguiente E/S, etc.)Si quedan paquetes en la cola,recibe el siguiente sin esperarApila un paquete de finalización(bytes / CompletionKey / OVERLAPPED)Despierta un subproceso en espera y se lo entregaTermina el procesamiento y vuelve a GetQueuedCompletionStatus

Figura 3: Los paquetes de finalización se apilan en FIFO y el trabajador gira en un bucle de retirar y procesar

Cuando se completa una E/S asíncrona, el paquete de finalización se apila en la cola del puerto en orden FIFO. El trabajador repite el bucle siguiente.17

  1. Recibe un paquete con GetQueuedCompletionStatus.
  2. Hace el procesamiento de finalización de la operación recibida.
  3. Al terminar, vuelve a llamar a GetQueuedCompletionStatus.

También existe GetQueuedCompletionStatusEx, que retira varios paquetes de una vez y, en E/S de alta frecuencia, reduce el número de llamadas.8

No salga del bucle solo porque vea FALSE

Aunque el valor de retorno de GetQueuedCompletionStatus sea FALSE, puede haberse retirado el paquete de finalización de una E/S fallida. Combínelo con el puntero OVERLAPPED para decidir.7

Valor de retorno OVERLAPPED Significado y tratamiento
FALSE no NULL Se obtuvo la finalización de una E/S fallida. Hace falta el tratamiento del error y la limpieza del albarán y del búfer
FALSE NULL No se pudo obtener el paquete. Tiempo de espera agotado, cierre del puerto, etc.

Una operación fallida también necesita limpieza. Si se procesa solo con if (!GetQueuedCompletionStatus(...)) break;, se pierde la E/S fallida y se produce una fuga. La gestión de la vida del albarán y del búfer vista en la parte 2 no es solo cosa de las finalizaciones normales.

Ver el orden de decisión en el bucle del trabajador

El esqueleto siguiente decide primero las dos vías de la tabla de arriba y, después, procesa el paquete de fin y la finalización habitual.

/* Esqueleto del bucle trabajador de 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) {
        /* No se ha podido retirar el paquete (el puerto se ha cerrado, etc.). Solo esta es una condición para salir */
        break;
    }
    if (!ok) {
        /* ov != NULL → se ha podido retirar el «paquete de finalización de una E/S fallida».
           La limpieza (tratamiento del error, liberación del albarán y del búfer) hace falta, así que se procesa sin salir */
        DWORD err = GetLastError();
        handle_failed_io(key, ov, err);
        continue;
    }
    if (key == SHUTDOWN_KEY) {
        /* Paquete de fin apilado con PostQueuedCompletionStatus (capítulo 4) */
        break;
    }
    handle_completed_io(key, ov, bytes);   /* Procesamiento habitual de finalización. Mantenerlo corto (apartado 3.4) */
}

El punto es no liquidar ok == FALSE en una sola rama, sino partirlo en dos según si ov es NULL. Si se pone un tiempo de espera (distinto de INFINITE), la decisión es la misma: el tiempo agotado aparece como ok == FALSE y ov == NULL.

Además, cuando un subproceso llama por primera vez a GetQueuedCompletionStatus, ese subproceso queda asociado a ese puerto (un subproceso solo puede estar asociado a un puerto a la vez).1 Es más exacto recordarlo con la imagen de «un equipo de trabajadores exclusivo se adhiere al puerto».

3.3. Los subprocesos se despiertan en LIFO

Aquí separe el orden en que los paquetes entran en la cola y el orden en que se despierta a un subproceso en espera.1

Objeto Orden En qué fijarse
Paquete de finalización FIFO El orden en que se apila la notificación de finalización en la cola
Subproceso en espera LIFO Se despierta primero el último subproceso que entró en espera

Como el orden de los paquetes y el de los subprocesos es distinto, el movimiento es que el subproceso que acababa de trabajar recoge también el siguiente paquete.

Subprocesos en espera (pila LIFO)P1, P2 y P3, si hay hueco,van primero al subproceso Asolo cuando A está ocupadocasi nunca se despiertaSubproceso A (acababa de ejecutarse, caliente)Subproceso B (lleva un rato dormido)Subproceso C (lleva mucho dormido)Cola: P1 → P2 → P3 (FIFO)

Figura 4: Liberación LIFO. Cuanto más ocupado está, más sigue girando el mismo subproceso, y los ociosos pueden seguir dormidos

Este diseño tiene dos beneficios.

  • No hay cambio de contexto. Mientras queden paquetes en la cola, el subproceso que ha terminado de procesar llama a GetQueuedCompletionStatus y recibe el siguiente paquete sin esperar, y sigue corriendo. La documentación deja claro, en un escenario de valor de concurrencia 1, que «no se produce cambio de subproceso».1
  • La caché se puede usar mientras sigue caliente. Como sigue girando el mismo subproceso, es más probable que el estado de la pila y de la planificación quede en la caché de la CPU. Los subprocesos dormidos se mantienen baratos como reserva para el pico de carga.

3.4. El valor de concurrencia — contar lo «ejecutable»

NumberOfConcurrentThreads al crear el puerto es el valor de concurrencia. Lo que se cuenta son los subprocesos ejecutables (runnable) asociados a ese puerto. Mientras se ha llegado al tope, un subproceso adicional no puede recibir un paquete.1

Si se pasa 0, se usa el número de procesadores del sistema. Eso es también lo que la documentación cita como el mejor máximo en conjunto, el número de CPU, así que se toma como punto de partida.21

No es el tope del número total, incluidos los subprocesos en espera. Mire en la figura 5 el caso en que alguien entra en espera.

menortopeLlega un paquete a la cola¿El número de subprocesos ejecutables esmenor que el valor de concurrencia?Despertar un subproceso en espera y hacerlo procesarNo despertar a nadie y dejarlo en la cola(el subproceso en ejecución vendrá a por él)Un subproceso en ejecución haentrado en espera por otro motivoEn la medida en que ha bajado el número ejecutable,despertar un subproceso en espera y reponer

Figura 5: Control de concurrencia. El tope es el «número ejecutable», así que si alguien se bloquea se repone automáticamente

Un trabajador que entra en espera se cubre con otro subproceso en espera

Si un trabajador en ejecución entra en alguna espera (un bloqueo, un error de página, una E/S síncrona escrita sin querer), baja el número ejecutable, así que el sistema despierta un subproceso en espera y llena el hueco.1 Por eso la receta no es crear «exactamente tantas CPU» de trabajadores, sino dejar en espera más subprocesos que el valor de concurrencia. Si el procesamiento mezcla cálculos largos, también se puede subir el propio valor de concurrencia; la postura de la documentación es, al final, ajustarlo con perfilado.1

También hay un rebasamiento temporal del tope, así que el procesamiento de finalización se mantiene corto

Sin embargo, reponer no es una panacea. Si el subproceso bloqueado se despierta después, en ese instante el número ejecutable supera el tope (la documentación también menciona este rebasamiento).1 El gran principio es mantener corto el procesamiento de finalización, y eso también vale, con la misma forma, en .NET en el apartado 5.4.

4. Caja de herramientas — las API que sostienen el puerto

4.1. Hacer fluir por la misma cola el encargo de trabajo o la orden de fin

PostQueuedCompletionStatus — se puede apilar en la cola un paquete de finalización propio, sin emitir E/S.3 Encargo de trabajo a un trabajador, orden de apagado (apilar tantos paquetes de fin —llamados a veces paquetes «píldora venenosa»— como trabajadores hay), notificación desde otro subproceso: poder procesar la finalización de E/S y un mensaje propio en la misma cola y el mismo bucle simplifica mucho el diseño.

4.2. Recibir de golpe las finalizaciones de E/S de alta frecuencia

GetQueuedCompletionStatusEx — retira varios paquetes de finalización de una vez. Es útil en E/S de alta frecuencia, donde el coste de una llamada por paquete empieza a notarse.8

4.3. Omitir la notificación cuando se completa de forma síncrona

SetFileCompletionNotificationModes — en el caso de «se emitió de forma asíncrona y aun así se completó de forma síncrona» visto en el capítulo 5 de la parte 2, se puede elegir un modo que no apila paquete en el puerto (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). El resultado de la finalización síncrona ya se conoce en el acto, así que volver a pasar por la cola es un desperdicio: esa es la aceleración.9

4.4. Dejar la creación y la gestión de subprocesos

API del grupo de subprocesos de Windows — CreateThreadpoolIo / StartThreadpoolIo usan IOCP por dentro y, al mismo tiempo, se hacen cargo de crear y gestionar subprocesos. Microsoft recomienda, en una aplicación de servidor nueva, considerar primero esta vía y usar el IOCP en crudo solo cuando se quiere controlar de forma explícita el valor de concurrencia o la gestión de subprocesos.1 Y el grupo de subprocesos de .NET es, precisamente, esa «automatización de IOCP + gestión de subprocesos» implementada como runtime de .NET.

4.5. Tres gestiones de vida que no cambian aunque se elija otra API

  • No bloquear durante mucho tiempo dentro de un trabajador. La reposición del apartado 3.4 solo mitiga la degradación.
  • Identificar por separado la unidad del identificador y la de la operación. CompletionKey es por identificador; OVERLAPPED, por operación. El «albarán» de la parte 2 no se debe liberar hasta la finalización.
  • No cerrar el identificador mientras quede E/S incompleta. El comportamiento de cleanup (capítulo 6 de la parte 1) y la forma de cancelar (capítulo 6 de la parte 2) se aplican tal cual.

5. El grupo de subprocesos de .NET — dos pisos construidos sobre IOCP

A partir de aquí se hace corresponder la notificación de finalización de Win32 con el código de .NET. El orden de lectura es el subproceso que recibe la finalización → un viaje de ida y vuelta de await → el tiempo de espera → el lugar donde se ejecuta la continuación.

El grupo de subprocesos de .NET ofrece subprocesos con estos dos papeles.4

Tipo El papel que se ve en este artículo
Subproceso trabajador Ejecuta Task.Run y las continuaciones
Subproceso de finalización de E/S Recibe la finalización de la E/S asíncrona

ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) también devuelve estos dos como números distintos. A continuación se sigue distinguiendo el «lado que recibe la finalización» del «lado que ejecuta el trabajo».4

En Windows, el grupo de subprocesos tiene su propio puerto de finalización de E/S. La API de bajo nivel que asocia un identificador del sistema operativo a este puerto es ThreadPoolBoundHandle.BindHandle. La E/S asíncrona sobre el identificador vinculado se trata combinándola con NativeOverlapped. Es el mecanismo del lado de .NET que corresponde al OVERLAPPED de la parte 2.5

El antiguo ThreadPool.BindHandle sigue con el mismo papel, pero si se escribe de nuevo, es ThreadPoolBoundHandle.BindHandle. Cuando FileStream o Socket abren un identificador en modo asíncrono, por dentro se hace este tipo de vinculación.5

La correspondencia con Win32, separada en emisión y finalización, queda así.

  • El «identificador en modo asíncrono + OVERLAPPED» de la parte 2 es el mecanismo de emisión
  • El IOCP de este artículo es el mecanismo de recibir la finalización
  • El subproceso de finalización de E/S del grupo de subprocesos de .NET es el equipo de trabajadores que gira el bucle de GetQueuedCompletionStatus

Con esa correspondencia, el dibujo de Win32 se convierte tal cual en el de .NET.

5.1. Un viaje de ida y vuelta de await ReadAsync, versión completa

El interior de la caja que en la figura 7 de la parte 2 se llamó «E/S asíncrona de verdad» se sigue ahora hasta la continuación posterior a la finalización. En la figura 6 separe el subproceso en el momento de emitir, el que recibe la notificación de finalización y el lugar donde se ejecuta el código de continuación.

Lugar de ejecución de la continuaciónSubproceso de finalización de E/SIOCP del grupo de subprocesosNúcleo(emisión del IRP hasta la finalización)Subproceso que llama(el de UI, etc.)Lugar de ejecución de la continuaciónSubproceso de finalización de E/SIOCP del grupo de subprocesosNúcleo(emisión del IRP hasta la finalización)Subproceso que llama(el de UI, etc.)await registra la continuación en el Task incompletoy suelta el subproceso (si es UI, al siguiente mensaje)El dispositivo está trabajandoen este intervalo no hay en ningún sitio un subproceso que espereFija el resultado (bytes, estado),completa el Task y programa la continuaciónCorre el código de continuación del awaitReadAsync emite una lectura asíncrona(con el equivalente a OVERLAPPED)ERROR_IO_PENDING (vuelve enseguida)Apila el paquete de finalizaciónDespierta uno en LIFO y se lo entregaLo lanza al contexto capturado(al subproceso de UI / si no hay, se ejecuta en el grupo)

Figura 6: El conjunto de un viaje de ida y vuelta de await. El subproceso trabaja solo en la «emisión» y «después de completar»; el tiempo de espera es cero subprocesos

5.2. El significado exacto de «la espera de E/S no consume un subproceso»

No se coloca un subproceso dedicado solo para el tiempo de espera

Lo que se quiere subrayar en esta figura es que entre la emisión y la finalización no existe, ni en modo usuario ni en el núcleo, un subproceso dedicado solo a esperar esa finalización. La explicación de async de Microsoft (async in depth) también baja, en un Task acotado por E/S, hasta el controlador de dispositivo y la interrupción.6

Por otro lado, hay momentos en que, dentro del núcleo, el controlador deja parte del procesamiento a un subproceso trabajador del sistema. Eso es trabajo para hacer avanzar la petición, distinto de un subproceso para seguir esperando bloqueando la finalización. «No consume un subproceso» es una explicación sobre este tiempo de espera.

Reformulado con lo acumulado desde la parte 1: el IRP permanece en la pila de dispositivos no como un subproceso, sino como una estructura de datos (parte 1); la emisión vuelve enseguida con ERROR_IO_PENDING (parte 2); la finalización llega como una cadena de eventos interrupción → paquete de finalización (este artículo). El diseño no necesita el recurso caro que es un subproceso para mantener el estado de «esperar».

Distinguirlo del procesamiento de CPU y de la asincronía aparente

Por eso, una aplicación que usa async/await correctamente puede mantener el estado de «10.000 E/S volando a la vez» con una docena de subprocesos. Dicho al revés, esta propiedad es solo de los Task acotados por E/S. Un procesamiento de CPU envuelto en Task.Run ocupa, como es natural, un subproceso trabajador, y la «asincronía aparente» del capítulo 7 de la parte 2 también duerme un subproceso por detrás.

5.3. Dónde corre la continuación

La última flecha de la figura 6 es «después de recibir que se ha completado, dónde se ejecuta la continuación». Se piensa por separado si se vuelve al contexto y adónde se saca el procesamiento que usa CPU.6

sínosí (subproceso de UI de WPF/WinForms, etc.)no (consola / ASP.NET Core, etc.)El Task se ha completado y se quiere ejecutar la continuaciónEn el momento del await, ¿se había capturadoun SynchronizationContext oun TaskScheduler no predeterminado?¿Se había puestoConfigureAwait(false)?Se lanza al destino capturadoejemplo: se ejecuta en el bucle de mensajes del subproceso de UIo sobre ese TaskSchedulerSin obligación de volver a un lugar concretosigue de forma síncrona en el subproceso que completóo se ejecuta en un subproceso del grupo

Figura 7: El destino de la continuación. Que «después del await se pueda tocar la UI tal cual» es porque se lanza de vuelta al contexto capturado

El contexto capturado y el caso en que se continúa de forma síncrona

  • Si se hace await en el subproceso de UI de WPF/WinForms, se captura el SynchronizationContext y la continuación vuelve al subproceso de UI. Por eso tocar un control justo después del await no es una violación de subproceso. El lado práctico de este diseño se trató en «WPF/WinForms async y el subproceso de UI, en una sola hoja».
  • Lo que se captura no es solo el SynchronizationContext: si se hace await sobre un TaskScheduler no predeterminado, ese planificador también es destino. En un lugar donde no hay ninguno (consola, ASP.NET Core, sobre el grupo de subprocesos) no hay obligación de volver a un sitio concreto, así que la continuación corre en el grupo o continúa de forma síncrona, tal cual, en el subproceso que completó el Task.
  • ConfigureAwait(false) es la declaración explícita de «no hace falta volver», no la garantía de «siempre se pasa al grupo de subprocesos». Si se hace await de un Task que ya está completado (la finalización síncrona vista en la parte 2 también entra aquí), no se produce espera y se continúa tal cual en el subproceso actual. Sobre cómo usarlo en código de biblioteca, consulte «Tabla de decisiones prácticas de C# async/await».

Mal ejemplo: creer que con ConfigureAwait(false) uno se ha movido

El último punto se comprueba con código. El ejemplo siguiente está escrito creyendo que ConfigureAwait(false) es «la instrucción de pasar al grupo de subprocesos».

// Mal ejemplo: el malentendido de «como puse ConfigureAwait(false), a partir de aquí corre en el grupo de subprocesos»
private async void OnLoadClick(object sender, EventArgs e)
{
    string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);

    // Expectativa: esto es el grupo de subprocesos, así que la UI no se congela
    // Realidad: si el Task ya estaba completado en el momento del await, no hay espera
    //           y se continúa en el subproceso de UI → este procesamiento pesado congela la UI
    var rows = ParseHeavy(csv);

    // Además, si se completa de forma asíncrona, la continuación no es el subproceso de UI
    resultLabel.Text = $"{rows.Count} filas";   // → puede convertirse en una excepción de violación de subproceso
}

Lo que dice ConfigureAwait(false) es solo «no hace falta volver al contexto capturado». No especifica «dónde corre», así que puede continuar en el subproceso de UI o continuar en un subproceso de finalización de E/S o del grupo. Que ambas sean posibles es la razón de que este código esté roto.

Buen ejemplo: indicar por separado el retorno a la UI y el destino del procesamiento pesado

Se separan el código de UI y el de biblioteca, y se escribe la intención por separado. El await para volver a la UI y el Task.Run para sacar el procesamiento de CPU al grupo de subprocesos son papeles distintos.

// Buen ejemplo: indicar por separado «volver / no volver a la UI» y «dónde hacer el procesamiento pesado»
private async void OnLoadClick(object sender, EventArgs e)
{
    // En el código de UI se deja capturar (la continuación vuelve al subproceso de UI)
    string csv = await File.ReadAllTextAsync(path);

    // Si se quiere sacar al grupo de subprocesos un procesamiento que usa CPU, se declara con Task.Run
    var rows = await Task.Run(() => ParseHeavy(csv));

    // Aquí es con certeza el subproceso de UI. Se puede tocar el control con seguridad
    resultLabel.Text = $"{rows.Count} filas";
}

// En el lado de la biblioteca (código sin UI), al revés, se declara que «no hace falta volver»
public async Task<string> ReadConfigAsync(string path)
{
    string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
    return text.Trim();   // no depende del contexto del llamador
}

La decisión se parte en estas tres.

Propósito La elección en este código
Tocar la UI después del await En el código de UI, dejar capturar el contexto
Sacar el procesamiento pesado de CPU al grupo de subprocesos Declararlo con Task.Run
Dentro de la biblioteca, no hace falta volver al contexto del llamador Dejar de capturar con ConfigureAwait(false)

ConfigureAwait(false) no especifica el subproceso de ejecución. Trátelo como una herramienta del lado de la biblioteca que funciona sin depender del contexto del llamador, separado de la colocación del procesamiento de UI y de CPU.

5.4. La verdadera identidad del atasco — inanición del grupo de subprocesos

Una espera síncrona tapa el subproceso que ejecuta la continuación

Si se espera de forma síncrona dentro de una continuación o de un trabajador, ese subproceso queda tapado. E/S síncrona, Task.Result/Wait(), una espera larga de bloqueo: ese es el patrón.

La reposición del propio IOCP (apartado 3.4) actúa de inmediato mientras haya subprocesos de reserva en espera. Pero más allá de que se agoten las reservas está la zona en la que el grupo de subprocesos solo inyecta subprocesos nuevos con lentitud.

Si en el instante de carga aparece la inanición (starvation) de «quiero ejecutar la continuación y no hay subproceso que la ejecute», toda la aplicación se vuelve pesada.

Investigar juntos el número libre y el punto real de bloqueo

Hay dos entradas para la investigación.

  1. Con ThreadPool.GetAvailableThreads, mirar el hueco de trabajadores y de subprocesos de finalización de E/S.4
  2. Con un seguimiento de eventos, captar la realidad del grupo de subprocesos y de los bloqueos.

El procedimiento del seguimiento está reunido en «Identificar lo «lento» con PerfView y dotnet-trace».

El principio de prevención es llevar el camino async hasta el final como async, sin mezclar sync-over-async. Aunque se suelte el subproceso durante la espera de E/S, si se espera de forma síncrona dentro de la continuación, ahí se vuelve a tapar un subproceso.

6. Resumen

  • IOCP es un mecanismo que unifica la cola de finalización (FIFO) y el control del número de subprocesos. Reúne las finalizaciones de muchos identificadores en un puerto y las procesa con el bucle de GetQueuedCompletionStatus.17
  • Los subprocesos se liberan en LIFO, así que cuanto más ocupado está, más sigue girando el mismo subproceso y se minimizan los cambios de contexto y los fallos de caché.1
  • El valor de concurrencia es el tope del «número de subprocesos ejecutables», y el punto de partida es el número de CPU (especificar 0). Si un subproceso en ejecución se bloquea, se repone con uno en espera, pero el gran principio es mantener corto el procesamiento de finalización.12
  • Con PostQueuedCompletionStatus también se pueden hacer fluir paquetes propios. En una implementación nueva, el primer candidato es la API del grupo de subprocesos de Windows (por dentro, IOCP).31
  • El grupo de subprocesos de .NET es una estructura de dos pisos, subprocesos trabajadores + de finalización de E/S, y el identificador asíncrono se vincula al IOCP del grupo. En la espera de E/S de un await no hay subproceso; solo la continuación posterior a la finalización monta en un subproceso.456
  • El destino de la continuación depende del contexto capturado (volver al subproceso de UI / continuar en el grupo). ConfigureAwait(false) es la instrucción de dejar de capturar.6
  • La verdadera identidad del atasco es, casi siempre, la inanición del grupo de subprocesos por colar espera síncrona. Lleve el camino async hasta el final como async.

Sigue la parte 4, «El administrador de caché — ¿cuándo llega su WriteFile al disco?». En la parte 2 se escribió que «si está en la caché, se completa de forma síncrona», y en esta entrega también ha asomado varias veces la sombra de la caché. La siguiente se enfrenta de frente a esa caché en sí: la escritura diferida, la lectura anticipada, FILE_FLAG_NO_BUFFERING y las condiciones en las que «los datos que se suponía escritos desaparecen con un corte de energía».

Artículos relacionados

Ámbitos de consulta relacionados

En KomuraSoft LLC nos ocupamos del diseño de aplicaciones y servidores de Windows que tratan muchas conexiones y E/S simultáneas, y de la investigación de causas de problemas de rendimiento como «el grupo de subprocesos se atasca» o «se ha hecho asíncrono y, aun así, no va más rápido».

Referencias

  1. Microsoft Learn, I/O Completion Ports. Sobre que un puerto de finalización de E/S ofrece un modelo de subprocesos eficiente para procesar numerosas peticiones de E/S asíncronas en un sistema multiprocesador; que, al completarse una E/S asíncrona, el paquete de finalización se apila en la cola del puerto en orden FIFO; que el destino no se limita a un archivo en disco y puede ser cualquier identificador que admita E/S superpuesta, como un socket, una canalización con nombre o un mailslot; que los subprocesos que esperan en el puerto se liberan en orden LIFO y que, con valor de concurrencia 1 y la cola llena, no se produce cambio de subproceso; que, cuando un subproceso llama por primera vez a GetQueuedCompletionStatus, queda asociado a ese puerto y no puede estar asociado a más de un puerto a la vez; que el valor de concurrencia limita el número de subprocesos ejecutables y que el mejor máximo en conjunto es el número de CPU; que, si un subproceso en ejecución entra en espera por otro motivo, un subproceso en espera puede procesar un paquete de finalización (y que, al despertar el subproceso bloqueado, se puede rebasar el tope de forma temporal); y que, en una aplicación de servidor nueva, hay que considerar primero la API del grupo de subprocesos de Windows (CreateThreadpoolIo, etc.; usa IOCP por dentro). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20

  2. Microsoft Learn, CreateIoCompletionPort function. Sobre que CreateIoCompletionPort hace tanto la creación de un puerto de finalización de E/S nuevo como la asociación de un identificador a un puerto existente; que al asociar se puede especificar un CompletionKey (un valor definido por el usuario) que se incluye en el paquete de finalización; y que NumberOfConcurrentThreads es el tope del número de subprocesos que pueden procesar paquetes de finalización en paralelo y que, si se especifica 0, se usa el número de procesadores del sistema. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  3. Microsoft Learn, PostQueuedCompletionStatus function. Sobre que PostQueuedCompletionStatus puede apilar en la cola de un puerto de finalización de E/S un paquete de finalización propio de la aplicación, sin iniciar una E/S asíncrona, y que con ello el puerto sirve no solo para recibir finalizaciones de E/S, sino también como punto de entrada de comunicación desde otros subprocesos del proceso. ↩ ↩2 ↩3

  4. Microsoft Learn, The managed thread pool. Sobre que el grupo de subprocesos de .NET ofrece subprocesos trabajadores y subprocesos para la finalización de E/S asíncrona; que con ThreadPool.GetAvailableThreads se pueden obtener por separado el número disponible de subprocesos trabajadores y de finalización de E/S; y que no se debe bloquear durante mucho tiempo un subproceso del grupo. ↩ ↩2 ↩3 ↩4 ↩5

  5. Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. Sobre que ThreadPoolBoundHandle.BindHandle devuelve un ThreadPoolBoundHandle que vincula un identificador del sistema operativo al grupo de subprocesos del sistema (a su puerto de finalización de E/S); que la E/S asíncrona de bajo nivel sobre el identificador vinculado se hace combinándola con NativeOverlapped; y que el procesamiento de finalización de la E/S asíncrona pasa a realizarlo el grupo de subprocesos. ↩ ↩2 ↩3 ↩4

  6. Microsoft Learn, Async in depth (.NET). Sobre que, en un Task acotado por E/S, una vez que la llamada ha pasado al sistema operativo no existe un subproceso dedicado a esperar esa finalización (el llamado «There is no thread»); que la finalización se notifica a través del controlador de dispositivo y de una interrupción, y se ejecuta la continuación registrada; que await captura por defecto el contexto actual (SynchronizationContext, etc.) y ejecuta la continuación ahí, y que, si no hay un contexto que capturar, se ejecuta en el grupo de subprocesos; y que con ConfigureAwait(false) se puede desactivar esa captura. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  7. Microsoft Learn, GetQueuedCompletionStatus function. Sobre que GetQueuedCompletionStatus retira un paquete de finalización de la cola del puerto (y espera si no hay); que, como resultado, se obtienen los bytes transferidos, el CompletionKey y el puntero OVERLAPPED; y que, aunque el valor de retorno sea FALSE, si el puntero OVERLAPPED no es NULL significa que «se retiró el paquete de finalización de una operación de E/S fallida», y solo si OVERLAPPED es NULL (por tiempo de espera, etc.) significa que no se pudo retirar el paquete. ↩ ↩2 ↩3 ↩4

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. Sobre que GetQueuedCompletionStatusEx puede retirar varios paquetes de finalización de una vez y que se devuelve el número de entradas retiradas. ↩ ↩2

  9. Microsoft Learn, SetFileCompletionNotificationModes function. Sobre que, con FILE_SKIP_COMPLETION_PORT_ON_SUCCESS, se puede elegir el comportamiento de no apilar un paquete en el puerto de finalización cuando la E/S tiene éxito de inmediato y el resultado queda fijado en el acto. ↩

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Qué es un puerto de finalización de E/S (IOCP)?
Es un mecanismo del núcleo de Windows que reúne en una sola cola las notificaciones de finalización de numerosas operaciones de E/S asíncronas y, al mismo tiempo, controla cuántos subprocesos pueden procesarla en paralelo. Al crear un puerto con CreateIoCompletionPort y asociarle identificadores como archivos o sockets, cada vez que una E/S asíncrona se completa se coloca un paquete de finalización en la cola FIFO del puerto. Los subprocesos trabajadores retiran los paquetes de la cola con GetQueuedCompletionStatus y los procesan. El punto clave es que no se trata solo de una cola de notificaciones: también actúa como un mecanismo de planificación que mantiene el número de subprocesos ejecutables por debajo del valor de concurrencia. Permite procesar de forma eficiente numerosas E/S simultáneas con un número reducido de subprocesos, y constituye la base tanto de las implementaciones de servidor de Windows como del grupo de subprocesos de .NET.
¿Qué valor debe tener la concurrencia de IOCP (el número de ejecuciones simultáneas)?
La documentación de Microsoft indica que, en general, el mejor valor máximo es el número de CPU del equipo. Si se pasa 0 en NumberOfConcurrentThreads de CreateIoCompletionPort, se usa el número de procesadores del sistema, así que, ante la duda, 0 es un buen punto de partida. Este valor es el límite del «número de subprocesos en estado ejecutable», no el límite del número de subprocesos en espera. Cuando un subproceso en ejecución entra en espera por algún motivo, el sistema despierta a otro subproceso en espera para llenar el hueco, de modo que si el procesamiento mezcla cálculos largos o bloqueos, también se puede optar por un valor de concurrencia mayor para aumentar la cantidad de paquetes procesados a la vez. En última instancia, se recomienda ajustarlo combinándolo con perfilado (profiling).
¿Por qué se puede afirmar que la espera de E/S de async/await no consume un subproceso?
Porque, entre la emisión de la E/S y su finalización, no existe en ningún lugar un subproceso dedicado a atender esa operación. Como vimos en las partes 1 y 2 de la serie, la solicitud emitida fluye por la pila de dispositivos como un IRP, y la llamada retorna de inmediato con ERROR_IO_PENDING. En ese punto, await se limita a registrar una continuación en el Task todavía incompleto y a liberar el subproceso. Mientras el dispositivo trabaja como hardware, no hay ningún «subproceso que solo espera» ni en modo usuario ni en el núcleo. Cuando se completa, el paquete de finalización se coloca en el IOCP del grupo de subprocesos, y solo entonces un subproceso de finalización de E/S se activa brevemente para programar la continuación registrada. En otras palabras, el subproceso se usa únicamente en el instante de la emisión y en el procesamiento posterior a la finalización; el tiempo de espera en sí transcurre con cero subprocesos.
¿En qué subproceso se ejecuta la continuación de un await?
Por defecto, se captura el SynchronizationContext (o el TaskScheduler) vigente en el momento del await, y la continuación se devuelve a ese contexto. Si se hace await en el subproceso de UI de WPF o WinForms, la continuación se ejecuta en el subproceso de UI, y por eso se puede tocar un control directamente después del await. Cuando no hay ningún contexto que capturar (una aplicación de consola, ASP.NET Core, código que ya corre en el grupo de subprocesos, etc.), la continuación se ejecuta en un subproceso del grupo o continúa directamente en el subproceso que completó el Task. Añadir ConfigureAwait(false) detiene esa captura, pero esto no es una garantía de que «siempre se pasará al grupo de subprocesos»: es una instrucción de que «no hace falta volver a un lugar concreto». Si se hace await sobre un Task que ya está completado, no se produce ninguna espera y la ejecución continúa de forma síncrona en el subproceso actual. ConfigureAwait(false) se recomienda en código de biblioteca para evitar idas y vueltas innecesarias al subproceso de UI, y para cortar de raíz la dependencia de un contexto concreto y el germen de los interbloqueos.
¿Qué ocurre si se bloquea durante mucho tiempo un subproceso trabajador de IOCP o un subproceso de finalización de E/S de .NET?
No se rompe de inmediato, pero el rendimiento se degrada porque se sale de lo que el mecanismo da por supuesto. Cuando un subproceso en ejecución entra en espera, IOCP repone el hueco despertando un subproceso en espera, pero cada reposición infla el número de ejecuciones simultáneas y aumenta los cambios de contexto. Si el bloqueo se vuelve habitual, los paquetes se acumulan en la cola y se retrasa el procesamiento de finalización en su conjunto. En .NET ocurre lo mismo: esperar de forma síncrona (E/S síncrona, Task.Result, etc.) dentro de un subproceso de finalización de E/S o de una continuación provoca inanición (starvation) del grupo de subprocesos. La regla es mantener breves el procesamiento de finalización y las continuaciones, y extraer aparte el trabajo pesado. Con ThreadPool.GetAvailableThreads se puede observar la disponibilidad de subprocesos trabajadores y de finalización de E/S, lo que resulta útil para investigar atascos.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog