Las profundidades de la E/S de Windows (Parte 2) ── E/S síncrona y E/S asíncrona: el verdadero significado de OVERLAPPED

· Actualizado el: · · Windows, Win32, I/O, Asíncrono, OVERLAPPED, Núcleo, .NET, CSharp

La vez pasada (Parte 1) vimos que una solicitud de E/S de Windows se convierte en un paquete llamado IRP que recorre la pila de dispositivos, y que la emisión y la finalización están separadas desde el fondo mismo del núcleo.

En esta entrega profundizamos en el mecanismo que las aplicaciones usan para aprovechar esa separación: la E/S asíncrona (E/S superpuesta). Se añade FILE_FLAG_OVERLAPPED y aun así vuelve de forma síncrona. Se reutiliza un OVERLAPPED y los datos se corrompen. Se llama a CancelIoEx y no se detiene. Se cancela y la aplicación se cae con una infracción de acceso. Todas estas «historias de terror sobre la E/S asíncrona» vienen de no tener el mecanismo completo como una sola imagen mental. En este artículo construimos esa imagen.

Esta es la segunda entrega de la serie «Las profundidades de la E/S de Windows». La estructura completa de la serie está al principio de la Parte 1.

1. Primero, la conclusión

  • La línea que separa la E/S síncrona de la asíncrona no está en el núcleo, sino en «si se espera o no». La garantía de la E/S síncrona es que la llamada no vuelve antes de completarse. Solo cuando la solicitud queda pendiente, el administrador de E/S espera la finalización, y el hilo duerme en un estado de espera que no consume CPU (capítulo 2).1
  • FILE_FLAG_OVERLAPPED es un modo del handle (el objeto de archivo). Se decide en el momento de CreateFile y no se puede cambiar llamada por llamada. Como en un handle asíncrono el sistema no gestiona el puntero de archivo, en un archivo en disco la posición se indica cada vez mediante el Offset de OVERLAPPED (capítulo 3).21
  • La estructura OVERLAPPED es «el comprobante de una sola operación». Hace falta una por cada operación en curso, y la documentación oficial deja claro que compartirla o reutilizarla lleva a la corrupción de datos. Hasta que la operación se completa, no se debe tocar ni la estructura ni el búfer (capítulo 3).34
  • En la práctica hay cuatro formas de recibir la finalización: la señalización del handle (en desuso), un evento de OVERLAPPED junto con GetOverlappedResult, un APC (espera «alertable») y el puerto de finalización de E/S (próxima entrega) (capítulo 4).156
  • Aunque se emita de forma asíncrona, la E/S puede completarse de forma síncrona. Los ejemplos típicos son que los datos ya estén en la caché, la compresión o el cifrado de NTFS, y una escritura que alarga el archivo. «Asíncrono» no equivale a «nunca bloquea» (capítulo 5).3
  • La cancelación es una «solicitud». Aunque se llame a CancelIoEx, la operación vuelve como una finalización con ERROR_OPERATION_ABORTED. No se debe hacer la limpieza hasta comprobar esa finalización (capítulo 6).78
  • El FileOptions.Asynchronous de .NET es el interruptor directo de este modo. Cuando el modo del handle y la API que se llama no coinciden, aparece una «asincronía aparente» donde el grupo de subprocesos se hace cargo, o una espera de sincronización inútil (capítulo 7).910

2. E/S síncrona ── ¿dónde duerme el hilo?

Empecemos por la forma por defecto. Un handle abierto sin FILE_FLAG_OVERLAPPED está en modo síncrono. Al llamar a ReadFile, la función no vuelve hasta que la E/S se completa.1

Como vimos en la Parte 1, el controlador deja la solicitud en espera (pendiente) mientras aguarda la respuesta del hardware. Entonces, en la E/S síncrona, ¿quién espera? Es el administrador de E/S quien espera la finalización antes de devolver el control a la aplicación.

Controlador (pila)Administrador de E/SHilo de la aplicaciónControlador (pila)Administrador de E/SHilo de la aplicaciónEl hilo pasa a estado de espera dentro del núcleoy duerme sin consumir CPUReadFile (handle síncrono)Emite el IRPSTATUS_PENDING (esperando respuesta)Finalización (IoCompleteRequest)Devuelve el resultado y despiertaReadFile vuelve con TRUE/FALSE

Figura 1: E/S síncrona (cuando la solicitud queda pendiente). El administrador de E/S espera la finalización antes de devolver el control a la aplicación

Este diagrama corresponde al caso en que la solicitud queda pendiente. Si el controlador puede completar la solicitud en el acto (por ejemplo, un acierto de caché; el camino de «finalización inmediata» de la figura 5 de la Parte 1), no se produce ninguna espera y el resultado vuelve directamente. La garantía de la E/S síncrona es que la llamada no vuelve antes de completarse, no que el hilo siempre duerma.

Conviene tener presentes dos puntos.

  • «Esperar» no consume CPU. Un hilo en estado de espera queda fuera de la lista de ejecución del planificador. Ya escribimos sobre por qué conviene dejar que un hilo espere en lugar de ahogarse a sí mismo con sondeo (polling) en «Por qué conviene preferir la espera de eventos sobre Sleep(1) en Windows».
  • En un handle en modo síncrono, el núcleo gestiona el puntero de archivo (la posición actual). Por eso llamadas sucesivas a ReadFile pueden leer «a partir de donde se quedó». Este estado reside en el objeto de archivo, no en el handle, así que un handle duplicado con DuplicateHandle comparte la misma posición (sección 3.3 de la Parte 1).

La debilidad de la E/S síncrona se reduce a esto: mientras espera, ese hilo no puede hacer ningún otro trabajo. Si se hace E/S síncrona en el hilo de la interfaz de usuario, la pantalla se congela; si un servidor levanta un hilo por cada conexión, unos pocos cientos de conexiones lo llenan de hilos. Existe además una API, CancelSynchronousIo, pensada precisamente para rescatar desde fuera a otro hilo que se ha quedado bloqueado en una E/S síncrona (capítulo 6).11

