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

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

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

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 2) ── E/S síncrona y asíncrona: el verdadero significado de OVERLAPPED. KomuraSoft LLC. https://comcomponent.com/es/blog/windows-io-sync-async-overlapped/

DOI (archivo registrado)
10.5281/zenodo.22175268
DOI (última versión registrada)
10.5281/zenodo.22175269

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 de la solicitud y su finalización están separadas dentro del núcleo. En esta entrega tratamos la E/S asíncrona (E/S superpuesta), que es cómo una aplicación usa esa separación.

Se añade FILE_FLAG_OVERLAPPED y aun así la llamada espera. Se reutiliza un OVERLAPPED y los datos se corrompen. Se libera el búfer justo después de cancelar y el proceso se cae. La clave para entenderlo es esta división del trabajo: «el modo pertenece al handle, el estado a cada operación, y la limpieza solo después de confirmar la finalización».

Este artículo sigue el recorrido en orden: abrir el archivo, emitir la E/S, recibir el resultado y hacer la limpieza. Una vez asentado el mecanismo de Win32, comprobamos dónde encajan FileStream, ReadAsync y CancellationToken de .NET.

Es la segunda entrega de la serie «Las profundidades de la E/S de Windows». La estructura completa está al comienzo de la parte 1.

1. Primero la conclusión: tres distinciones fáciles de confundir

La E/S asíncrona se vuelve difícil de entender si se juzga solo por el nombre de la API. Empiece por separar qué se configura de en qué momento se decide la finalización.

Fácil de confundir Cómo distinguirlos
El modo del handle y el estado de la operación El modo síncrono o asíncrono se decide en el momento de CreateFile. OVERLAPPED guarda el estado de una sola operación emitida contra ese handle
El resultado de emitir y la recepción de la finalización ERROR_IO_PENDING no es un fallo, sino una aceptación. TRUE es una finalización síncrona, pero por defecto también llega una notificación. No trate el resultado en ambos sitios
La solicitud de cancelación y el momento en que se puede limpiar CancelIoEx es una solicitud de anulación. La estructura y el búfer se liberan después de confirmar la finalización de esa operación

La E/S síncrona no vuelve al llamador antes de completarse. En la E/S asíncrona se puede usar una vía que vuelve antes de la finalización. Sin embargo, incluso en modo asíncrono la operación puede completarse dentro de la llamada, así que no hay una garantía de que «nunca se le hará esperar».12

En la implementación, piense en este orden: decidir el modo → preparar una estructura y un búfer exclusivos de la operación → juzgar el resultado de emitir → recibir la finalización → hacer la limpieza. Aunque haya solicitado la cancelación, no omita la etapa de recibir la finalización.34

Si ya tiene un objetivo concreto, avance desde la guía siguiente.

Qué quiere saber o en qué está atascado Dónde leer primero
En qué se diferencian la E/S síncrona y la asíncrona Capítulo 2: el mecanismo de espera, apartado 3.1: el modo del handle
OVERLAPPED corrompe los datos, o el proceso se cae al salir de la función Apartado 3.2: estado y vida útil por operación
ReadFile vuelve con FALSE, o una finalización síncrona provoca un tratamiento duplicado Apartado 3.3: las tres ramas del resultado de emitir
Quiere elegir cómo recibir la finalización, o no llega la devolución de llamada Capítulo 4: comparación de las vías de notificación, apartado 4.3: cómo esperar un APC
Lo hizo asíncrono y aun así la llamada espera Capítulo 5: condiciones de finalización síncrona y capacidad de respuesta
La cancelación no surte efecto, o el proceso se cae después de cancelar Capítulo 6: limpiar solo después de confirmar la finalización
Usa ReadAsync y aun así aumentan los hilos Capítulo 7: combinación de handles y API en .NET

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 (36 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. E/S síncrona: el hilo que espera la finalización duerme sin consumir CPU

2.1. Quien espera es el administrador de E/S

Un handle abierto sin FILE_FLAG_OVERLAPPED está en modo síncrono. ReadFile no vuelve hasta que la E/S se completa.1

Cuando el controlador deja la solicitud en espera (pendiente) porque aguarda la respuesta del hardware, el administrador de E/S espera la finalización y después devuelve el control a la aplicación. El hilo de la aplicación espera dentro del núcleo durante ese tiempo.

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)Emitir el IRPSTATUS_PENDING (esperando respuesta)Finalización (IoCompleteRequest)Devuelve el resultado y despiertaReadFile vuelve con TRUE/FALSE

