Los entresijos de 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

En la entrega anterior (Parte 2) vimos cómo se emite la E/S asíncrona y las cuatro vías por las que se recibe su finalización. En ese momento mencionamos solo por su nombre, como «la verdadera respuesta para atender numerosas E/S simultáneas con pocos subprocesos», al puerto de finalización de E/S (IOCP).

¿Por qué un servidor web puede atender miles de conexiones simultáneas con una docena de subprocesos? ¿Por qué se puede afirmar sin rodeos que la espera de E/S de async/await «no consume un subproceso»? ¿Por qué la continuación de await a veces se ejecuta en el subproceso de UI y otras veces en el grupo de subprocesos? — las respuestas a estas tres preguntas convergen en un único diseño: IOCP. Esta es la entrega de la serie más directamente ligada a los desarrolladores de .NET.

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

Como es un artículo largo, primero indico dónde se resuelve cada pregunta.

  • Pregunta 1: por qué se pueden atender miles de conexiones simultáneas con una docena de subprocesos → se resuelve en el capítulo 3 (el diseño de IOCP que integra la cola de finalización con el control del número de subprocesos).
  • Pregunta 2: por qué se puede decir que la espera de E/S «no consume un subproceso» → se resuelve en las secciones 5.1 y 5.2 (el panorama completo de un ida y vuelta de await, y el significado exacto de que no exista ningún subproceso durante la espera).
  • Pregunta 3: qué decide en qué subproceso se ejecuta la continuación de await → se resuelve en la sección 5.3 (el contexto capturado y el verdadero significado de ConfigureAwait(false)).

1. Primero, la conclusión

  • IOCP integra una «cola de notificación de finalización» con el «control del número de subprocesos». Los paquetes de finalización se apilan en la cola en orden FIFO, y los subprocesos trabajadores los retiran con GetQueuedCompletionStatus (capítulo 3).1
  • Los subprocesos se despiertan en orden LIFO. El subproceso «caliente» que trabajó hasta hace un instante es el que recoge también el siguiente paquete, de modo que, mientras la cola tenga elementos, apenas se producen cambios de contexto (sección 3.3).1
  • El valor de concurrencia es el límite del «número de subprocesos ejecutables». El punto de partida recomendado es el número de CPU (con 0 se usa el número de procesadores). Si un subproceso en ejecución se bloquea, se despierta uno en espera para llenar el hueco (sección 3.4).12
  • El puerto también sirve para notificaciones propias. PostQueuedCompletionStatus permite apilar paquetes sin relación con ninguna E/S, de modo que también se pueden enviar por la misma cola encargos de trabajo para los trabajadores o instrucciones de cierre (capítulo 4).3
  • Para una implementación de servidor nueva, se recomienda la API del grupo de subprocesos de Windows (CreateThreadpoolIo) antes que IOCP en crudo. Por dentro sigue siendo IOCP, pero se encarga de la gestión de subprocesos por usted (capítulo 4).1
  • El grupo de subprocesos de .NET tiene dos plantas, subprocesos trabajadores y subprocesos de finalización de E/S, y los identificadores de E/S asíncrona se vinculan al IOCP del propio grupo. No existe ningún subproceso durante la espera de E/S de await; solo la continuación posterior a la finalización se 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ó el Task). ConfigureAwait(false) es una instrucción para dejar de capturar, no una garantía de que se pasará al grupo de subprocesos (sección 5.3).6

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

Primero confirmemos el problema que IOCP intentó resolver. Un servidor ingenuo se puede escribir como «un subproceso por conexión»: leer con E/S síncrona, procesar y escribir. Es un diseño fácil de entender, pero al crecer el número de conexiones choca con dos muros.

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

Figura 1: A la izquierda, los subprocesos crecen en proporción al número de conexiones. A la derecha, solo los «sucesos ocurridos» se procesan con un número reducido de subprocesos

  1. Un subproceso no es gratis. Cada uno consume una pila (1 MB reservado por defecto) y un objeto del núcleo, y cuantos más hay, mayor es la carga acumulada sobre el planificador y los cambios de contexto. Miles de conexiones equivalen a miles de subprocesos, y eso sale caro aunque la mayoría solo esté «dormida esperando poder leer».
  2. Que el «número que se quiere ejecutar» supere el número de CPU no sirve de nada. Físicamente, solo pueden correr a la vez tantos subprocesos como CPU haya. Hacer ejecutables más subprocesos que eso solo aumenta la pérdida por cambios de contexto.