3. E/S asíncrona ── el modo del handle y el comprobante de la operación

3.1. El modo se decide por handle

Cuando se pasa FILE_FLAG_OVERLAPPED a CreateFile, el objeto de archivo que hay detrás de ese handle se abre en modo asíncrono.1 Lo importante aquí es que se trata de una propiedad por handle. No es posible decir «solo esta llamada, de forma asíncrona». Sí es posible abrir el mismo archivo con dos handles distintos, uno síncrono y otro asíncrono (simplemente se crean dos objetos de archivo).

Un handle en modo asíncrono tiene otra diferencia importante: el sistema no gestiona el puntero de archivo.2 Con varias operaciones volando al mismo tiempo, la noción de «posición actual» pierde sentido. En dispositivos con posición, como un archivo en disco, la posición de lectura o escritura se especifica explícitamente cada vez mediante Offset/OffsetHigh de la estructura OVERLAPPED. En cambio, en dispositivos sin concepto de posición de búsqueda, como un puerto serie o una canalización con nombre (named pipe), Offset no se usa para indicar posición (se deja en 0). Aun así, como veremos en el siguiente apartado, la propia estructura OVERLAPPED sigue siendo necesaria para cada operación.

3.2. OVERLAPPED es «el comprobante de una sola operación»

El papel de la estructura OVERLAPPED es identificar una operación en curso y transportar su estado.4

Miembro Función
Offset / OffsetHigh Posición del archivo donde esta operación lee o escribe (se indica al emitirla; no se usa en dispositivos sin posición)
hEvent Evento que se señaliza al completarse (opcional; se recomienda que sea de reinicio manual)
Internal Estado de la operación. Antes de completarse contiene el equivalente a STATUS_PENDING (de uso interno del sistema)
InternalHigh Número de bytes transferidos al completarse (de uso interno del sistema)
Estructura OVERLAPPED = comprobante de una operaciónOffset: qué posición leerhEvent: cómo enterarse de la finalizaciónInternal/InternalHigh:estado y resultado (los escribe el sistema)Handle (objeto de archivo) = modoModo síncronoEl núcleo gestiona la posición actualReadFile no vuelve hasta completarseModo asíncrono (FILE_FLAG_OVERLAPPED)La posición actual no se gestionaLa emisión y la finalización se separanSe decide una sola vez, en CreateFileSe prepara una por cada emisión de ReadFile/WriteFile

Figura 2: el modo pertenece al handle; el estado, a la operación (el comprobante). Confundir este reparto provoca accidentes

De aquí se derivan de forma natural las dos prohibiciones que la documentación oficial deja explícitas.3

  1. Hace falta una OVERLAPPED por cada operación en curso simultáneamente. Si se emiten tres, hacen falta tres. Reutilizarla lleva a «resultados impredecibles o corrupción de datos».
  2. Hasta la finalización, tanto OVERLAPPED como el búfer de datos deben mantenerse vivos y no deben tocarse. El núcleo va a escribir en esa zona de memoria. Emitir con un OVERLAPPED en variable local y salir de la función es el accidente típico de dejar que el núcleo pise la pila.

3.3. La emisión puede devolver tres resultados

Un ReadFile sobre un handle asíncrono puede volver de tres maneras.2

ReadFile(handle asíncrono, con OVERLAPPED)¿Qué devuelve?TRUESe completó en el acto (finalización síncrona)Por defecto, también llega la notificación de finalizaciónFALSE + ERROR_IO_PENDINGAceptada. La finalización se notificará despuésFALSE + otro errorLa propia emisión fallóEspera la notificación de finalización(las 4 vías del capítulo 4)

Figura 3: las tres ramas de una emisión asíncrona. La E/S asíncrona solo funciona bien cuando se manejan correctamente tanto TRUE (finalización inmediata) como ERROR_IO_PENDING

A la hora de traducirlo a código, la decisión se toma combinando el valor de retorno con GetLastError. Esta tabla es directamente el árbol de decisión.

Valor de retorno de ReadFile GetLastError() Significado Qué debe hacer quien llama
TRUE (no se consulta) Se completó en el acto (finalización síncrona) Por defecto también llega la notificación de finalización. Deje el procesamiento del resultado al lado de la notificación
FALSE ERROR_IO_PENDING (997) Aceptada. En curso No haga nada. Espere la notificación de finalización sin tocar ni OVERLAPPED ni el búfer
FALSE Cualquier otro valor La propia emisión falló La notificación de finalización no llegará. Maneje el error en el acto y limpie OVERLAPPED y el búfer
// C++ / Win32
// hFile : handle abierto con FILE_FLAG_OVERLAPPED
// ov    : OVERLAPPED reservado exclusivamente para esta operación (Offset y hEvent ya están configurados)
// buf/len: búfer exclusivo de esta operación. No se libera hasta recibir la notificación de finalización
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
    // En una emisión asíncrona se pasa NULL en lpNumberOfBytesRead,
    // y el número de bytes transferidos se obtiene después con GetOverlappedResult
    if (ReadFile(hFile, buf, len, nullptr, ov))
    {
        // (1) Finalización síncrona. Por defecto también llega la notificación, así que aquí no se procesa el resultado
        return ERROR_SUCCESS;
    }

    DWORD err = GetLastError();
    if (err == ERROR_IO_PENDING)
    {
        // (2) Aceptada. Se espera la notificación de finalización sin tocar ov ni buf
        return ERROR_IO_PENDING;
    }

    // (3) Falló la propia emisión. No llegará notificación de finalización, así que quien llama limpia aquí mismo
    return err;
}