Figura 1: E/S síncrona en la que la solicitud queda pendiente. ReadFile vuelve después de esperar la finalización

Ahora bien, que sea E/S síncrona no significa que el hilo duerma siempre. Si la solicitud se puede completar en el acto, por ejemplo por un acierto de caché, el resultado vuelve sin espera (la vía de «finalización inmediata» de la figura 5 de la parte 1). Lo que se garantiza es que «no vuelve antes de completarse».

2.2. No consumir CPU y poder hacer otro trabajo son cosas distintas

Un hilo en estado de espera sale del conjunto de hilos ejecutables del programador, así que no consume CPU. Por qué conviene dejarlo esperar en lugar de seguir sondeando uno mismo también se explica en «Por qué priorizar la espera por eventos sobre Sleep(1) en Windows».

Por otro lado, un hilo en espera no puede hacer otro trabajo. Si es el hilo de la interfaz de usuario, la pantalla se congela; si un servidor prepara un hilo por cada conexión, con unos cientos de conexiones aumentan los hilos. El punto débil de la E/S síncrona no es el uso de CPU, sino que ese hilo no se puede usar hasta que se complete.

En modo síncrono el núcleo también gestiona el puntero de archivo (la posición actual). Por eso las llamadas consecutivas a ReadFile leen «a partir de donde quedó la anterior». La posición pertenece al objeto de archivo que hay detrás del handle, así que los handles duplicados con DuplicateHandle comparten la posición (apartado 3.3 de la parte 1).

Además existe CancelSynchronousIo, que solicita la cancelación de una E/S síncrona en ejecución en otro hilo. El capítulo 6 resume cuándo usarla frente a las API pensadas para E/S asíncrona.5

3. Preparar y emitir E/S asíncrona: separar el modo, el estado y el valor de retorno

3.1. El modo asíncrono se decide al abrir el archivo

Si se pasa FILE_FLAG_OVERLAPPED a CreateFile, el objeto de archivo detrás del handle pasa a modo asíncrono. El modo no se alterna en cada llamada. Se puede abrir el mismo archivo con dos handles, uno síncrono y otro asíncrono; en ese caso también hay dos objetos de archivo.1

En modo asíncrono el sistema no gestiona el puntero de archivo. Como se pueden emitir varias operaciones a la vez, en un archivo en disco la posición de lectura o escritura se indica cada vez con OVERLAPPED.Offset / OffsetHigh. En dispositivos sin posición de búsqueda, como un puerto serie o una canalización con nombre, esa posición no se usa y se deja en 0. Aunque no se indique posición, hace falta un OVERLAPPED exclusivo de la operación.6

Al contrario, pasar un OVERLAPPED a un handle en modo síncrono no lo vuelve asíncrono. Lee desde la posición de Offset, pero el comportamiento de bloquear hasta completarse no cambia. Lo que hay que comprobar no es si se pasó la estructura, sino en qué modo se abrió el handle.6

3.2. Correspondencia de un OVERLAPPED y un búfer con una sola operación

OVERLAPPED es la estructura que identifica una operación en curso y transporta su posición, su estado y su resultado. Pensarla como «el comprobante de una operación» aclara la división del trabajo con el handle.3

Miembro Función
Offset / OffsetHigh Posición del archivo que esta operación lee o escribe (se indica al emitir; no se usa en dispositivos sin posición)
hEvent Evento que se señaliza al completarse (opcional; se recomienda uno de reinicio manual)
Internal Estado de la operación. Antes de completarse contiene el equivalente de STATUS_PENDING (uso del sistema)
InternalHigh Número de bytes transferidos al completarse (uso del sistema)
Estructura OVERLAPPED = comprobante de una operaciónOffset: de dónde 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)no se gestiona la posición actualla emisión y la finalización se separanSe decide una sola vez, en CreateFileSe prepara uno en cada emisión de ReadFile/WriteFile

Figura 2: El handle guarda el modo; OVERLAPPED, la posición y el estado de cada operación

Aquí hay que respetar dos cosas: el número y la vida útil. Si se emiten tres E/S a la vez, se preparan tres OVERLAPPED. Compartir la misma estructura entre varias operaciones no completadas lleva a resultados impredecibles o a la corrupción de datos.2

Además, hasta que se complete hay que mantener válidos la estructura y el búfer de datos, sin modificarlos, reutilizarlos ni liberarlos. El núcleo sigue usando esa zona. Si se emite con un OVERLAPPED de variable local y se sale de la función mientras la operación sigue en curso, se está dejando usar una zona de pila cuya vida útil ya terminó.32