Hasta la parte 2 vimos cómo recibir la notificación de finalización de E/S mediante «eventos» o «APC». Pero el método de eventos se topa con el límite de 64 objetos de WaitForMultipleObjects y complica el diseño de la espera conjunta, y las APC quedan atadas al subproceso que emitió la operación. IOCP es el diseño concebido desde el principio para la fórmula numerosas E/S × pocos subprocesos.1

3. El diseño de IOCP — la integración de la cola y el control de subprocesos

3.1. Las dos caras de CreateIoCompletionPort

Pese a su nombre, CreateIoCompletionPort hace 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 (cualquier cantidad)controlaEsperan con GetQueuedCompletionStatusEsperan con GetQueuedCompletionStatusCola de paquetes de finalización (FIFO)paquete = bytes transferidos +CompletionKey + puntero OVERLAPPEDControl de concurrenciasubprocesos ejecutables ≤ límiteArchivoSocketCanalización con nombre

Figura 2: Composición de IOCP. Las finalizaciones de numerosos identificadores convergen en una sola cola, y hasta el número de subprocesos que las retiran queda controlado

El CompletionKey que se pasa al asociar es un valor libre para indicarle al trabajador «esta finalización viene de este identificador» (lo habitual es poner ahí el puntero al objeto de conexión). El paquete de finalización llega con el CompletionKey, el puntero OVERLAPPED de esa operación y el número de bytes transferidos. De qué conexión (CompletionKey), qué operación (OVERLAPPED) y cuánto avanzó (bytes): aquí es donde se recupera el «vale de la operación» que vimos en la parte 2.27

El objeto no se limita a «archivos». Se puede asociar cualquier identificador capaz de hablar E/S superpuesta: sockets, canalizaciones con nombre, ranuras de correo (mailslots), etc.1 Aquí también actúa el diseño de «todo se ve como un archivo» que vimos en la parte 1.

3.2. El viaje de un paquete de finalización

Subproceso trabajadorCola del puerto (FIFO)Núcleo (finalización de IRP)Subproceso trabajadorCola del puerto (FIFO)Núcleo (finalización de IRP)Espera en GetQueuedCompletionStatusExamina el paquete y procesa la finalización(ejecuta la continuación, emite la siguiente E/S, etc.)Si quedan paquetes en la cola,el siguiente se recibe sin esperarColoca el paquete de finalización(bytes / CompletionKey / OVERLAPPED)Despierta un subproceso en espera y le entrega el paqueteTermina de procesar y vuelve a llamar a GetQueuedCompletionStatus

Figura 3: Los paquetes de finalización se apilan en FIFO, y el trabajador repite el ciclo de retirarlos y procesarlos

Cuando una E/S asíncrona se completa, el paquete de finalización se apila en la cola del puerto en orden FIFO. El trabajador llama a GetQueuedCompletionStatus para recibir un paquete y, cuando termina de procesarlo, vuelve a llamarlo: este bucle es el esqueleto de la programación con IOCP.17 También existe GetQueuedCompletionStatusEx, que retira varios paquetes de una sola vez, y que en E/S de alta frecuencia reduce el número de llamadas.8

Aprovechemos para eliminar de raíz el error clásico del bucle de trabajador. Aunque GetQueuedCompletionStatus devuelva FALSE, si el puntero OVERLAPPED vuelve distinto de NULL, eso significa que se pudo retirar el paquete de finalización de una E/S que falló.7 Las operaciones fallidas también requieren limpieza (manejo de errores y liberación del vale y del búfer que vimos en la parte 2), así que ese paquete hay que procesarlo. Solo se puede decir que «no se pudo retirar ningún paquete» (por tiempo de espera agotado, cierre del puerto, etc.) cuando OVERLAPPED es NULL. Escribir con desidia if (!GetQueuedCompletionStatus(...)) break; hace que se pierdan y filtren todas las E/S fallidas.

Dejo el esqueleto en una forma que se pueda copiar directamente. El orden de las comprobaciones corresponde tal cual a la explicación anterior.

