1. Introducción
Imagínate que eres el director de una orquesta (tu aplicación). Si cada vez que el violinista afina su violín (una I/O lenta) esperas a que termine antes de seguir con otros instrumentos, toda la orquesta se queda parada. Pero si el violinista dice: "Me pongo a afinar y vosotros seguid con lo vuestro, os doy señal cuando esté listo", — eso es asincronía.
En el mundo de C# y .NET 9 hay herramientas para esa "multitarea sin congelar". Los protagonistas de hoy son las versiones asíncronas de los métodos Read y Write, llamadas ReadAsync y WriteAsync.
Te permiten iniciar una operación de lectura o escritura y "soltar" inmediatamente el hilo de ejecución para que haga otra cosa. Cuando la operación de E/S termine (por ejemplo, los datos se leen o se escriben en disco), tu código "despertará" y seguirá desde donde se quedó.
Para usar estos métodos necesitamos dos palabras mágicas que llegaron a C# en 2012 con la versión 5.0 (y ahora, en C# 14, ya son familiares):
- async: Es un modificador que añades al método para decirle al compilador: "Dentro de este método puede haber operaciones asíncronas y voy a usar await".
- await: Es el operador que pones delante de la llamada asíncrona (como ReadAsync o WriteAsync). Significa: "Arranca esta operación, pero no esperes aquí. Devuelve el control al llamador y vuelve cuando la operación esté lista".
No te preocupes si estos conceptos parecen un poco borrosos aún. Tendremos todo un nivel dedicado a async y await (Nivel 58), donde los desmenuzaremos. Ahora lo importante es entender que ayudan a no bloquear el hilo principal.
2. ReadAsync: leer sin prisas
El método ReadAsync permite leer datos de un stream de forma asíncrona. En lugar de esperar a que los bytes se lean del disco, inicias la lectura y te cambias a otras tareas al instante.
A continuación su firma principal para leer en un buffer:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
O, lo que se usa más en C# moderno (y .NET 9), con Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Desglosamos los parámetros:
- buffer: Es un array de byte (o Memory<byte>) donde se leerán los datos. Recuerda, hablamos de buffers para optimizar; aquí se usan igual, pero en operaciones asíncronas.
- offset: El desplazamiento en el buffer donde empezar a escribir los bytes leídos.
- count: El número máximo de bytes a leer.
- CancellationToken cancellationToken: Parámetro muy útil para cancelar la operación si ya no hace falta (por ejemplo, el usuario cerró la app o pulsó el botón "Cancelar").
- ValueTask<int>: Es una "promesa" de que cuando la operación termine devolverá un entero (int) igual al número de bytes leídos. ValueTask es una versión optimizada de Task para escenarios donde el resultado puede estar disponible síncronamente o asíncronamente.
Ejemplo 1: Lectura asíncrona de un archivo
Imagina que tenemos un archivo de texto grande y queremos leerlo sin bloquear el hilo principal. Aquí un ejemplo básico:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Función asíncrona para leer un archivo y contar el número de líneas
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Apertura asíncrona del archivo
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"En el archivo {filename} líneas: {count}");
}
}
Comentarios sobre el código:
- Fíjate en el parámetro useAsync: true en FileStream. Es importante para obtener asincronía real.
- Usamos await con ReadLineAsync para no bloquear el hilo mientras se lee una línea.
- El método Main ahora es asíncrono (C# 7+ lo permite).
Si esto fuera una aplicación gráfica, durante la lectura del archivo (mientras ReadAsync espera los datos del disco) el usuario podría pulsar botones, hacer scroll y realizar otras acciones, porque el hilo UI no estaría bloqueado. En una consola es menos evidente, pero el principio es el mismo.
3. WriteAsync: escribir sin demora
De forma similar a ReadAsync, el método WriteAsync permite escribir datos en un stream de manera asíncrona. Es muy útil cuando necesitas volcar muchos datos sin forzar a la aplicación a esperar a que se escriban en disco.
Firmas principales:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Y usando ReadOnlyMemory<byte> (para escritura no modificamos el buffer):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
Los parámetros son parecidos a los de ReadAsync:
- buffer: Array de byte (o ReadOnlyMemory<byte>) con los datos a escribir.
- offset: Desplazamiento en el buffer desde donde empezar a leer los datos para escribir.
- count: Cantidad de bytes a escribir.
- CancellationToken cancellationToken: Para cancelar la operación.
- ValueTask: No devuelve valor, porque la cantidad escrita se especifica por count.
Ejemplo 2: Escritura asíncrona a archivo
Ahora vamos a escribir algo en un archivo — también de forma asíncrona.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Línea número {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"La escritura de {filename} ha finalizado.");
}
}
Aquí el bucle escribe 10000 líneas y el hilo principal no se bloquea: si fuera una aplicación GUI, la interfaz no se "colgaría".
Gracias a async y await, nuestra app de consola puede realizar operaciones de copia de archivos manteniéndose "reactiva" a la entrada del usuario (en nuestro caso, a pulsar Enter para cancelar). Este es un principio fundamental para construir aplicaciones modernas, performantes y responsivas en C#.
4. Matices útiles
Visualización: cómo funciona la lectura/escritura asíncrona
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Tu código (UI/lógica)│ ─────────────────────→ │ OS/E/S: operación asíncrona │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(hace otra cosa) │(lee el archivo, espera al disco)
│<────────────────────────────────────────→│
└─ Espera el Task, obtiene el resultado ←──────────┘
Esquema aproximado: mientras el disco trabaja despacio, el código puede hacer otras cosas. Solo cuando se necesitan los datos — la ejecución espera al Task.
Aplicación práctica: ¿dónde se necesita realmente?
- Aplicaciones de escritorio: si tu app lee o escribe cosas grandes (logs, bases de datos, vídeo), la asincronía es un must-have. Incluso en máquinas potentes, el usuario puede abrir un archivo en red y ahí la velocidad puede ser de tortuga en hora de comida.
- Backends o aplicaciones web: decenas (o miles) de usuarios pueden golpear el servidor simultáneamente. Si cada hilo se bloquea leyendo archivos — hola, latencias y 502 Bad Gateway.
- Apps móviles: si abrir o guardar un archivo tarda, el usuario notará lag. Usa asincronía.
- Cualquier procesamiento masivo de archivos: apps que trabajan con colecciones de archivos (compresores, parsers, analizadores) ganan mucho con I/O asíncrono.
Síncrono vs Asíncrono lectura/escritura
| Método | ¿Bloquea el hilo? | ¿Fácil de implementar? | ¿Mejor rendimiento? | ¿Conveniente para UI/servidores? |
|---|---|---|---|---|
| Síncrono (Read/Write) | Sí | Sí | No | No |
| Asíncrono (ReadAsync) | No | Casi | Sí | Sí |
5. Matices y buenas prácticas
La bufferización sigue siendo importante: Incluso con ReadAsync y WriteAsync, leer o escribir byte a byte sigue siendo muy ineficiente. La asincronía quita el bloqueo, pero no acelera mágicamente la lectura de cada byte. Buenas tamaños iniciales de buffer: 4096-8192 bytes; para archivos grandes prueba 65536 o 131072.
"La asincronía debe ser en todas partes" (Async All The Way Down): Si empiezas a usar async/await en un punto, lo habitual es propagarlo por la cadena de llamadas: C hace una operación asíncrona — entonces C es async Task, B también async Task, y A — async Task. Si no, puedes obtener bloqueos e incluso deadlocks en apps UI.
Manejo de excepciones: En código asíncrono usa los habituales try-catch. Son comunes OperationCanceledException y IOException — trátalos explícitamente.
Liberar recursos (await using): Para streams y otros objetos IDisposable libera recursos correctamente. Si el tipo implementa IAsyncDisposable, entonces await using llamará a DisposeAsync(); si solo implementa IDisposable — se llamará a Dispose().
Qué ocurre bajo el capó (breve): Con await el compilador convierte el método en un autómata: la operación se lanza, el método "se pausa", el control vuelve al llamador. Al completarse el resultado, el SynchronizationContext (en UI) o el ThreadPool (en consola/servidor) reanudan la ejecución desde donde quedó. Esto permite a un solo hilo atender muchas tareas "suspendidas" sin bloquearse.
En conclusión, la programación asíncrona con async y await es una herramienta potente para crear aplicaciones responsivas y escalables. Permite que tu código use eficientemente los recursos del sistema sin bloquear la interfaz de usuario o los hilos en el servidor. Al principio puede parecer un poco raro, pero créeme: merece la pena dominarlo. En las próximas lecciones seguiremos profundizando en el mundo de la asincronía y el paralelismo. ¡Nos vemos!
GO TO FULL VERSION