Cuando se reutiliza después de confirmar la finalización, se reinicializa para que no quede el estado de la operación anterior. Si se elige la vía de eventos, se usa un evento de reinicio manual en hEvent. La relación con la forma de esperar se explica en el apartado 4.2.3

3.3. Dividir el valor de retorno de ReadFile en tres y decidir dónde tratarlo

El resultado de emitir ReadFile contra un handle asíncrono se juzga con la combinación del valor de retorno y GetLastError(). Lo importante es no tratar todo FALSE como un fallo.6

Valor de retorno de ReadFile GetLastError() Significado Qué hace el llamador
TRUE (no se mira) Se completó en el acto (finalización síncrona) Por defecto también llega una notificación de finalización. Deje el tratamiento del resultado al lado de la notificación
FALSE ERROR_IO_PENDING (997) Aceptada. En curso No hacer nada. Esperar la notificación de finalización sin tocar el OVERLAPPED ni el búfer
FALSE Cualquier otro Falló la propia emisión La notificación de finalización no llega. Trate el error en el acto y limpie el OVERLAPPED y el búfer
ReadFile(handle asíncrono, con OVERLAPPED)¿Cuál es el valor de retorno?TRUEse completó en el acto (finalización síncrona)por defecto también llega una notificaciónFALSE + ERROR_IO_PENDINGaceptada. La finalización se notifica despuésFALSE + otro errorfalló la propia emisiónEsperar la notificación de finalización(las 4 vías del capítulo 4)

Figura 3: Tratar las tres ramas: finalización síncrona, aceptada y en curso, y fallo de emisión

La función siguiente solo hace este juicio y lo devuelve al llamador. Se da por hecho que en otro sitio se preparan el handle, la estructura, el búfer y el evento exclusivos de la operación, y el código que recibe la finalización.

// C++ / Win32
// hFile : handle abierto con FILE_FLAG_OVERLAPPED
// ov    : OVERLAPPED reservado en exclusiva 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 trata el resultado
        return ERROR_SUCCESS;
    }

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

    // (3) Fallo de la propia emisión. No llega notificación, así que el llamador limpia aquí
    return err;
}

ERROR_IO_PENDING es el resultado «aceptada y aún no completada». No se debe limpiar como un error habitual. La finalización síncrona que vuelve con TRUE también es una vía normal, así que hay que tratarla siempre. Las razones de una finalización síncrona se explican en el capítulo 5.

El resultado se trata una sola vez. Por defecto, incluso en una operación que se completó de forma síncrona, si el handle está asociado a un IOCP se encola un paquete de finalización, y si se usa la vía de eventos el evento se señaliza. Si se trata el resultado tanto justo después de TRUE como al recibir la notificación, se procesa dos veces la misma operación y se corre el riesgo de liberar dos veces la estructura. La forma básica segura es unificar las vías TRUE y ERROR_IO_PENDING en el tratamiento del resultado del lado de la notificación.1

En cambio, en la vía en la que falló la propia emisión, el emisor hace el tratamiento del error y la limpieza. Si se pasa a esperar cuando no va a llegar ninguna notificación, se espera para siempre.

También existe una optimización que omite la notificación al IOCP en una finalización síncrona, pero es un diseño distinto del comportamiento predeterminado. El ámbito de aplicación de FILE_SKIP_COMPLETION_PORT_ON_SUCCESS se explica por separado en el apartado 5.3.7

4. Elegir la notificación de finalización: se decide por el número de E/S y por el hilo que las trata

Una vez separadas la emisión y la finalización, hace falta «cómo recibir la finalización». Empiece por comparar las cuatro vías según el número de E/S que se tratan a la vez y el hilo que ejecuta el tratamiento de finalización.1