/* Esqueleto del bucle de 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 pudo retirar ningún paquete (p. ej., el puerto se cerró). Es la única condición que puede salir del bucle */
        break;
    }
    if (!ok) {
        /* ov != NULL → se retiró el "paquete de finalización de una E/S fallida".
           Hay que procesarlo sin salir, porque requiere limpieza (manejo de errores y liberación del vale y del búfer) */
        DWORD err = GetLastError();
        handle_failed_io(key, ov, err);
        continue;
    }
    if (key == SHUTDOWN_KEY) {
        /* Paquete de cierre colocado con PostQueuedCompletionStatus (capítulo 4) */
        break;
    }
    handle_completed_io(key, ov, bytes);   /* Procesamiento normal de la finalización. Mantenlo breve (sección 3.4) */
}

El punto clave es no resolver ok == FALSE con una sola rama, sino dividirla en dos según si ov es NULL o no. La comprobación es la misma aunque se use un tiempo de espera (distinto de INFINITE): el agotamiento del tiempo 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 Conviene recordarlo con la imagen de «un equipo de trabajadores dedicado se asigna al puerto».

3.3. Los subprocesos se despiertan en orden LIFO

Aquí empieza lo ingenioso del diseño de IOCP. Los paquetes se apilan en FIFO, pero los subprocesos en espera se despiertan en LIFO. Es decir, el subproceso que trabajó hasta hace más poco tiempo es el que recoge también el siguiente paquete.1

Subprocesos en espera (pila LIFO)P1, P2 y P3: si está libre,van primero al subproceso ASolo cuando A está ocupadoRara vez se despiertaSubproceso A (ejecutó hasta hace un instante, «caliente»)Subproceso B (lleva un rato dormido)Subproceso C (dormido desde hace mucho)Cola: P1 → P2 → P3 (FIFO)

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

Este diseño ofrece dos beneficios.

  • No se producen cambios de contexto. Mientras queden paquetes en la cola, cuando el subproceso que terminó de procesar llama a GetQueuedCompletionStatus, recibe el siguiente paquete sin esperar y sigue corriendo. La documentación afirma explícitamente que, en un escenario con valor de concurrencia 1, «no se produce ningún cambio de subproceso».1
  • Se aprovecha la caché aún caliente. Como el mismo subproceso sigue corriendo, es más probable que su pila y su estado de planificación permanezcan en la caché de la CPU. Los subprocesos dormidos se mantienen baratos, como reserva para los picos de carga.

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

El valor de concurrencia es NumberOfConcurrentThreads en el momento de crear el puerto. Es el límite del «número de subprocesos ejecutables (runnable) asociados a ese puerto», y mientras se esté en ese límite, ningún subproceso adicional puede recibir un paquete.1 Si se pasa 0, se usa el número de procesadores del sistema, y la documentación también sostiene que, en general, el mejor valor máximo es el número de CPU.21

Lo inteligente es que este número no cuenta los «subprocesos despiertos», sino los «subprocesos ejecutables».

Por debajoEn el límiteLlega un paquete a la cola¿El número de subprocesos ejecutablesestá por debajo del valor de concurrencia?Despierta un subproceso en espera y le hace procesarloNo despierta a nadie; lo deja en la cola(un subproceso en ejecución vendrá a buscarlo)Un subproceso en ejecuciónentra en espera por otro motivoSe despierta un subproceso en espera parareponer exactamente lo que bajó el número ejecutable

Figura 5: Control de concurrencia. Como el límite es el «número ejecutable», si alguien se bloquea el sistema repone automáticamente

Cuando un trabajador en ejecución entra en alguna espera (un bloqueo, un fallo de página, una E/S síncrona escrita por descuido), el número ejecutable baja, así que el sistema despierta un subproceso en espera para llenar el hueco.1 Por eso lo habitual no es crear trabajadores solo en cantidad «exactamente igual al número de CPU», sino mantener en espera más subprocesos de los que marca el valor de concurrencia. Si el procesamiento mezcla cálculos largos, también se puede optar por subir el propio valor de concurrencia, y la postura de la documentación es que, en última instancia, hay que ajustarlo mediante perfilado.1

Pero la reposición no es omnipotente. Si el subproceso que se bloqueó despierta más tarde, en ese instante el número ejecutable supera el límite (la documentación también menciona este exceso).1 El principio fundamental es mantener breve el procesamiento de finalización, y esto se aplica de la misma forma en .NET, como veremos en el capítulo 6.