ERROR_IO_PENDING no es un error, sino una «aceptación». Los dos errores típicos son tratarlo como un error normal, o justo lo contrario, escribir el código sin contemplar el caso TRUE (finalización síncrona). Por qué ocurre la finalización síncrona lo veremos en el capítulo 5.

Y aquí hay un aviso importante. Por defecto, incluso una operación que se completó de forma síncrona (TRUE) recibe igualmente una notificación de finalización aparte. Si el handle está asociado a un puerto de finalización de E/S, el paquete de finalización se encola; si se usa el esquema de evento, el evento también se señaliza. Por eso, si el código dice «si es TRUE, proceso el resultado ahí mismo, y si además llega la notificación, lo proceso otra vez», se produce el accidente de procesar dos veces la misma operación y liberar dos veces el comprobante. La forma básica y segura es concentrar el procesamiento del resultado en el lado de la notificación, para las dos rutas «TRUE (finalización síncrona)» y «ERROR_IO_PENDING». Ahora bien, existe una tercera ruta: cuando la propia emisión falla (FALSE + otro error), no llega ninguna notificación de finalización. Si se deja esta ruta esperando una notificación, la espera es eterna, así que en el punto de emisión hay que manejar el error y limpiar el comprobante ahí mismo. Solo cuando se quiere cambiar a «omitir la notificación en la finalización síncrona y procesar en el acto» se activa explícitamente SetFileCompletionNotificationModes (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); pero lo que esta función suprime es únicamente el paquete al puerto de finalización de E/S, no la señalización de OVERLAPPED.hEvent. Es una optimización exclusiva de la ruta de IOCP, que no sirve para el esquema de evento (capítulo 5).12

Además, si se pasa un OVERLAPPED a un handle en modo síncrono, la lectura sí parte de la posición de Offset, pero el comportamiento de bloquear hasta completarse no cambia.2 No es que «pasar un OVERLAPPED lo vuelva asíncrono»: el modo lo tiene el handle, y solo el handle.

4. Cómo enterarse de la finalización ── cuatro vías de notificación

Una vez que la emisión y la finalización están separadas, cómo recibir el «ya terminó» pasa a ser el centro del diseño. En la práctica hay cuatro vías.1

La E/S se completa en el núcleo(IoCompleteRequest → el resultado se confirma vía APC)(1) El handle de archivo pasa a estado señalizadoSe recibe con: WaitForSingleObject(handle)(2) El hEvent de OVERLAPPED pasa a estado señalizadoSe recibe con: WaitForSingleObject + GetOverlappedResult(3) Se encola una rutina de finalización en la cola de APC del hilo emisorSe recibe con: se ejecuta durante una espera alertable como SleepEx(4) Llega un paquete de finalización al puerto de finalización de E/SSe recibe con: GetQueuedCompletionStatus (Parte 3)

Figura 4: las cuatro vías de notificación de finalización. Cuál se usa depende de cómo se emitió la operación (si hay hEvent, si se usó ReadFileEx, si el handle está asociado a un puerto)

Antes de entrar en detalle, presentamos la visión de conjunto en una tabla. Cada apartado explica el contenido de esta tabla.

Método Hilo donde corre el procesamiento de finalización Número de E/S simultáneas posibles Escenario adecuado
(1) Señal del handle Cualquier hilo que estuviera esperando En la práctica, solo una. Con varias operaciones no se puede distinguir cuál se completó Prácticamente ninguno (4.1)
(2) Evento + GetOverlappedResult Cualquier hilo que estuviera esperando Hace falta un evento por operación. Si se agrupan con WaitForMultipleObjects, el límite es 64 Unas pocas E/S simultáneas. Comunicación con dispositivos (4.2)
(3) APC (ReadFileEx) El hilo que emitió la operación, y solo mientras está en una espera alertable Sin límite de cantidad, pero todo el procesamiento de finalización corre en serie en ese único hilo Procesamiento de comunicaciones que se quiere resolver en un solo hilo (4.3)
(4) Puerto de finalización de E/S El grupo de hilos trabajadores asociado al puerto Permite atender muchas E/S con pocos hilos Servidores, grupos de subprocesos (4.4)

4.1. Señal del handle ── no la use

Si se emite sin establecer hEvent, al completarse es el propio handle de archivo el que pasa a estado señalizado. Parece cómodo a primera vista, pero si hay varias operaciones en vuelo sobre el mismo handle, no hay forma de distinguir cuál se completó.1 Salvo en el caso especial de «emitir la E/S asíncrona de una en una», lo más seguro es no usar este método.

4.2. Evento + GetOverlappedResult ── la forma básica

Se emite con un evento de reinicio manual en OVERLAPPED.hEvent, se espera con WaitForSingleObject (o WaitForMultipleObjects para varias a la vez), y con GetOverlappedResult se extrae el resultado (éxito o fracaso, y bytes transferidos).13 Si se pasa TRUE en el bWait de GetOverlappedResult, también es posible «esperar a la finalización y extraer el resultado» en un solo paso. Si el evento se configura como de reinicio automático existe la trampa de que, si otra espera consume la señal, GetOverlappedResult se queda colgado; por eso se recomienda el reinicio manual.134

Es el método más claro y sólido para manejar unas pocas E/S simultáneas. En dispositivos como los puertos serie, donde es imprescindible «leer mientras se escribe», esta forma sigue vigente hoy («Los puntos ciegos de las aplicaciones de comunicación serie»).

4.3. APC ── se entrega al hilo que emitió la operación

ReadFileEx/WriteFileEx reciben, en lugar de un evento, una rutina de finalización (callback). Al completarse, esa rutina se encola en la cola de APC del hilo que hizo la emisión, y se ejecuta cuando ese hilo entra en una espera alertable como SleepEx o WaitForSingleObjectEx.14515