Vía Hilo que ejecuta el tratamiento de finalización Número de E/S que se pueden lanzar a la vez Dónde encaja
(1) Señal del handle Cualquier hilo que espere En la práctica, 1. Si se lanzan varias no se distingue cuál se completó Casi ninguna (4.1)
(2) Evento + GetOverlappedResult Cualquier hilo que espere Hace falta un evento por operación. Si se esperan juntas con WaitForMultipleObjects el tope es 64 Unas pocas E/S concurrentes. Comunicación con dispositivos (4.2)
(3) APC (ReadFileEx) El hilo que emitió, y solo mientras está en espera alertable No hay límite de número, pero todo el tratamiento de finalización corre en serie en ese único hilo Comunicación que se quiere cerrar en un solo hilo (4.3)
(4) Puerto de finalización de E/S El conjunto de hilos trabajadores ligados al puerto Muchas E/S las pueden atender pocos hilos Servidores, grupo de subprocesos (4.4)
La E/S se completa en el núcleo(IoCompleteRequest → un APC confirma el resultado)(1) El handle de archivo pasa a estado señalizadorecepción: WaitForSingleObject(handle)(2) El hEvent de OVERLAPPED pasa a estado señalizadorecepción: WaitForSingleObject + GetOverlappedResult(3) La rutina de finalización se encola en la cola de APC del hilo emisorrecepción: se ejecuta durante una espera alertable como SleepEx(4) Un paquete de finalización entra en el puerto de finalización de E/Srecepción: GetQueuedCompletionStatus (parte 3)

Figura 4: Las cuatro vías de notificación de finalización. La forma de recibirla cambia según cómo se emitió

4.1. Señal del handle: no se pueden distinguir varias operaciones

Si se emite sin indicar hEvent, al completarse el propio handle de archivo pasa a estado señalizado. Sin embargo, si hay varias operaciones en curso sobre el mismo handle, no se distingue cuál se completó.1

Salvo el caso especial de «no emitir nunca más de una E/S asíncrona a la vez», lo seguro es no usarla. Aunque parezca cómoda, no llega a ser un mecanismo para gestionar el resultado de cada operación.

4.2. Eventos y GetOverlappedResult: la forma básica para unas pocas E/S concurrentes

Se configura un evento de reinicio manual en el OVERLAPPED.hEvent de cada operación y se emite. Tras esperar con WaitForSingleObject, se obtienen el éxito o el fallo y el número de bytes transferidos con GetOverlappedResult. Para esperar varios eventos juntos se usa WaitForMultipleObjects, pero el máximo simultáneo es 64.18

Si bWait de GetOverlappedResult se pone en TRUE, también se puede esperar hasta la finalización y entonces tomar el resultado. Si aquí se usa un evento de reinicio automático, GetOverlappedResult puede seguir esperando después de que otra espera haya consumido la señal. Se usa reinicio manual precisamente para evitar este problema de sincronización.83

Para tratar con solidez unas pocas E/S concurrentes, es una vía clara. También se usa en el tratamiento de «leer mientras se escribe» de un puerto serie. Un ejemplo práctico está en «Puntos débiles de las aplicaciones de comunicación serie».

4.3. APC: mantener el hilo emisor en espera alertable hasta la finalización

ReadFileEx / WriteFileEx son la vía en la que se indica una rutina de finalización (devolución de llamada). Cuando la E/S se completa, la rutina se encola en la cola de APC del hilo que la emitió. Se ejecuta cuando ese hilo entra en espera alertable con SleepEx, WaitForSingleObjectEx u otra llamada similar.91011

Como el tratamiento de finalización corre en serie en el mismo hilo, en un procesamiento que se cierra en un solo hilo se pueden evitar los bloqueos. Por otro lado, si el hilo emisor no entra en espera alertable, la rutina de finalización no se ejecuta. Combinarla con el bucle de mensajes de la interfaz de usuario exige MsgWaitForMultipleObjectsEx, y el diseño de la espera se complica. En uso general se elige con más frecuencia un evento o un IOCP.

Lo más fácil de pasar por alto con un APC es si se pudo emitir, si la forma de esperar es correcta y si se completó la propia operación. El código siguiente es un extracto que contrasta una espera mala con una buena; no es un ejemplo de ejecutar las dos seguidas. Se da por hecho que en otro sitio se preparan el handle y el búfer, y OnReadCompleted, que actualiza el indicador de finalización de cada operación.

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

// Ejemplo malo: la rutina de finalización nunca se llama
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // espera que no es alertable. El APC no se entrega

// Ejemplo bueno: seguir en espera alertable hasta que termine esta E/S
//
// La rutina de finalización pone este indicador (por ejemplo, en la estructura que aloja ov)
volatile bool completed = false;