4. La caja de herramientas — las API que sostienen el puerto

  • PostQueuedCompletionStatus — permite apilar en la cola un paquete de finalización propio sin emitir ninguna E/S.3 Encargos de trabajo para los trabajadores, instrucciones de cierre (apilar tantos paquetes de terminación —popularmente llamados paquetes «píldora envenenada»— como trabajadores haya), notificaciones desde otros subprocesos: poder procesar la finalización de E/S y los mensajes propios en la misma cola y el mismo bucle simplifica enormemente el diseño.
  • GetQueuedCompletionStatusEx — retira varios paquetes de finalización de una sola vez. Es eficaz en E/S de alta frecuencia, donde pesa la sobrecarga de una llamada por paquete.8
  • SetFileCompletionNotificationModes — en el caso, visto en el capítulo 5 de la parte 2, de una E/S emitida como asíncrona que terminó completándose de forma síncrona, permite elegir un modo en el que no se apila ningún paquete en el puerto (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). Es una optimización basada en que, si el resultado de una finalización síncrona ya se conoce en el momento, pasar de nuevo por la cola es puro desperdicio.9
  • La API del grupo de subprocesos de WindowsCreateThreadpoolIo / StartThreadpoolIo usan IOCP por dentro, pero se encargan de crear y gestionar los subprocesos. Microsoft recomienda que las aplicaciones de servidor nuevas consideren primero esta opción, y recurrir a IOCP en crudo solo cuando se quiera controlar explícitamente el valor de concurrencia o la gestión de subprocesos.1 Y el grupo de subprocesos de .NET es, precisamente, la implementación en el runtime de .NET de esta misma «automatización de IOCP más gestión de subprocesos».

Solo mencionaré tres trampas. (1) No bloquear durante mucho tiempo dentro de un trabajador (la reposición de la sección 3.4 solo mitiga la degradación, no la elimina). (2) La identificación del paquete de finalización se hace en dos niveles, CompletionKey (por identificador) y OVERLAPPED (por operación): la gestión del ciclo de vida del «vale» de la parte 2 (no liberarlo hasta la finalización) sigue siendo aquí una cuestión vital. (3) No cerrar un identificador mientras queden E/S sin completar: el comportamiento de la limpieza (capítulo 6 de la parte 1) y las buenas prácticas de cancelación (capítulo 6 de la parte 2) se aplican tal cual.

5. El grupo de subprocesos de .NET — un edificio de dos plantas sobre IOCP

A partir de aquí llega el tema central: «el sótano de async/await».

El grupo de subprocesos de .NET tiene dos tipos de subprocesos: los subprocesos trabajadores, que ejecutan Task.Run y las continuaciones, y los subprocesos de finalización de E/S, que reciben la finalización de la E/S asíncrona. El hecho de que ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) devuelva dos números por separado se debe a que, por dentro, en efecto tiene dos plantas.4

Y en Windows, el grupo de subprocesos tiene su propio puerto de finalización de E/S. La API de bajo nivel vigente para asociar un identificador del sistema operativo a este puerto es ThreadPoolBoundHandle.BindHandle, y la E/S asíncrona sobre un identificador vinculado se maneja junto con NativeOverlapped (que es, precisamente, la versión en .NET del OVERLAPPED de la parte 2); el antiguo ThreadPool.BindHandle sigue existiendo con el mismo papel, pero para código nuevo esta es la opción a usar. Cuando FileStream o Socket abren un identificador en modo asíncrono, por dentro se realiza este tipo de vinculación.5 En otras palabras:

  • 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 que recibe la finalización
  • Los subprocesos de finalización de E/S del grupo de subprocesos de .NET son el equipo de trabajadores que ejecuta el bucle de GetQueuedCompletionStatus

Con esta correspondencia, el esquema de Win32 se convierte tal cual en el esquema de .NET.

5.1. Un ida y vuelta completo de await ReadAsync

En la figura 7 de la parte 2 dejamos una caja rotulada simplemente «E/S asíncrona de verdad». Esta vez la abrimos hasta el final.

Lugar de ejecución de la continuaciónSubproceso de finalización de E/SIOCP del grupo de subprocesosNúcleo(de la emisión del IRP a su finalización)Subproceso llamador(p. ej., el subproceso de UI)Lugar de ejecución de la continuaciónSubproceso de finalización de E/SIOCP del grupo de subprocesosNúcleo(de la emisión del IRP a su finalización)Subproceso llamador(p. ej., el subproceso de UI)await registra la continuación en el Task aún incompletoy libera el subproceso (en UI, pasa al siguiente mensaje)El dispositivo está trabajandodurante este tiempo no existe ningún subproceso en esperaDetermina el resultado (bytes y estado),completa el Task y programa la continuaciónSe ejecuta el código que sigue al awaitReadAsync emite una lectura asíncrona(con el equivalente a OVERLAPPED)ERROR_IO_PENDING (retorna de inmediato)Coloca el paquete de finalizaciónDespierta uno en orden LIFO y se lo entregaLo envía al contexto capturado(al subproceso de UI, o si no hay ninguno, en el grupo de subprocesos)