La característica de este método es que el procesamiento de finalización siempre corre en el hilo emisor. Esto elimina la necesidad de bloqueos (locks), pero a cambio, mientras el hilo emisor no entre en una espera alertable, la rutina de finalización nunca se ejecuta. Combinarlo con el bucle de mensajes del hilo de la interfaz de usuario exige MsgWaitForMultipleObjectsEx, entre otras complicaciones en el diseño de la espera, así que para uso general suele preferirse el evento o el IOCP.

Y «el APC nunca llega» es el error típico de este método. La causa casi siempre es una sola: la espera no es alertable.

// C++ / Win32. hFile es un handle abierto con FILE_FLAG_OVERLAPPED,
// ov y buf se mantienen vivos hasta la finalización (sección 3.2)

// Mal ejemplo: la rutina de finalización nunca se llama
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // Espera no alertable. El APC no se entrega

// Buen ejemplo: mantener una espera alertable hasta que esta E/S termine
//
// Esta bandera la activa la rutina de finalización (por ejemplo, en una estructura asociada a ov)
volatile bool completed = false;

// Compruebe siempre si la emisión tuvo éxito. Si devuelve 0, no se encoló ninguna rutina de finalización
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // Tómelo de inmediato; otra API lo sobrescribirá
    ReportError(err);                   // Dispositivo desconectado, handle inválido, etc.
    return;                             // ★ No entre en el bucle de espera de abajo
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // El TRUE del segundo argumento es lo que hace la espera alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // Se ejecutó algún APC. Pero no tiene por qué ser el de esta I/O,
        // así que se comprueba completed y, si no coincide, se vuelve a esperar
        continue;
    }
    // Volvió por timeout. La I/O sigue en curso, así que
    // si se va a abortar, se cancela con CancelIoEx y se espera a que llegue la finalización
    CancelIoEx(hFile, ov);
}

No entre en el bucle de espera sin comprobar antes el valor de retorno de ReadFileEx. Si la propia emisión falla, por ejemplo porque el dispositivo se acaba de desconectar o el handle ya no es válido, ReadFileEx devuelve 0 y no se encola ninguna rutina de finalización. Si en ese estado se entra en while (!completed), completed nunca se activa, y el resultado es un bucle que repite SleepEx y CancelIoEx sin fin para una I/O que ya no existe. Y como en apariencia parece solo que «el dispositivo no responde», llegar hasta la causa real lleva mucho tiempo. Si devuelve 0, tome GetLastError() en el acto (basta con intercalar una sola API para que se sobrescriba) y salga sin entrar en la espera.

Llamar a SleepEx una sola vez no es suficiente. Al volver por timeout, en ese mismo instante el hilo sale de la espera alertable. La I/O sigue en curso, así que si después se sale del ámbito (scope) y ov o buf desaparecen, el núcleo termina pisando un búfer que cree que sigue vivo (sección 3.2). Registre la finalización desde la propia rutina de finalización y siga esperando hasta que se active, o bien cancele con CancelIoEx y espere a que llegue la finalización; elija una de las dos opciones.

Que se devuelva WAIT_IO_COMPLETION solo significa que «se ejecutó uno o más APC», y no garantiza que sea la rutina de finalización de esta I/O. Si en el mismo hilo hay otra I/O o un APC de QueueUserAPC en cola, la espera puede volver por eso. Por eso no hay que decidir solo con el valor de retorno, sino comprobar la bandera que uno mismo activó.

Por lo demás, elegir la función de espera correcta es simple: cambie Sleep por SleepEx(..., TRUE), y WaitForSingleObject por WaitForSingleObjectEx(..., TRUE). Cuando se ha escrito una rutina de finalización y no pasa nada, lo primero que hay que comprobar es si la función de espera termina en Ex y si el argumento de alertable es TRUE.5

4.4. Puerto de finalización de E/S ── la opción que escala (próxima entrega)

El mecanismo pensado para atender muchas E/S simultáneas con pocos hilos es el puerto de finalización de E/S (IOCP). Al asociar un handle al puerto, las finalizaciones entran en la cola del puerto y los hilos trabajadores las extraen con GetQueuedCompletionStatus.6 Es también el lugar al que, en última instancia, llega la E/S de async/await en .NET. La próxima entrega la dedicamos entera a este tema.

5. El problema de «debería ser asíncrono, pero se completa de forma síncrona»

Este es el primer tropiezo típico al diseñar E/S asíncrona. Aunque se emita correctamente en modo asíncrono, es perfectamente normal que la E/S se complete de forma síncrona. Microsoft, en su documento de solución de problemas, deja explícitas las razones más habituales.3

Ninguna de las anterioresSe emite ReadFile/WriteFile sobre un handle asíncrono¿Se cumple alguna condición de finalización síncrona?Solicitud que se puede satisfacer de inmediato(por ejemplo, los datos ya están en la caché)Archivo comprimido con NTFS(un archivo comprimido no se vuelve asíncrono)Archivo cifrado con NTFS (EFS)Escritura que alarga la longitud del archivoVuelve TRUE al instante= se ejecutó hasta el final dentro de la propia llamadaVuelve con ERROR_IO_PENDING= sigue en curso, de verdad, de forma asíncrona

Figura 5: las principales condiciones que producen finalización síncrona. La caché, la compresión, el cifrado y la escritura que alarga el archivo «no se vuelven asíncronas»