// Hay que comprobar siempre si se pudo emitir. Si devuelve 0, no se encoló ninguna rutina de finalización
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // tomarlo enseguida. Las API posteriores lo sobrescriben
    ReportError(err);                   // desconexión del dispositivo, handle no válido, etc.
    return;                             // * no se debe entrar en el bucle de espera de abajo
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // el TRUE del segundo argumento es alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // Se ejecutó algún APC. No tiene por qué ser la propia E/S,
        // así que se decide mirando completed y, si no es esa, se vuelve a esperar
        continue;
    }
    // Volvió por tiempo de espera. La E/S sigue emitida, así que
    // si se va a cortar, se cancela con CancelIoEx y se espera a que se entregue la finalización
    CancelIoEx(hFile, ov);
}

Si la emisión falla, no se entra a esperar. Si ReadFileEx devuelve 0 por desconexión del dispositivo, un handle no válido u otra causa, no se ha encolado ninguna rutina de finalización. Se toma GetLastError() enseguida, se trata el error y se sale. Si se pasa esto por alto, completed no se pone nunca a verdadero y se repiten SleepEx y CancelIoEx contra una E/S que no existe.9

El tiempo de espera de la espera no se toma por el fin de la E/S. Cuando SleepEx agota el tiempo sale de la espera alertable, pero la E/S ya emitida puede seguir ahí. No se debe salir del ámbito y dejar caducar ov o buf. O se sigue esperando hasta la finalización, o, si se corta, se solicita la cancelación y se espera a que se entregue esa finalización. La regla de vida útil del apartado 3.2 sigue igual después de un tiempo de espera.

No se concluye solo con WAIT_IO_COMPLETION que terminó la propia E/S. Ese valor de retorno significa que se ejecutó al menos un APC. Si en el mismo hilo hay otra E/S o un APC de QueueUserAPC, también vuelve por eso. Se juzga con el indicador que actualiza la propia rutina de finalización y, si aún no está, se vuelve a esperar.10

Cuando «no llega el APC», además del éxito o el fallo de la emisión se comprueba la función de espera. Que sea SleepEx(..., TRUE) y no Sleep, WaitForSingleObjectEx(..., TRUE) y no WaitForSingleObject. La Ex del final y el TRUE del argumento alertable son los puntos a comprobar.10

4.4. IOCP: atender muchas E/S con pocos trabajadores

Con un puerto de finalización de E/S (IOCP) se asocia el handle a un puerto. Los paquetes de finalización entran en la cola del puerto y los hilos trabajadores los extraen con GetQueuedCompletionStatus. Es el mecanismo para tratar muchas E/S concurrentes con pocos hilos.12

También es la vía que sostiene la E/S asíncrona de .NET. Cómo se combinan la cola de notificaciones de finalización y el control del número de hilos que se ejecutan en paralelo se trata con detalle en la parte 3.

5. La excepción de la finalización síncrona: «asíncrono» y «no se le hace esperar» no son lo mismo

5.1. Condiciones típicas en las que se completa dentro de la llamada

Aunque se emita correctamente en modo asíncrono, la E/S puede completarse dentro de la llamada. Finalización síncrona significa que la E/S terminó antes de que la función volviera; no es una promesa de que vuelva en poco tiempo. Hay que separar el caso que termina rápido por un acierto de caché del caso en que se le hace esperar dentro de la llamada.2

ninguna de ellasEmitir ReadFile/WriteFile contra un handle asíncrono¿Cae en una condición de finalización síncrona?Solicitud que se puede satisfacer de inmediato(los datos ya están en la caché, etc.)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 de inmediato= se ejecutó hasta completarse dentro de la llamadaVuelve con ERROR_IO_PENDING= realmente en curso de forma asíncrona

Figura 5: Condiciones principales en las que una E/S emitida de forma asíncrona se completa de forma síncrona. Distinguirlo del tiempo que tarda la llamada en volver

El documento de resolución de problemas de Microsoft cita las razones siguientes.2

Condición Por qué se trata de forma síncrona y qué implica en el código
Solicitud que se puede satisfacer de inmediato o acierto de caché Si los datos están en memoria, el controlador puede completar en el acto. Que termine rápido está bien, pero el código que presupone que siempre vuelve ERROR_IO_PENDING se rompe
Lectura con caché activa en la que falta la página necesaria La caché de Windows está implementada con asignación de archivos. Como no hay un mecanismo asíncrono de error de página, a veces se trata de forma síncrona
Archivo comprimido con NTFS o cifrado con EFS El controlador del sistema de archivos convierte el acceso a síncrono
Escritura que alarga el archivo Una escritura que cambia la longitud se vuelve síncrona

Lo importante es que un tratamiento síncrono puede ocurrir no solo con un acierto de caché, sino también cuando los datos no están en la caché. El mecanismo de la caché en sí se trata en la parte 4 de la serie.