Figura 6: El ida y vuelta completo de un await. Los subprocesos solo trabajan en la «emisión» y «después de la finalización»; el tiempo de espera transcurre con cero subprocesos

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

Lo que quiero destacar con 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 que exista solo para esperar esa finalización. La explicación de Microsoft sobre async («Async in depth») también describe, bajando hasta los controladores de dispositivo y las interrupciones, que para un Task ligado a E/S «no hay en ningún lugar un subproceso que solo esté esperando a que termine».6 Cabe señalar que sí hay ocasiones en las que, dentro del núcleo, un controlador delega parte del procesamiento a un subproceso trabajador del sistema. Pero eso es un trabajo breve para hacer avanzar la solicitud, y no existe en ningún lugar un subproceso que se quede bloqueado esperando la finalización: ese es el alcance de lo que está garantizado.

Dicho de otro modo, apoyándonos en 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 retorna de inmediato con ERROR_IO_PENDING (parte 2); y la finalización llega mediante una cadena de eventos, interrupción → paquete de finalización (este artículo). El diseño está hecho de forma que mantener el estado de «espera» no requiere el recurso caro que es un subproceso.

Por eso, una aplicación que use async/await correctamente puede mantener «10.000 E/S en vuelo simultáneamente» con apenas una docena de subprocesos. Dicho a la inversa, esta propiedad es exclusiva de los Task ligados a E/S. Un procesamiento de CPU envuelto en Task.Run ocupa, por supuesto, un subproceso trabajador entero, y la «asincronía aparente» del capítulo 7 de la parte 2 también hace dormir un subproceso por debajo.

5.3. Dónde se ejecuta la continuación

La última flecha de la figura 6 — «a dónde se envía la continuación» — sigue una regla clara.6

NoSí (p. ej., el subproceso de UI de WPF/WinForms)No (p. ej., consola o ASP.NET Core)El Task se completó y hay que ejecutar la continuación¿En el momento del awaitse capturó un SynchronizationContexto un TaskScheduler no predeterminado?¿Se usóConfigureAwait(false)?Se devuelve al destino capturadop. ej.: se ejecuta en el bucle de mensajesdel subproceso de UI o en ese TaskSchedulerSin obligación de volver a un lugar concretocontinúa de forma síncrona en el subproceso que completó el Tasko se ejecuta en un subproceso del grupo

Figura 7: El destino de la continuación. Que se pueda «tocar la UI directamente después del await» se debe a que se devuelve al contexto capturado

  • Si se hace await en el subproceso de UI de WPF o WinForms, se captura el SynchronizationContext y la continuación vuelve al subproceso de UI. Por eso, justo después del await, se puede tocar un control sin que se produzca una violación de subproceso. El lado práctico de este diseño se trató en «Async y el subproceso de UI en WPF/WinForms en una sola hoja».
  • No solo se captura el SynchronizationContext: si se hace await sobre un TaskScheduler no predeterminado, ese planificador también es objeto de captura. En los lugares donde no hay ninguno de los dos (consola, ASP.NET Core, código ya en el grupo de subprocesos), no existe la obligación de volver a un lugar concreto, así que la continuación se ejecuta en el grupo de subprocesos o continúa directamente, de forma síncrona, en el subproceso que completó el Task.
  • ConfigureAwait(false) es una indicación explícita de que «no hace falta volver», no una garantía de que «siempre se pasará al grupo de subprocesos». Si se hace await sobre un Task que ya está completado (lo que incluye la finalización síncrona vista en la parte 2), no se produce ninguna espera y la ejecución continúa tal cual en el subproceso actual. Para saber cuándo usarlo en código de biblioteca, consulte «Tabla de decisiones prácticas de C# async/await».

Este último punto genera muchos malentendidos, así que lo mostraré con código en paralelo. Primero, el código escrito pensando que ConfigureAwait(false) es una instrucción para pasar al grupo de subprocesos.