Cada una tiene su propia razón.3

  • Acierto de caché. Muchos controladores tienen un tratamiento especial: «la solicitud que se puede completar de inmediato, se completa en el acto». En el caso de un disco, esto ocurre cuando los datos ya están en la caché de memoria. Al ser más rápido no debería haber queja, pero es aquí donde se rompe el código que da por hecho que «siempre vuelve ERROR_IO_PENDING».
  • A la inversa, cuando los datos no están en la caché también hay una trampa. La caché de Windows está implementada mediante mapeo de archivos (file mapping), y como el manejo de fallos de página (page fault) no tiene un mecanismo asíncrono, una lectura asíncrona con la caché habilitada puede terminar procesándose de forma síncrona. El propio mecanismo de la caché lo trataremos en la Parte 4.
  • Compresión NTFS y cifrado EFS. El controlador del sistema de archivos convierte a síncrono el acceso a archivos comprimidos o cifrados.
  • Escritura que alarga el archivo. Una escritura que cambia la longitud del archivo se vuelve síncrona.

Las implicaciones prácticas son simples.

  1. Escriba siempre la ruta «vuelve TRUE al instante». Las tres ramas de la figura 3 son todas casos normales. Ahora bien, por defecto la notificación de finalización llega igualmente aunque sea síncrona, así que lo seguro es concentrar el procesamiento del resultado en la ruta de notificación (sección 3.3).
  2. No sirve como garantía de capacidad de respuesta. No se sostiene la idea de que «como es asíncrono, la interfaz no se congela». En los hilos que no deben congelarse hace falta un diseño que directamente no emita E/S en ellos (separándola a un hilo dedicado o a un grupo de subprocesos). Ya tratamos este aspecto práctico en «Guía práctica para acercarse al tiempo real blando en un Windows normal».
  3. En E/S de alta frecuencia, la finalización síncrona también es una oportunidad de optimización. Existe una API, SetFileCompletionNotificationModes, que evita el paquete al puerto de finalización de E/S cuando la finalización es síncrona, y resulta eficaz combinada con IOCP (Parte 3).12

6. Cancelación y limpieza ── «deténlo» es una solicitud

Cuando se quiere detener una E/S de larga duración (un destino de red que no responde, datos de un puerto serie que no llegan), el método correcto es CancelIoEx.7

ControladorAdministrador de E/SAplicaciónControladorAdministrador de E/SAplicaciónMarca el IRP no completado correspondientecomo solicitud de cancelaciónSi se puede cancelar, se interrumpesi está a punto de completarse, puede completarse con normalidadSolo tras comprobar esta notificaciónse liberan OVERLAPPED y el búferCancelIoEx(handle, OVERLAPPED)Llama a la rutina de cancelaciónIoCompleteRequest(STATUS_CANCELLED)Llega la notificación de finalizaciónGetOverlappedResult devuelve ERROR_OPERATION_ABORTED

Figura 6: la cancelación en la práctica. Incluso una operación cancelada vuelve como una «finalización»

Conociendo el mecanismo, hay tres consecuencias naturales.

  • La cancelación es una «solicitud» asíncrona. Aunque CancelIoEx tenga éxito, eso solo significa que «lo marcó». Una operación que ya estaba a punto de terminar puede completarse con normalidad.8
  • Una operación cancelada también vuelve, mediante la notificación de finalización, como ERROR_OPERATION_ABORTED. Hasta que se recibe esa notificación, el núcleo sigue usando OVERLAPPED y el búfer. Liberarlos antes provoca corrupción de memoria. La causa de «desde que añadí la cancelación, empezó a caerse» casi siempre es esta.78
  • Antes de cerrar el handle, resuelva la E/S no completada. Como vimos en la Parte 1, cuando se cierra el último handle, el procesamiento de limpieza (cleanup) cancela automáticamente los IRP no completados, pero el código que «cierra solo el handle mientras quedan I/O emitidas pendientes» tiende a romper la gestión de la notificación de finalización y del ciclo de vida del búfer. El orden correcto es cancelar → comprobar la finalización → cerrar.

Dos apuntes adicionales. El antiguo CancelIo solo puede cancelar las operaciones emitidas por el propio hilo que lo llama (una limitación que existió hasta que CancelIoEx llegó con Vista; hoy no hay motivo para usarlo).16 Y para otro hilo que está bloqueado en E/S síncrona se usa CancelSynchronousIo.11 «El sistema operativo no se ocupa del timeout por usted; la cancelación hay que diseñarla uno mismo»: este es el núcleo de la práctica de la E/S asíncrona.

7. Desde la óptica de .NET ── el desajuste de modos genera «asincronía aparente»

Todo lo visto hasta aquí conecta directamente con el código de .NET. El useAsync (o FileOptions.Asynchronous) del constructor de FileStream es, literalmente, el interruptor directo de FILE_FLAG_OVERLAPPED (véase la tabla de correspondencias de la Parte 1).

Noawait fs.ReadAsync(...)¿El handle está en modo asíncrono(FileOptions.Asynchronous)?E/S asíncrona realSe emite el equivalente a OVERLAPPEDy la finalización llega al grupo de subprocesos vía IOCP (Parte 3)Asincronía aparenteUn hilo del grupo de subprocesosse hace cargo de un Read síncrono y espera

Figura 7: aunque el ReadAsync sea el mismo, lo que ocurre por debajo es completamente distinto según el modo del handle

La diferencia está en una sola línea: la que abre el archivo. El lado que llama a ReadAsync queda igual, así que leyendo el código no se nota.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];

