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
PostQueuedCompletionStatusse 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
awaitno 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
awaiten 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.
flowchart TB
subgraph A["Un subproceso por conexión (E/S síncrona)"]
T1["Subproceso 1: espera el read de la conexión 1"]
T2["Subproceso 2: espera el read de la conexión 2"]
T3["Subproceso 3: espera el read de la conexión 3"]
TN["…los subprocesos aumentan al número de conexiones<br/>la mayoría solo duerme esperando E/S"]
end
subgraph B["Modelo IOCP (E/S asíncrona)"]
Q["Cola de finalización<br/>(se reúnen las notificaciones de todas las conexiones)"]
W1["Trabajador 1"]
W2["Trabajador 2"]
WN["Los trabajadores son unos pocos, del orden del número de CPU"]
Q --> W1
Q --> W2
Q --> WN
end
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
flowchart LR
subgraph SRC["Identificadores asociados (los que se quiera)"]
H1["Archivo"]
H2["Socket"]
H3["Canalización con nombre"]
end
subgraph PORT["Puerto de finalización de E/S"]
Q["Cola de paquetes de finalización (FIFO)<br/>paquete = bytes transferidos +<br/>CompletionKey + puntero OVERLAPPED"]
C["Control de concurrencia<br/>subprocesos ejecutables ≤ tope"]
end
subgraph W["Grupo de subprocesos trabajadores"]
G1["Espera con GetQueuedCompletionStatus"]
G2["Espera con GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.control.-> W
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
sequenceDiagram
participant DRV as Núcleo (finalización del IRP)
participant Q as Cola del puerto (FIFO)
participant W as Subproceso trabajador
Note over W: Espera con GetQueuedCompletionStatus
DRV->>Q: Apila un paquete de finalización<br/>(bytes / CompletionKey / OVERLAPPED)
Q->>W: Despierta un subproceso en espera y se lo entrega
Note over W: Mira el paquete y hace el procesamiento de finalización<br/>(ejecutar la continuación, emitir la siguiente E/S, etc.)
W->>Q: Termina el procesamiento y vuelve a GetQueuedCompletionStatus
Note over Q: Si quedan paquetes en la cola,<br/>recibe el siguiente sin esperar
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
- Recibe un paquete con
GetQueuedCompletionStatus. - Hace el procesamiento de finalización de la operación recibida.
- 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.
flowchart TB
Q["Cola: P1 → P2 → P3 (FIFO)"]
subgraph TH["Subprocesos en espera (pila LIFO)"]
A["Subproceso A (acababa de ejecutarse, caliente)"]
B["Subproceso B (lleva un rato dormido)"]
C["Subproceso C (lleva mucho dormido)"]
end
Q -->|"P1, P2 y P3, si hay hueco,<br/>van primero al subproceso A"| A
B -.->|"solo cuando A está ocupado"| Q
C -.->|"casi nunca se despierta"| Q
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
GetQueuedCompletionStatusy 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.
flowchart TB
P["Llega un paquete a la cola"]
Q{"¿El número de subprocesos ejecutables es<br/>menor que el valor de concurrencia?"}
RUN["Despertar un subproceso en espera y hacerlo procesar"]
HOLD["No despertar a nadie y dejarlo en la cola<br/>(el subproceso en ejecución vendrá a por él)"]
BLK["Un subproceso en ejecución ha<br/>entrado en espera por otro motivo"]
COMP["En la medida en que ha bajado el número ejecutable,<br/>despertar un subproceso en espera y reponer"]
P --> Q
Q -->|menor| RUN
Q -->|tope| HOLD
BLK --> COMP
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.
sequenceDiagram
participant U as Subproceso que llama<br/>(el de UI, etc.)
participant K as Núcleo<br/>(emisión del IRP hasta la finalización)
participant Q as IOCP del grupo de subprocesos
participant IO as Subproceso de finalización de E/S
participant C as Lugar de ejecución de la continuación
U->>K: ReadAsync emite una lectura asíncrona<br/>(con el equivalente a OVERLAPPED)
K-->>U: ERROR_IO_PENDING (vuelve enseguida)
Note over U: await registra la continuación en el Task incompleto<br/>y suelta el subproceso (si es UI, al siguiente mensaje)
Note over K: El dispositivo está trabajando<br/>en este intervalo no hay en ningún sitio un subproceso que espere
K->>Q: Apila el paquete de finalización
Q->>IO: Despierta uno en LIFO y se lo entrega
Note over IO: Fija el resultado (bytes, estado),<br/>completa el Task y programa la continuación
IO->>C: Lo lanza al contexto capturado<br/>(al subproceso de UI / si no hay, se ejecuta en el grupo)
Note over C: Corre el código de continuación del await
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
flowchart TB
A["El Task se ha completado y se quiere ejecutar la continuación"]
Q1{"En el momento del await, ¿se había capturado<br/>un SynchronizationContext o<br/>un TaskScheduler no predeterminado?"}
Q2{"¿Se había puesto<br/>ConfigureAwait(false)?"}
UI["Se lanza al destino capturado<br/>ejemplo: se ejecuta en el bucle de mensajes del subproceso de UI<br/>o sobre ese TaskScheduler"]
TP["Sin obligación de volver a un lugar concreto<br/>sigue de forma síncrona en el subproceso que completó<br/>o se ejecuta en un subproceso del grupo"]
A --> Q2
Q2 -->|"sí"| TP
Q2 -->|"no"| Q1
Q1 -->|"sí (subproceso de UI de WPF/WinForms, etc.)"| UI
Q1 -->|"no (consola / ASP.NET Core, etc.)"| TP
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
awaiten el subproceso de UI de WPF/WinForms, se captura elSynchronizationContexty la continuación vuelve al subproceso de UI. Por eso tocar un control justo después delawaitno 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 haceawaitsobre unTaskSchedulerno 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.
- Con
ThreadPool.GetAvailableThreads, mirar el hueco de trabajadores y de subprocesos de finalización de E/S.4 - 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
PostQueuedCompletionStatustambié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
awaitno 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
- Las profundidades de la E/S de Windows (parte 1) — toda lectura y escritura se convierte en un IRP: el panorama del sistema de E/S
- Las profundidades de la E/S de Windows (parte 2) — E/S síncrona y E/S asíncrona: el verdadero significado de OVERLAPPED
- Tabla de decisiones prácticas de C# async/await - Task.Run y ConfigureAwait
- WPF/WinForms async y el subproceso de UI, en una sola hoja
- El malentendido de que se puede hacer Receive por cada unidad de Send en TCP — diseño de recepción para tratarlo como un flujo de bytes
- Identificar lo «lento» con PerfView y dotnet-trace — introducción práctica a la investigación de rendimiento de .NET
- Guía práctica para acercarse lo más posible al tiempo real suave en un Windows corriente
Á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».
- Desarrollo de aplicaciones para Windows
- Investigación de errores y análisis de causa raíz
- Desarrollo de aplicaciones Windows de tiempo real suave
- Contacto
Referencias
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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 relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Las profundidades de la E/S de Windows (parte 4) — El administrador de caché: ¿cuándo llega su WriteFile al disco?
Cuarta entrega de la serie ilustrada sobre el administrador de caché de Windows. Organiza la caché implementada como asignación de archiv...
Las profundidades de la E/S de Windows (parte 2) ── E/S síncrona y asíncrona: el verdadero significado de OVERLAPPED
Segunda entrega de la serie que explica con diagramas la E/S síncrona y la E/S asíncrona (E/S superpuesta) de Windows. Organiza el signif...
Las profundidades de la E/S de Windows (parte 1) — Toda lectura y escritura se convierte en un IRP: panorama general del sistema de E/S
Primera entrega de la serie que explica desde la base el sistema de E/S de Windows: el espacio de nombres del Administrador de objetos, l...
Cómo manejar correctamente los tokens de suplantación de Windows — préstamo de privilegios por hilo y una forma segura de revertirlos
Guía práctica sobre los tokens de suplantación de Windows: tokens de acceso, primarios y de hilo, niveles de suplantación, RevertToSelf y...
Las profundidades del I/O de Windows (6.ª entrega, final) — Cómo funcionan los minifiltros y la investigación de retrasos con Procmon
Cómo los minifiltros vigilan y controlan el I/O de archivos: FltMgr, altitudes, callbacks pre/post, fltmc, operaciones lentas en Procmon,...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
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.