// Ejemplo incorrecto: el malentendido de que "como se puso ConfigureAwait(false), a partir de aquí se ejecuta en el grupo de subprocesos"
private async void OnLoadClick(object sender, EventArgs e)
{
    string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);

    // Expectativa: esto va en 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 continúa en el propio subproceso de UI → este procesamiento pesado congela la UI
    var rows = ParseHeavy(csv);

    // Y además, si se completó de forma asíncrona, la continuación no está en el subproceso de UI
    resultLabel.Text = $"{rows.Count} filas";   // → puede lanzar una excepción por violación de subproceso
}

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

Separar las intenciones en el código queda así.

// Ejemplo correcto: indicar por separado "si se vuelve o no a la UI" y "dónde se hace el procesamiento pesado"
private async void OnLoadClick(object sender, EventArgs e)
{
    // En código de UI, se deja que la captura siga activa (la continuación vuelve al subproceso de UI)
    string csv = await File.ReadAllTextAsync(path);

    // Si se quiere sacar el procesamiento intensivo de CPU al grupo de subprocesos, se indica explícitamente con Task.Run
    var rows = await Task.Run(() => ParseHeavy(csv));

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

// En el lado de la biblioteca (código sin UI), en cambio, 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 código que lo llama
}

El criterio para decidir es sencillo. En el código que toca la UI, deje que la captura siga activa; cuando quiera cambiar el destino, indíquelo explícitamente con Task.Run. Considere ConfigureAwait(false) como una herramienta del lado de la biblioteca para declarar que «funciona con seguridad sea cual sea el contexto del código que lo llama».

5.4. La verdadera causa de los atascos — la inanición del grupo de subprocesos

Para terminar, veamos un único patrón por el que este sótano se atasca. Si se espera de forma síncrona dentro de una continuación o un trabajador (E/S síncrona, Task.Result/Wait(), una espera larga de bloqueo), ese subproceso se queda ocupado sin liberarse. La reposición propia de IOCP (sección 3.4) funciona de inmediato mientras haya subprocesos de reserva en espera. Pero una vez agotada la reserva, se entra en un territorio donde el grupo de subprocesos solo inyecta subprocesos nuevos lentamente. En el instante en que llega la carga se produce inanición (starvation) — «quiero ejecutar la continuación pero no hay ningún subproceso que la ejecute» — y toda la aplicación se atasca.

Hay dos puntos de partida para investigarlo. Uno, observar la disponibilidad de subprocesos trabajadores y de finalización de E/S con ThreadPool.GetAvailableThreads.4 Y dos, captar con trazas de eventos la realidad del grupo de subprocesos y los bloqueos: el procedimiento está reunido en «Cómo identificar la «lentitud» con PerfView y dotnet-trace». La prevención es simple y se reduce a esto: mantenga el camino de async como async hasta el final (no mezcle sync-over-async).

6. Resumen

  • IOCP es un mecanismo que integra la cola de finalización (FIFO) con el control del número de subprocesos. Reúne en un solo puerto la finalización de numerosos identificadores y la procesa con el bucle de GetQueuedCompletionStatus.17
  • Como los subprocesos se liberan en LIFO, cuanto más ocupado está el sistema más se repite el mismo subproceso, lo que minimiza los cambios de contexto y los fallos de caché.1
  • El valor de concurrencia es el límite del número de subprocesos ejecutables, y el punto de partida es el número de CPU (indicando 0). Si un subproceso en ejecución se bloquea, se repone con un subproceso en espera, pero el principio fundamental es mantener breve el procesamiento de finalización.12
  • Con PostQueuedCompletionStatus también se pueden enviar paquetes propios. En una implementación nueva, la primera opción es la API del grupo de subprocesos de Windows (que por dentro sigue siendo IOCP).31
  • El grupo de subprocesos de .NET tiene dos plantas, subprocesos trabajadores y subprocesos de finalización de E/S, y los identificadores asíncronos se vinculan al IOCP del propio grupo. No existe ningún subproceso durante la espera de E/S de await; solo la continuación posterior a la finalización se monta en un subproceso.456
  • El destino de la continuación depende del contexto capturado (vuelve al subproceso de UI o continúa en el grupo de subprocesos). ConfigureAwait(false) es la instrucción para dejar de capturarlo.6
  • La verdadera causa de los atascos es, casi siempre, la inanición del grupo de subprocesos por la mezcla de esperas síncronas. Mantenga el camino de async como async de principio a fin.