// (A) Asincronía aparente. Si se omite useAsync o se pone en false, el handle se abre en modo síncrono
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // El llamador no queda bloqueado, pero por detrás un hilo del grupo de subprocesos se hace cargo de un Read síncrono y espera
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (B) Asincronía real. useAsync: true se conecta directamente a FILE_FLAG_OVERLAPPED
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: true))
{
    // La finalización llega al grupo de subprocesos vía IOCP (Parte 3)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) .NET 6 en adelante. Forma directa que expone el modo y el desplazamiento (offset)
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

La diferencia entre (A) y (B) está solo en la palabra useAsync (escribir FileOptions.Asynchronous da lo mismo), y esa es exactamente la condición que reproduce la «asincronía aparente». Al revisar código existente, busque el lugar donde se crea el FileStream, no el lado de ReadAsync/WriteAsync. Sobrecargas cortas como File.OpenRead o new FileStream(path, FileMode.Open) abren siempre en modo síncrono. Además, si se crea un FileStream a partir de un SafeFileHandle, el argumento isAsync debe coincidir con el modo real del handle.

  • Handle en modo síncrono + ReadAsync es la «asincronía aparente»: la lectura síncrona la hace un hilo del grupo de subprocesos. El llamador no espera, pero por detrás hay un hilo durmiendo. Si son pocos casos el daño real es pequeño, pero en servidores o procesamiento de alta frecuencia se convierte en causa de agotamiento del grupo de subprocesos.9
  • Handle en modo asíncrono + Read síncrono es el desajuste en sentido contrario: se produce un desperdicio por la espera interna de finalización. El principio es hacer coincidir el modo con la API que se llama.10
  • Desde .NET 6, el interior de FileStream se reescribió por completo, y además se añadió la API File.OpenHandle + RandomAccess, que permite «leer y escribir indicando explícitamente el SafeFileHandle y el desplazamiento».9 Esta forma de pasar el desplazamiento cada vez es, ni más ni menos, la figura desnuda de Win32 que vimos en este artículo: handle asíncrono + OVERLAPPED.Offset.
  • Si el handle está en modo asíncrono, la cancelación de E/S de archivo mediante CancellationToken termina, internamente, en CancelIoEx. Detrás de un ReadAsync al que se le pasó el token y que termina con OperationCanceledException está funcionando, tal cual, el diagrama del capítulo 6. También se conserva que la cancelación es una «solicitud» y que no se garantiza su inmediatez. En cambio, en la «asincronía aparente» de un handle en modo síncrono no existe ninguna operación superpuesta (overlapped) a la que apuntar la cancelación, así que esta ruta no está disponible. Los runtimes recientes de .NET incorporan además un mecanismo que intenta cancelar, mediante CancelSynchronousIo, este tipo de llamadas en ejecución síncrona, pero que funcione depende de la versión del runtime y del tipo de operación, y no se garantiza una interrupción segura. Si la cancelación forma parte del diseño, lo correcto es igualar los modos y usar E/S asíncrona real.

Para la práctica de más arriba en la pila —cómo escribir async/await, ConfigureAwait, la relación con el hilo de la interfaz de usuario— consulte «Tabla práctica de decisiones para C# async/await» y «Async en WPF/WinForms y el hilo de la interfaz de usuario, en una sola hoja». Este artículo es el primer sótano de ese edificio; la próxima entrega (IOCP) será el segundo.

8. Resumen

  • La E/S síncrona y la asíncrona no son dos tuberías distintas: la diferencia es si el administrador de E/S espera la finalización o vuelve sin esperar. El hilo de una E/S síncrona duerme en estado de espera y no consume CPU.1
  • El modo pertenece al handle (el objeto de archivo); el estado, a la operación (OVERLAPPED). Como en un handle asíncrono no se gestiona el puntero de archivo, en un archivo con posición hay que indicarla cada vez mediante Offset.24
  • OVERLAPPED y el búfer deben mantenerse vivos y sin tocar hasta la notificación de finalización. Se prepara uno por cada emisión simultánea. Reutilizarlos provoca corrupción de datos.3
  • La notificación de finalización tiene cuatro vías: handle, evento, APC e IOCP. Con varias E/S simultáneas no use la señal del handle, use eventos de reinicio manual y recuerde que el APC exige una espera alertable.1135
  • Aunque se emita de forma asíncrona, con la caché, la compresión y el cifrado de NTFS y la escritura que alarga el archivo la operación se completa de forma síncrona. Escriba siempre la ruta «vuelve TRUE al instante» como un caso normal, y no la use como garantía de capacidad de respuesta.3
  • La cancelación es una solicitud. Incluso después de CancelIoEx, haga la limpieza solo tras comprobar la notificación de finalización (ERROR_OPERATION_ABORTED). El orden es cancelar → confirmar la finalización → cerrar.78
  • El FileOptions.Asynchronous de .NET es el equivalente directo de FILE_FLAG_OVERLAPPED, y el desajuste entre el modo y la API genera «asincronía aparente». Por debajo de CancellationToken es CancelIoEx quien está trabajando.910

La continuación es la Parte 3, «Puertos de finalización de E/S (IOCP) y el grupo de subprocesos de .NET ── el sótano de async/await». Bajaremos hasta explicar por qué el IOCP, que en esta entrega solo mencionamos por su nombre en la sección 4.4, integra en un mismo diseño «la cola de notificaciones de finalización» y «el control del número de hilos en ejecución», y en qué hilo termina corriendo la continuación de un await.

Artículos relacionados

Áreas de consultoría relacionadas

En KomuraSoft LLC nos ocupamos del diseño de aplicaciones empresariales y de comunicación con dispositivos en Windows que usan E/S asíncrona, así como de la investigación de causas de fallos como «se congela», «se cae al cancelar» o «se agota el grupo de subprocesos».

Referencias

  1. Microsoft Learn, Synchronous and asynchronous I/O. Sobre que en la E/S síncrona la función se bloquea hasta que la E/S se completa, mientras que en la E/S asíncrona la función que emitió la solicitud vuelve de inmediato y el hilo puede seguir con otro trabajo; que la E/S asíncrona requiere abrir el handle indicando FILE_FLAG_OVERLAPPED; que como métodos de notificación de finalización existen la señal del handle de archivo, la señal del evento indicado en la estructura OVERLAPPED, la rutina de finalización (APC) ejecutada en una espera alertable, y el puerto de finalización de E/S; y que cuando hay varias operaciones emitidas al mismo tiempo, la señal del handle de archivo no permite distinguir cuál se completó.  2 3 4 5 6 7 8 9

  2. Microsoft Learn, ReadFile function. Sobre que en un handle abierto con FILE_FLAG_OVERLAPPED, lpOverlapped es obligatorio y la posición de inicio de lectura se indica mediante Offset/OffsetHigh de la estructura OVERLAPPED; que cuando se procesa de forma asíncrona se devuelve FALSE y ERROR_IO_PENDING; que el sistema no mantiene el puntero de archivo de un handle asíncrono; y que si se pasa un OVERLAPPED a un handle abierto sin FILE_FLAG_OVERLAPPED, la lectura parte del desplazamiento indicado pero ReadFile no vuelve hasta que la lectura se completa.  2 3 4 5

  3. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. Sobre las razones por las que una E/S codificada como asíncrona termina completándose de forma síncrona: un archivo comprimido con NTFS (el controlador del sistema de archivos no accede de forma asíncrona a los archivos comprimidos, así que todas las operaciones se vuelven síncronas), un archivo cifrado con NTFS, una escritura que alarga la longitud del archivo, y que cuando la solicitud se puede satisfacer de inmediato (por ejemplo, cuando los datos ya están en la caché de memoria) el controlador completa la operación en el acto y devuelve TRUE; que la caché de Windows está implementada mediante mapeo de archivos y no dispone de un mecanismo asíncrono de fallo de página cuando la página no está presente; y además, que si se emiten tres E/S hacen falta tres estructuras OVERLAPPED, que reutilizarlas lleva a resultados impredecibles o corrupción de datos, y que no se debe leer ni escribir en el búfer de datos correspondiente hasta que la operación se complete.  2 3 4 5 6 7

  4. Microsoft Learn, OVERLAPPED structure. Sobre que la estructura OVERLAPPED conserva la información para la entrada/salida asíncrona; que Offset/OffsetHigh contienen la posición del archivo, hEvent el evento que se señaliza al completarse, e Internal/InternalHigh el código de estado de la operación y el número de bytes transferidos; que mientras la operación se ejecuta no se debe modificar la estructura, la cual debe mantenerse válida; y las precauciones al usar el evento.  2 3 4

  5. Microsoft Learn, Alertable I/O. Sobre que en la E/S alertable la entrada a la rutina de finalización se encola en la cola de APC del hilo; que el APC se ejecuta cuando el hilo entra en estado alertable mediante SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx, etc.; y que el APC siempre se ejecuta en el contexto del hilo que lo emitió.  2 3 4

  6. Microsoft Learn, I/O Completion Ports. Sobre que el puerto de finalización de E/S ofrece un modelo de hilos eficiente para procesar muchas solicitudes de E/S asíncrona en un sistema multiprocesador; que al asociar un handle de archivo al puerto, el paquete de finalización se encola y los hilos trabajadores lo extraen con GetQueuedCompletionStatus; y que el puerto controla el número de hilos que se ejecutan de forma concurrente.  2

  7. Microsoft Learn, CancelIoEx function. Sobre que CancelIoEx marca como cancelada la E/S no completada de un handle indicado, sin importar qué hilo la emitió; que si se especifica lpOverlapped, solo se apunta a esa operación, y con NULL se apunta a todas las operaciones no completadas; que una operación cancelada se completa con ERROR_OPERATION_ABORTED; y que no se garantiza la cancelación de todas las operaciones, por lo que hay que esperar a que el procesamiento de finalización termine.  2 3 4

  8. Microsoft Learn, Canceling pending I/O operations. Sobre el mecanismo de cancelación de E/S no completada; que aunque se solicite la cancelación, la operación puede estar ya encaminada hacia la finalización; que hay que liberar los recursos solo después de confirmar la finalización de la operación cancelada; y la distinción entre usar CancelSynchronousIo para operaciones síncronas y CancelIo/CancelIoEx para operaciones asíncronas.  2 3 4

  9. Microsoft .NET Blog, File IO improvements in .NET 6. Sobre que en .NET 6 se reescribió por completo la implementación interna de FileStream; que la estrategia varía según si el handle está abierto en modo asíncrono o no; que con File.OpenHandle se obtiene directamente un SafeFileHandle y con RandomAccess se pueden hacer lecturas y escrituras (seguras entre hilos) indicando explícitamente el desplazamiento; y que las llamadas asíncronas sobre un handle que no está en modo asíncrono se descargan al grupo de subprocesos.  2 3 4

  10. Microsoft Learn, Asynchronous file I/O (.NET). Sobre el planteamiento de la E/S de archivo asíncrona en .NET; que para usar E/S asíncrona con FileStream hay que indicar useAsync (FileOptions.Asynchronous) en el constructor para habilitar la E/S asíncrona a nivel de sistema operativo; y la distinción entre usar métodos síncronos o asíncronos.  2 3

  11. Microsoft Learn, CancelSynchronousIo function. Sobre que CancelSynchronousIo marca como cancelada la operación de E/S síncrona que un hilo indicado está ejecutando, y que la operación cancelada se devuelve como fallida con ERROR_OPERATION_ABORTED.  2

  12. Microsoft Learn, SetFileCompletionNotificationModes function. Sobre que FILE_SKIP_COMPLETION_PORT_ON_SUCCESS permite elegir que, cuando la E/S tiene éxito de inmediato, no se encole el paquete de finalización en el puerto de finalización de E/S, y que FILE_SKIP_SET_EVENT_ON_HANDLE permite omitir la señalización del evento del handle de archivo.  2

  13. Microsoft Learn, GetOverlappedResult function. Sobre que GetOverlappedResult obtiene el resultado de una operación asíncrona (éxito o fracaso y bytes transferidos); que pasar TRUE en bWait hace que espere hasta la finalización de la operación; y que si se especifica un evento de reinicio automático en el hEvent de OVERLAPPED y otra espera consume la señal, una llamada con bWait=TRUE puede no detectar la finalización y quedarse esperando indefinidamente, por lo que conviene usar un evento de reinicio manual.  2 3

  14. Microsoft Learn, ReadFileEx function. Sobre que ReadFileEx recibe una rutina de finalización (FileIOCompletionRoutine) que se llama cuando la lectura se completa; que la rutina de finalización se ejecuta cuando el hilo que la llamó está en estado de espera alertable; y que se necesita un handle abierto con FILE_FLAG_OVERLAPPED. 

  15. Microsoft Learn, Asynchronous Procedure Calls. Sobre que un APC es una función que se ejecuta de forma asíncrona en el contexto de un hilo determinado, que cada hilo tiene su propia cola de APC, y que un APC en modo usuario solo se ejecuta cuando el hilo está en estado alertable. 

  16. Microsoft Learn, CancelIo function. Sobre que CancelIo solo puede cancelar las operaciones de E/S emitidas por el propio hilo que la llama, y que para cancelar también las operaciones emitidas por otros hilos hay que usar CancelIoEx. 

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é cambia al añadir FILE_FLAG_OVERLAPPED?
El objeto de archivo que hay detrás del handle se abre en «modo asíncrono». Esta es una propiedad que se decide por handle en el momento de llamar a CreateFile, y no se puede alternar entre síncrono y asíncrono llamada por llamada. En un handle en modo asíncrono siempre hay que pasar una estructura OVERLAPPED a ReadFile/WriteFile. El sistema no gestiona el puntero de archivo (la posición actual) de este handle, así que en dispositivos que tienen posición, como un archivo en disco, la posición de lectura o escritura se especifica cada vez mediante el campo Offset de OVERLAPPED (en dispositivos sin noción de posición, como un puerto serie, Offset no se usa). Una operación emitida puede devolver el control antes de completarse; en ese caso ReadFile devuelve FALSE y GetLastError indica ERROR_IO_PENDING. La finalización se recibe mediante una notificación: un evento, un APC, un puerto de finalización de E/S, etc.
¿Por qué, si emito una E/S asíncrona, a veces vuelve completada de inmediato?
Porque el modo asíncrono significa «no hace falta esperar la finalización», no «nunca se le hará esperar». La documentación de Microsoft menciona como razones típicas por las que una E/S emitida de forma asíncrona termina completándose de forma síncrona: que la solicitud pueda satisfacerse de inmediato (por ejemplo, porque los datos ya están en la caché), un archivo comprimido con NTFS, un archivo cifrado con NTFS (EFS) y una escritura que alarga la longitud del archivo. En estos casos ReadFile/WriteFile devuelve TRUE y el resultado ya está confirmado en el momento de la llamada. Por eso el código que usa E/S asíncrona debe contemplar tanto el caso «vuelve con ERROR_IO_PENDING» como el caso «se completa en el acto», y la capacidad de respuesta tampoco queda garantizada de forma absoluta. Además, por defecto, incluso en una operación que se completó de forma síncrona llega igualmente una notificación de finalización aparte (la señalización de un evento o un paquete en el puerto de finalización de E/S), así que lo más seguro es concentrar el procesamiento del resultado en el lado de la notificación.
¿Se puede reutilizar una estructura OVERLAPPED?
No se debe compartir entre varias operaciones simultáneas. La estructura OVERLAPPED representa «el estado de una operación emitida», y la documentación de Microsoft indica explícitamente que si se emiten tres E/S hacen falta tres estructuras OVERLAPPED, y que reutilizar una lleva a resultados impredecibles o a la corrupción de datos. Hasta que la operación se completa hay que mantener válidos tanto la estructura como el búfer de lectura o escritura, y no se debe tocar su contenido. Si se va a reutilizar la estructura después de que se complete una operación, hay que reinicializarla cada vez para que los datos que quedaron de la vez anterior no influyan. Para hEvent, lo más seguro es usar un evento de reinicio manual.
¿Cómo se cancela una E/S que ya está en curso?
CancelIoEx permite solicitar la cancelación de la E/S pendiente de un handle determinado, sin importar qué hilo la emitió. Si se pasa un OVERLAPPED como segundo argumento se apunta a una única operación concreta; con NULL se apunta a todas las operaciones de ese handle. El antiguo CancelIo solo puede cancelar «las operaciones que emitió el propio hilo que lo llama». Lo importante es que la cancelación es una «solicitud», no una «garantía» inmediata. Una operación que ya estaba a punto de completarse puede terminar completándose con normalidad, y una operación cancelada se notifica como completada con ERROR_OPERATION_ABORTED. En ambos casos, hasta que se recibe la notificación de finalización no se debe liberar la estructura OVERLAPPED ni el búfer. Para un hilo que está bloqueado en E/S síncrona desde otro hilo existe una API dedicada, CancelSynchronousIo.
¿Qué ocurre si no se especifica FileOptions.Asynchronous (useAsync) en FileStream de .NET?
El handle se abre en modo síncrono, así que aunque se llame a ReadAsync/WriteAsync no se obtiene una E/S asíncrona real: un hilo del grupo de subprocesos se hace cargo de la lectura o escritura síncrona, produciendo una «asincronía aparente». El hilo que llama no queda bloqueado, pero por detrás hay otro hilo esperando, lo que puede provocar el agotamiento del grupo de subprocesos y una pérdida de escalabilidad. A la inversa, si se abre en modo asíncrono y luego se llama a Read/Write síncronos, se incurre internamente en el coste de esperar la finalización. El principio es hacer coincidir «el modo del handle» con «la API que se llama»; desde .NET 6 en adelante, File.OpenHandle junto con RandomAccess permite escribirlo de forma directa, indicando explícitamente el modo y el desplazamiento (offset).

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