5.2. Diseñar por separado las ramas del resultado de emitir y la capacidad de respuesta de la interfaz

Lo primero que hace falta es tratar las tres ramas del apartado 3.3. También TRUE se contempla como un resultado normal y, por defecto, el tratamiento del resultado se unifica en el lado de la notificación.

Ahora bien, escribir bien las ramas no garantiza la capacidad de respuesta. Como no se puede decir «es E/S asíncrona, así que la interfaz no se congela», hace falta un diseño que separe la propia emisión de la E/S de cualquier hilo que no se pueda detener, y la deje a un hilo dedicado o al grupo de subprocesos. La práctica relacionada se explica también en «Guía práctica para lograr el máximo de soft real-time posible en un Windows convencional».

5.3. Omitir la notificación en una finalización síncrona es una optimización solo del IOCP

En E/S de alta frecuencia se puede optimizar omitiendo la notificación en una finalización síncrona. Si se activa FILE_SKIP_COMPLETION_PORT_ON_SUCCESS con SetFileCompletionNotificationModes, no se encola en el IOCP el paquete de finalización de una E/S que tuvo éxito de inmediato. Es la opción para cuando se pasa a un diseño que trata el resultado en el acto, no en el lado de la notificación.7

Lo que se omite es solo el paquete hacia el IOCP: la señal de OVERLAPPED.hEvent no se suprime. No aplique la misma optimización a la vía de eventos. Mezclar la vía de notificación predeterminada con la vía ya optimizada lleva al tratamiento duplicado del apartado 3.3 o a esperar una notificación que no llega. La combinación con IOCP se trata en la parte 3.

6. Cancelación y cierre: solicitar, confirmar la finalización, cerrar

6.1. Elegir la API según lo que se quiere anular

Las API de cancelación se eligen según la operación y el hilo que la emitió.4135

API Destino y forma de indicarlo
CancelIoEx Solicita la cancelación de la E/S no completada de un handle indicado, sin importar qué hilo la emitió. Si el segundo argumento es un OVERLAPPED, esa operación; si es NULL, todas las operaciones de ese handle
CancelIo Solo las operaciones que emitió el propio hilo que llama
CancelSynchronousIo La E/S síncrona en ejecución en otro hilo indicado

CancelIoEx se introdujo en Vista. En E/S asíncrona no hay hoy razón para usar a propósito el antiguo CancelIo, limitado al hilo emisor; la base es CancelIoEx.

6.2. El éxito de CancelIoEx no significa que la E/S haya terminado

CancelIoEx es una API que solicita la cancelación de IRP no completados, no una API que espera la finalización de la operación. Aunque tenga éxito, solo se ha solicitado la anulación. Una operación que ya estaba a punto de completarse puede terminar con normalidad porque la cancelación no llegó a tiempo.14

ControladorAdministrador de E/SAplicaciónControladorAdministrador de E/SAplicaciónSolicitar la cancelación del IRPno completado correspondiente (marcarlo)Si se puede anular, se interrumpesi está a punto de terminar, puede completar con normalidadTras observar esta notificaciónse liberan OVERLAPPED y el búferCancelIoEx(handle, OVERLAPPED)Llamada a la rutina de cancelaciónIoCompleteRequest(STATUS_CANCELLED)Llega la notificación de finalizaciónGetOverlappedResult indica ERROR_OPERATION_ABORTED

Figura 6: Una operación anulada también se notifica como finalización. La limpieza se hace después de esa confirmación

Una operación que realmente se anuló vuelve en la notificación de finalización como ERROR_OPERATION_ABORTED. Tanto si terminó con normalidad como si se anuló, no se liberan la estructura ni el búfer hasta recibir la notificación. Liberarlos antes hace que el núcleo pierda una zona que aún usa, y eso lleva a la corrupción de memoria. En una infracción de acceso después de cancelar, lo primero es comprobar esta vida útil.414

6.3. Recuperar las operaciones ya emitidas antes de cerrar el handle

La secuencia básica de cierre es solicitar la cancelación → observar la finalización → cerrar el handle.

Como se vio en la parte 1, al cerrar el último handle el tratamiento de limpieza anula los IRP no completados. Sin embargo, cerrar solo el handle dejando E/S ya emitidas suele romper la gestión de las notificaciones de finalización y de la vida útil de los búferes. No se delega la limpieza al cierre: primero se cierran las operaciones no completadas.