La continuación es la parte 4, «El gestor de caché — cuándo llega realmente su WriteFile al disco». En la parte 2 escribimos que «si está en la caché, se completa de forma síncrona», y también en esta entrega la sombra de la caché asomó varias veces. La próxima vez nos enfrentamos de frente a esa caché en sí — la escritura diferida, la lectura anticipada, FILE_FLAG_NO_BUFFERING y la condición en la que «los datos que se creían escritos desaparecen con un corte de energía».

Artículos relacionados

Áreas de consultoría relacionadas

En KomuraSoft LLC nos ocupamos del diseño de aplicaciones y servidores de Windows que manejan numerosas conexiones y E/S simultáneas, así como de la investigación de la causa raíz de problemas de rendimiento como «el grupo de subprocesos se atasca» o «se hizo asíncrono pero no mejoró la velocidad».

Referencias

  1. Microsoft Learn, I/O Completion Ports. Sobre que los puertos de finalización de E/S ofrecen un modelo de subprocesamiento eficiente para procesar numerosas solicitudes de E/S asíncrona 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 objeto no se limita a archivos en disco, sino que puede ser cualquier identificador que admita E/S superpuesta, como sockets, canalizaciones con nombre o ranuras de correo; que los subprocesos en espera en el puerto se liberan en orden LIFO, y que con un valor de concurrencia de 1 y la cola llena no se produce ningún cambio de subproceso; que un subproceso queda asociado al puerto la primera vez que llama a GetQueuedCompletionStatus, y solo puede estar asociado a un puerto a la vez; que el valor de concurrencia limita el número de subprocesos ejecutables, y que en general el mejor valor máximo 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 el paquete de finalización (y que el subproceso bloqueado puede hacer que se supere temporalmente el límite al despertar); y que para las aplicaciones de servidor nuevas conviene considerar primero la API del grupo de subprocesos de Windows (CreateThreadpoolIo, etc., que 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 tanto crea un puerto de finalización de E/S nuevo como asocia 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 límite del número de subprocesos que pueden procesar paquetes de finalización en paralelo, usándose el número de procesadores del sistema si se especifica 0.  2 3 4 5

  3. Microsoft Learn, PostQueuedCompletionStatus function. Sobre que PostQueuedCompletionStatus permite apilar en la cola del puerto de finalización de E/S un paquete de finalización propio de la aplicación sin iniciar ninguna E/S asíncrona, lo que hace que el puerto sirva también como punto de recepción de comunicaciones de otros subprocesos del proceso, además de la recepción de finalizaciones de E/S.  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 puede obtener por separado el número disponible de subprocesos trabajadores y de subprocesos de finalización de E/S; y que no se debe bloquear durante mucho tiempo en un subproceso del grupo de subprocesos.  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 realiza en combinación con NativeOverlapped; y que el procesamiento de la finalización de la E/S asíncrona pasa a estar a cargo del grupo de subprocesos.  2 3

  6. Microsoft Learn, Async in depth (.NET). Sobre que, en un Task ligado a E/S, no existe un subproceso dedicado a esperar su finalización una vez que la llamada pasa al sistema operativo (el llamado «There is no thread»); que la finalización se notifica a través del controlador de dispositivo y las interrupciones, y se ejecuta la continuación registrada; que, por defecto, await captura el contexto actual (SynchronizationContext, etc.) y ejecuta allí la continuación, y que, cuando no hay ningún contexto que capturar, se ejecuta en el grupo de subprocesos; y que ConfigureAwait(false) permite 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 de finalización (y espera si no hay ninguno); que al retirarlo se obtienen el número de bytes transferidos, el CompletionKey y el puntero OVERLAPPED; y que, si el valor de retorno es FALSE pero el puntero OVERLAPPED no es NULL, significa que se retiró el paquete de finalización de una operación de E/S que falló, mientras que solo cuando OVERLAPPED es NULL significa que no se pudo retirar ningún paquete (por ejemplo, por tiempo de espera agotado).  2 3 4

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

  9. Microsoft Learn, SetFileCompletionNotificationModes function. Sobre que FILE_SKIP_COMPLETION_PORT_ON_SUCCESS permite elegir el comportamiento de no apilar ningún paquete en el puerto de finalización cuando una E/S se completa de inmediato con éxito y el resultado ya queda determinado en el momento. 

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