Lo mismo vale cuando se quiere cortar el tratamiento por tiempo de espera. El sistema operativo no decide por la aplicación hasta dónde cortar, así que se diseñan juntas la cancelación posterior al tiempo de espera y el procedimiento para recibir la finalización. En la vía APC, como en el apartado 4.3, se sigue en espera alertable hasta que se entregue la finalización.

7. Correspondencia con .NET: no basta con ReadAsync; hay que mirar dónde se abre el archivo

7.1. Hacer coincidir el modo del handle y la API que se llama

El useAsync de FileStream, o FileOptions.Asynchronous, corresponde a FILE_FLAG_OVERLAPPED de Win32. Igual que en la tabla de correspondencias de la parte 1, también en .NET importa el modo al abrir el archivo.1516

sínoawait fs.ReadAsync(...)¿El handle está en modo asíncrono(FileOptions.Asynchronous)?E/S asíncrona realse emite el equivalente de OVERLAPPED yla finalización llega al grupo de subprocesos por IOCP (parte 3)Asincronía aparenteun hilo del grupo de subprocesos se hace cargoy espera en un Read síncrono

Figura 7: Aunque sea el mismo ReadAsync, el modo del handle cambia la vía de tratamiento en el sistema operativo

Combinación de handle y API Qué ocurre por dentro
Modo asíncrono + ReadAsync / WriteAsync La combinación que usa la E/S asíncrona del sistema operativo
Modo síncrono + ReadAsync / WriteAsync Un hilo del grupo de subprocesos se hace cargo de la lectura o escritura síncrona: «asincronía aparente»
Modo asíncrono + Read / Write síncronos Se produce el coste de esperar internamente la finalización

También con la «asincronía aparente» el hilo que llama no espera, pero por detrás espera otro hilo. Si son pocos casos el daño real es pequeño, pero en un servidor o en un tratamiento de alta frecuencia se convierte en causa de agotamiento del grupo de subprocesos y de pérdida de escalabilidad. El principio es hacer coincidir el modo y la API.1615

7.2. Comparar tres formas de crear un FileStream

En (A) y (B) siguientes, la forma de llamar a ReadAsync es la misma. Lo único que cambia es useAsync al abrir el archivo. (C) es el ejemplo, a partir de .NET 6, que hace explícitos el handle y la posición.

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 se bloquea, 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) E/S asíncrona real. useAsync: true enlaza de forma directa con 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 por IOCP (parte 3)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) A partir de .NET 6. Forma directa que hace explícitos el modo y el desplazamiento
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

Al revisar código existente, no se busca solo el lado que llama a ReadAsync / WriteAsync, sino el lugar donde se crea el FileStream. Las sobrecargas cortas como File.OpenRead o new FileStream(path, FileMode.Open) abren en modo síncrono. Si se crea un FileStream a partir de un SafeFileHandle, el argumento isAsync también se hace coincidir con el modo real del handle.

7.3. Con RandomAccess se hacen explícitos el handle y el desplazamiento

En .NET 6 se reescribió por completo la implementación interna de FileStream y se añadieron File.OpenHandle y RandomAccess. Son API que tratan directamente un SafeFileHandle y pasan en cada llamada la posición de lectura o escritura.16

La forma de (C), que hace explícitos el modo y fileOffset, corresponde a la división del trabajo de este artículo: handle asíncrono y OVERLAPPED.Offset por operación.

7.4. También con CancellationToken la cancelación se queda en una solicitud

En un handle 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 el mecanismo del capítulo 6. Tampoco aquí se garantiza una interrupción inmediata.

En la «asincronía aparente» de un handle en modo síncrono no hay una operación superpuesta a la que apuntar la cancelación, así que esta vía no está disponible. Los runtimes recientes de .NET incluyen además un mecanismo que intenta anular, con CancelSynchronousIo, una llamada en ejecución síncrona, pero el comportamiento depende de la versión del runtime y del tipo de operación, y no se garantiza una interrupción segura. Si el diseño presupone la cancelación, lo propio es igualar el modo del handle y usar la E/S asíncrona del sistema operativo.

La práctica de la capa de async/await —por ejemplo ConfigureAwait y la relación con el hilo de la interfaz de usuario— está en «Tabla práctica de decisión de C# async/await - Task.Run y ConfigureAwait» y «WPF/WinForms: async y el hilo de UI, resumidos en una hoja». Este artículo explica cómo el sistema operativo avanza las lecturas y escrituras por debajo de esa capa.

8. Resumen: comprobar de la emisión a la limpieza como un solo recorrido

Al revisar E/S asíncrona, se sigue el código en este orden.

  1. En el lugar que abre, confirmar el modo síncrono o asíncrono. Si es un archivo en disco, indicar la posición en cada operación asíncrona.
  2. En el lugar que emite, confirmar que hay un OVERLAPPED y un búfer exclusivos de la operación, y que se tratan las tres ramas del apartado 3.3.
  3. En el lugar que recibe la finalización, confirmar que la forma de esperar encaja con la vía (evento, APC, IOCP) y que no se trata dos veces el mismo resultado.
  4. En el lugar que cierra, confirmar que no se libera nada solo con un tiempo de espera o una solicitud de cancelación.

La E/S síncrona y la asíncrona no son tuberías distintas. La diferencia es si se vuelve después de esperar la finalización o se usa una vía que vuelve antes. Como también en modo asíncrono ocurre la finalización síncrona, se separan las ramas del resultado de emitir y el diseño de la capacidad de respuesta.12

El modo pertenece al handle, el estado a cada operación, y la limpieza solo después de confirmar la finalización. Esta división del trabajo es común tanto si se trata directamente el OVERLAPPED de Win32 como si se usa FileOptions.Asynchronous de .NET. También una operación cancelada sigue bajo gestión hasta que se reciba su finalización.3415

La continuación es la parte 3, «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». Trata por qué el IOCP del apartado 4.4 unifica la cola de notificaciones de finalización y el control del número de hilos en ejecución, y en qué hilo corre 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 Windows y de comunicación con dispositivos que usan E/S asíncrona, y 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

  2. 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 asignación de archivos y no dispone de un mecanismo asíncrono de error 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 a la 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

  3. Microsoft Learn, OVERLAPPED structure. Sobre que la estructura OVERLAPPED conserva la información para la entrada y 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 ↩6

  4. Microsoft Learn, CancelIoEx function. Sobre que CancelIoEx marca para cancelación 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 a todas las E/S 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 termine el tratamiento de finalización. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, CancelSynchronousIo function. Sobre que CancelSynchronousIo marca para cancelación 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

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

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

  8. Microsoft Learn, GetOverlappedResult function. Sobre que GetOverlappedResult obtiene el resultado de una operación asíncrona (éxito o fallo 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, por lo que conviene usar un evento de reinicio manual. ↩ ↩2

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

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

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

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

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

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

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

  16. 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 de forma explícita 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

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». Es una propiedad del handle que se decide en el momento de CreateFile, y no se puede alternar entre síncrono y asíncrono en cada llamada. En un handle en modo asíncrono hay que pasar siempre una estructura OVERLAPPED a ReadFile/WriteFile. El sistema no gestiona el puntero de archivo (la posición actual) de este handle, así que en un dispositivo con posición, como un archivo en disco, la posición de lectura o escritura se indica cada vez con 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 con 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 ya completada?
Porque el modo asíncrono significa «no hace falta esperar la finalización», no «nunca se le hará esperar». La documentación de Microsoft cita como razones típicas por las que una E/S emitida de forma asíncrona se completa de forma síncrona: que la solicitud se pueda satisfacer 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 esos casos ReadFile/WriteFile devuelve TRUE y el resultado ya está confirmado. 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 una notificación de finalización aparte (la señal de un evento o un paquete en el puerto de finalización de E/S), así que lo más seguro es concentrar el tratamiento 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 en curso», y la documentación de Microsoft indica con claridad que si se emiten tres E/S hacen falta tres estructuras OVERLAPPED, y que reutilizarlas lleva a resultados impredecibles o a la corrupción de datos. Hasta que la operación se complete 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 reutiliza después de que se complete, hay que reinicializarla cada vez para que no influyan los datos que quedaron de la vez anterior. 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 no completada de un handle determinado, sin importar qué hilo la emitió. Si se pasa un OVERLAPPED como segundo argumento se apunta a una sola operación concreta; con NULL, 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 con normalidad, y una operación cancelada se notifica como completada con ERROR_OPERATION_ABORTED. En ambos casos, hasta que llegue 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, y aparece una «asincronía aparente». El hilo que llama no queda bloqueado, pero por detrás hay otro hilo esperando, lo que puede agotar el grupo de subprocesos y reducir la 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, File.OpenHandle junto con RandomAccess permite escribirlo de forma directa, indicando de forma explícita 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