Tabla práctica de decisión de C# async/await - Task.Run y ConfigureAwait
· Actualizado el: · Go Komura · C#, async/await, .NET, Diseño
El uso de async / await en C# es cotidiano, pero en la práctica lo que suele generar dudas no es la sintaxis en sí, sino qué forma de escribirlo elegir en cada situación.
Las búsquedas más frecuentes giran en torno a decisiones como cuándo usar Task.Run, dónde colocar ConfigureAwait(false) o si está bien permitir fire-and-forget.
- Envolver en
Task.Rununa espera de E/S que no lo necesita - Hacer
awaiten serie, uno a uno, sobre procesos independientes - Introducir
fire-and-forgetsin pensarlo y perder de vista las excepciones o el momento de finalización - Añadir
ConfigureAwait(false)de la misma manera en todas partes - Elegir
ValueTasksolo porque «parece más liviano»
Más que memorizar estos casos por separado, conviene partir de distinguir primero el tipo de proceso; así hay menos margen para la duda.
En este artículo, partiendo principalmente de un desarrollo típico de aplicaciones C# / .NET con .NET 6 o posterior,
se ordenan las formas de escribir async / await en un orden que facilita la decisión.
Se tienen en mente, por ejemplo, los siguientes tipos de desarrollo.
- Aplicaciones de escritorio como WinForms / WPF
- Aplicaciones web / API de ASP.NET Core
- Servicios worker / en segundo plano
- Aplicaciones de consola
- Bibliotecas de clases reutilizables
Además, el código que aparece en este artículo se publica en GitHub como un conjunto de muestras que se puede compilar y ejecutar (biblioteca, demo de consola y pruebas unitarias que verifican cada patrón de la tabla de decisión).
csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)
Cómo leer este artículo
Es un artículo bastante largo, así que primero se ofrecen puntos de entrada según el objetivo.
| Objetivo | Dónde leer |
|---|---|
| Ver solo la tabla de decisión | La tabla y el diagrama de 3.1. Es el centro de este artículo |
| Conocer la forma de escribir cada patrón | A partir de 3.2. Corresponden una a una con las filas de la tabla de 3.1 |
| Revisar su propio código | La tabla de antipatrones de 5. |
| Alinear los criterios de revisión | La lista de verificación de 6. |
| Solo quiere la conclusión | 1. |
Índice
- Primero, la conclusión (en pocas palabras)
- Términos usados en este artículo
- 2.1. Términos que conviene distinguir primero
- 2.2. Términos frecuentes
- La tabla de decisión que conviene ver primero
- 3.1. Panorama general
- 3.2. Si es espera de E/S, haga await directamente sobre la API async
- 3.3. Si la carga de CPU es pesada, elija dónde usar Task.Run
- 3.4. Si hay varios procesos independientes, use Task.WhenAll
- 3.5. Si quiere usar el primero que termine, use Task.WhenAny
- 3.6. Si hay muchos elementos y quiere limitar el paralelismo, use Parallel.ForEachAsync o SemaphoreSlim
- 3.7. Si quiere procesar en orden, use Channel<T>
- 3.8. Si quiere ejecutar a intervalos regulares, use PeriodicTimer
- 3.9. Si los datos llegan de forma secuencial, use IAsyncEnumerable<T>
- 3.10. Si necesita liberar recursos de forma asíncrona, use await using
- 3.11. Si necesita exclusión mutua a través de un await, use SemaphoreSlim
- 3.12. Diferencie la forma de escribir await según sea UI, código de aplicación o biblioteca
- Reglas básicas de escritura
- 4.1. El valor de retorno: primero Task / Task<T>
- 4.2. async void solo en controladores de eventos
- 4.3. Reciba el CancellationToken y páselo aguas abajo
- 4.4. Encadene las API asíncronas de forma asíncrona hasta el final
- 4.5. Al crear tareas con LINQ, confírmelas con ToArray / ToList
- Antipatrones frecuentes
- Lista de verificación para revisiones
- Resumen de cuándo usar cada opción
- Resumen
- Referencias
1. Primero, la conclusión (en pocas palabras)
async/awaites una forma de escribir código que evita bloquear el hilo mientras se espera, no un mecanismo que acelera todo automáticamente ni que convierte el código en multihilo por sí solo- Primero hay que separar si ese proceso es una espera de E/S o un cálculo de CPU
- Si es espera de E/S, lo básico es hacer await directamente sobre la API async
- Si es cálculo de CPU, hay que pensar dónde debe ejecutarse ese cálculo. En la UI,
Task.Runpuede ser útil, pero en el procesamiento de solicitudes de ASP.NET Core, en general se evita escribir unTask.Runseguido de un await inmediato - Para varios procesos independientes, conviene considerar primero
Task.WhenAllantes que hacer await en serie - Cuando hay muchos elementos, en lugar de lanzarlos todos a la vez con
Task.WhenAll, hay que decidir un límite de paralelismo fire-and-forgetparece sencillo, pero es difícil de gestionar. Si de verdad se quiere desacoplar el ciclo de vida del llamador, es más estable enviarlo a un lugar gestionado, como un Channel o un HostedService- El valor de retorno debe ser primero
Task/Task<T>.ValueTaskse elige después de medir y ver que hace falta ConfigureAwait(false)es útil sobre todo en código de biblioteca de propósito general, mientras que en código de UI o de aplicación basta con unawaitnormalasync voidno se usa fuera de los controladores de eventos
En resumen, lo más importante en torno a async / await es
no caer en «Task.Run por si acaso», «fire-and-forget por si acaso» ni «ValueTask por si acaso».
Antes que nada, conviene mirar estos tres puntos:
- Qué es lo que ese proceso está esperando
- Quién posee el ciclo de vida de ese proceso
- Dónde se controla el número de ejecuciones simultáneas
Con esto, las dudas se reducen bastante.
2. Términos usados en este artículo
2.1. Términos que conviene distinguir primero
Distinguir primero estos dos términos ayuda bastante a evitar confusiones.
| Término | Significado aquí |
|---|---|
| I/O-bound | Procesos centrados en esperar a que termine algo externo, como HTTP, base de datos, archivos o sockets |
| CPU-bound | Procesos centrados en el cálculo de CPU en sí, como compresión, procesamiento de imágenes, cálculo de hash o conversiones pesadas |
async / await resulta especialmente eficaz en la espera de E/S, porque mientras se espera se puede devolver el hilo a otro trabajo. El cálculo de CPU, en cambio, no es una «espera» sino tiempo real de cómputo, así que el tema central pasa a ser en qué hilo ejecutarlo y cómo decidir el grado de paralelismo.
2.2. Términos frecuentes
| Término | Significado aquí |
|---|---|
| Bloqueo (blocking) | Mantener ocupado un hilo mientras se espera a que algo termine |
fire-and-forget |
Una forma de iniciar un proceso en la que el llamador no espera su finalización |
SynchronizationContext |
El mecanismo que decide «dónde se ejecuta la continuación de un await». Vea el apartado siguiente para más detalle |
| backpressure | Mecanismo que, cuando el flujo de entrada es demasiado rápido, hace esperar al lado que escribe para evitar una acumulación excesiva |
IHostedService |
Mecanismo del host genérico de .NET que llama a StartAsync al iniciar y a StopAsync al detenerse. Es el punto de entrada de los procesos residentes que se ejecutan junto al ciclo de vida de la aplicación |
BackgroundService |
Clase abstracta que implementa IHostedService. Basta con sobrescribir un único método, ExecuteAsync(CancellationToken), para escribir un bucle residente. Se registra con AddHostedService<T>() (3.7) |
Cuando se usa Channel<T>, el lugar donde se coloca el lado consumidor es precisamente este BackgroundService. En 3.7 se trata la forma de «acumular en una cola y dejar que un consumidor dedicado procese en orden»; la relación con este apartado es que aquí se gestiona el ciclo de vida de ese consumidor en sincronía con el inicio y la parada de la aplicación.
Apartado adicional sobre SynchronizationContext
El tema de ConfigureAwait(false) (3.12) termina reduciéndose a entender bien este único término.
- Al ejecutar el código que sigue (la continuación),
awaitcaptura elSynchronizationContextvigente en el momento de entrar en espera y ejecuta la continuación de vuelta en él (si no haySynchronizationContextconfigurado, comprueba si se está usando unTaskSchedulerdistinto del predeterminado) - WinForms / WPF tienen un
SynchronizationContextque reenvía el procesamiento al hilo de UI. Por eso, después de unawait, se pueden tocar los controles con normalidad - ASP.NET Core no tiene
SynchronizationContext. Por lo tanto no hay «un lugar al que volver», y la continuación delawaitse ejecuta directamente en el hilo del ThreadPool que quede libre ConfigureAwait(false)indica que la continuación puede ejecutarse sin volver a ese contexto capturado
De aquí surgen las conclusiones de 3.12: «en código de UI es más natural no añadirlo», «en código de aplicación de ASP.NET Core da prácticamente igual añadirlo o no» y «en una biblioteca de propósito general, donde no se sabe en qué contexto se ejecutará, sí vale la pena añadirlo». El trasfondo detallado está mejor explicado en ConfigureAwait FAQ, en la sección 9. Referencias.
Es especialmente importante tener claro que asíncrono y paralelo son cosas distintas.
- Asíncrono: se refiere a cómo se espera
- Paralelo: se refiere a avanzar varias cosas a la vez
Cuando estos dos conceptos se mezclan, surge la tentación de usar Task.Run en todas partes.
Ahí está la primera bifurcación.
3. La tabla de decisión que conviene ver primero
3.1. Panorama general
Empezar por esta tabla ya deja bastante clara la orientación general.
| Situación | Qué usar primero | Punto a vigilar |
|---|---|---|
| Espera de HTTP / BD / archivos | await directamente sobre la API async |
No envolver con Task.Run |
| Cálculo pesado que no debe congelar la UI | Task.Run |
Sacar el cálculo de CPU del hilo de UI |
| Procesamiento de solicitudes de ASP.NET Core | await normal (plain) |
No hacer await inmediato sobre Task.Run |
| Pocos procesos asíncronos independientes | Task.WhenAll |
Iniciarlos todos primero y esperar juntos al final |
| Usar solo el que termine primero | Task.WhenAny |
Pensar en la cancelación del resto y en recoger las excepciones |
| Muchos elementos, se quiere poner un límite | Parallel.ForEachAsync / SemaphoreSlim |
Definir explícitamente el grado de paralelismo |
| Procesamiento en segundo plano que debe fluir en orden | Channel<T> |
Pensar en una cola acotada y en el backpressure |
| Proceso asíncrono a intervalos regulares | PeriodicTimer |
Respetar 1 temporizador por 1 consumidor |
| Procesar los resultados poco a poco | IAsyncEnumerable<T> / await foreach |
Avanzar sin esperar a que terminen todos |
| Necesita liberación asíncrona de recursos | await using |
Usar IAsyncDisposable |
| Exclusión mutua que atraviesa un await | SemaphoreSlim.WaitAsync |
Llamar siempre a Release en try/finally |
| Código de biblioteca de propósito general | Considerar ConfigureAwait(false) |
No depender de un contexto específico de UI o de aplicación |
flowchart TD
accTitle: Árbol de decisión para elegir el patrón de async/await
accDescr: Parte de si se espera E/S externa o cálculo de CPU y llega hasta Task.WhenAll, Task.WhenAny, Parallel.ForEachAsync, Channel, PeriodicTimer o IAsyncEnumerable según el caso
start["Proceso que se desea realizar"] --> q1{"¿Espera una E/S externa?"}
q1 -- "Sí" --> p1["Hacer await directamente sobre la API async"]
q1 -- "No" --> q2{"¿El cálculo de CPU es pesado?"}
q2 -- "Sí" --> q3{"¿Dónde se ejecuta?"}
q3 -- "Evento de UI / escritorio" --> p2["Considerar Task.Run"]
q3 -- "Solicitud de ASP.NET Core" --> p3["No envolver con Task.Run<br/>Si hace falta, enviar a otro worker o cola"]
q3 -- "worker / segundo plano" --> p4["Ejecutar en el momento<br/>o definir explícitamente el grado de paralelismo"]
q2 -- "No" --> q4{"¿Se manejan varios trabajos?"}
q4 -- "Esperar a que terminen todos" --> p5["Task.WhenAll"]
q4 -- "Usar el primero que termine" --> p6["Task.WhenAny"]
q4 -- "Hay muchos elementos" --> p7["Parallel.ForEachAsync<br/>o SemaphoreSlim"]
q4 -- "Procesar en orden" --> p8["Channel<T>"]
q4 -- "Intervalo regular" --> p9["PeriodicTimer"]
q4 -- "Flujo secuencial" --> p10["IAsyncEnumerable<T>"]
A continuación se repasa cada patrón en orden.
3.2. Si es espera de E/S, haga await directamente sobre la API async
Este es el patrón más básico de todos.
Por ejemplo, para HTTP, base de datos o lectura/escritura de archivos, lo primero es comprobar si existe una versión async de la API.
Si existe, lo básico es hacer await directamente sobre ella.
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
En este punto conviene evitar envolver en Task.Run una E/S que ya es async.
// Ejemplo no recomendado
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}
Esto solo reenvía la espera de E/S a otro hilo, lo que complica el código sin aportar ninguna ventaja.
- Si es espera de E/S, no hace falta
Task.Run - Buscar primero la API async
- Si se recibe un token, pasarlo directamente aguas abajo
Este es un camino bastante consolidado.
3.3. Si la carga de CPU es pesada, elija dónde usar Task.Run
Task.Run resulta útil cuando se quiere sacar un cálculo de CPU del hilo actual.
Por ejemplo, si un controlador de evento de UI ejecuta directamente un cálculo pesado, la pantalla se congela.
En estos casos, Task.Run es la solución natural.
public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] current = data;
for (int i = 0; i < repeat; i++)
{
cancellationToken.ThrowIfCancellationRequested();
current = sha256.ComputeHash(current);
}
return current;
}, cancellationToken);
}
Sin embargo, aquí es fundamental dónde se está llamando.
- UI como WinForms / WPF: hay situaciones en las que
Task.Runes eficaz - Procesamiento de solicitudes de ASP.NET Core: en general se evita escribir un
Task.Runseguido de unawaitinmediato - Procesamiento worker / en segundo plano: se procesa en el momento o se diseña el grado de paralelismo
Intercalar un único Task.Run en el procesamiento de solicitudes de ASP.NET Core y hacer await justo después tiende a añadir solo programación innecesaria.
Este punto se malinterpreta con facilidad, así que conviene separar los motivos. No es que «Task.Run no sirva de nada porque ya se ejecuta sobre el ThreadPool» (ejecutarse sobre el ThreadPool ocurre igualmente en el procesamiento en segundo plano de una aplicación de UI). Los puntos clave son estos dos:
- No aumenta el throughput. La cantidad total de cálculo de CPU no cambia; solo se traslada a otro hilo del ThreadPool. No aumenta el número de solicitudes que se pueden atender a la vez
- Tampoco libera la espera. En una aplicación de UI,
Task.Runes eficaz porque hay un hilo especial concreto que conviene liberar (el hilo de UI). En el lado del servidor no existe ese hilo especial. El hilo original sí queda libre, pero a cambio otro hilo queda ocupado con el mismo cálculo durante el mismo tiempo, así que el balance neto es cero
Lo que queda es el coste de encolar y cambiar de hilo, y que resulta un nivel más difícil saber «en qué hilo se está ejecutando esto». Por eso se evita.
Así que en ASP.NET Core es más razonable pensar así:
- Si es espera de E/S,
awaitnormal (plain) - Si es un cálculo de CPU corto, ejecutarlo en el momento
- Si es un proceso largo o que se quiere desacoplar del ciclo de vida de la solicitud, enviarlo a una cola o a un HostedService
Cabe señalar que, al llamar desde la UI a una API que solo tiene versión síncrona, sí se usa a veces Task.Run por la capacidad de respuesta de la UI.
Pero esto no es «E/S asíncrona»: solo se está evitando el problema ocupando un hilo completo.
En el lado del servidor, como en ASP.NET Core, esta forma de escapar en general no escala bien.
3.4. Si hay varios procesos independientes, use Task.WhenAll
Cuando hay varios procesos asíncronos independientes, es habitual encontrar código que los espera de uno en uno, así:
// Ejemplo de procesos independientes que terminan en serie
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);
Si no dependen entre sí, es más natural iniciarlos todos primero y esperarlos juntos al final.
public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
Task<string>[] tasks = urls
.Select(url => _httpClient.GetStringAsync(url, cancellationToken))
.ToArray();
return await Task.WhenAll(tasks);
}
El punto clave es ToArray().
Como LINQ tiene ejecución diferida, con solo hacer Select puede que todavía no se haya enumerado nada.
Al confirmarlo de una vez con ToArray() o ToList(), todas las tareas se inician en ese momento.
sequenceDiagram
accTitle: Inicio y espera conjunta de tres tareas independientes
accDescr: El llamador inicia las tres tareas y después espera con Task.WhenAll hasta que las tres se completan
participant Caller as Llamador
participant T1 as Tarea 1
participant T2 as Tarea 2
participant T3 as Tarea 3
Caller->>T1: Iniciar
Caller->>T2: Iniciar
Caller->>T3: Iniciar
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: Completada
T2-->>Caller: Completada
T3-->>Caller: Completada
Este patrón resulta adecuado cuando:
- El número de elementos es bajo o medio
- Se quiere esperar a todos juntos
- No hay problema en ejecutarlos todos a la vez sin límite
Si el número de elementos es alto, es más seguro poner un límite de paralelismo, como en 3.6.
3.5. Si quiere usar el primero que termine, use Task.WhenAny
Por ejemplo, en una situación donde se quiere usar la primera respuesta entre varios servidores espejo, Task.WhenAny resulta claro.
public async Task<byte[]> DownloadFromFirstMirrorAsync(
IReadOnlyList<string> urls,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
List<Task<byte[]>> pending = urls
.Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
.ToList();
var failures = new List<Exception>();
try
{
while (pending.Count > 0)
{
Task<byte[]> finished = await Task.WhenAny(pending);
pending.Remove(finished);
try
{
byte[] data = await finished; // Solo se sale de aquí si tuvo éxito
cts.Cancel(); // Detiene el resto una vez decidido el ganador
return data;
}
catch (Exception ex)
{
// Si quien llamó fue quien detuvo esto, no es un "fallo de espejo".
// Si se deja pasar sin distinguirlo, la cancelación de todas las
// tareas se acumula como fallo y termina en un AggregateException
// indistinguible de un fallo real
cancellationToken.ThrowIfCancellationRequested();
// Este espejo falló. Todavía hay esperanza en el resto, así que continúa
failures.Add(ex);
}
}
}
finally
{
cts.Cancel(); // Aunque se salga por una excepción, detiene las descargas restantes
try
{
await Task.WhenAll(pending);
}
catch
{
// Recoge las cancelaciones o fallos de las tareas que no ganaron
}
}
throw new AggregateException("No se pudo obtener el archivo desde ningún espejo.", failures);
}
En este código, el orden importa porque la cancelación se emite solo después de que se decide el ganador.
- Lo que devuelve
Task.WhenAnyes la primera tarea que se completa, no la primera que tiene éxito. Aunque el espejo más rápido falle con un 404 o un corte de conexión, ese es el que se devuelve como «ganador» - Si se cancela antes de mirar el resultado, se termina deteniendo por cuenta propia los espejos restantes que todavía están vivos, y además relanzando la excepción del ganador que en realidad falló. Es la peor forma de romperse, porque anula por completo el sentido de tener varios espejos
- Por eso, se van extrayendo las tareas completadas de una en una con
await, y solo cuando tienen éxito se cancela el resto. Si falla, esa tarea se descarta de los candidatos y se espera a la siguiente que termine Cancel()solo emite la solicitud, no espera a que el otro lado se detenga. Por eso se espera al resto enfinally, y ahí se observan las excepciones de cancelación o de fallo. Si se omite esto, quedan excepciones en las tareas que nadie observa- Si todas fallan, se lanzan juntos los fallos individuales. Si solo se lanzara la excepción de la primera, se perdería «cuál espejo falló y de qué manera»
- Solo la cancelación del llamador no se cuenta como fallo: se relanza tal cual hacia fuera. Cuando
cancellationTokense dispara, todas las tareas terminan conOperationCanceledException; si esto se acumula enfailures, al final se convierte en unAggregateException, y la interrupción o el tiempo de espera agotado del usuario se registra y se reintenta como si fuera «fallo de todos los espejos». Por eso se llama aThrowIfCancellationRequested()al principio delcatch, y la cancelación se devuelve tal cual comoOperationCanceledException
Aquí conviene tener presente que WhenAny solo devuelve un ganador.
El resto de los procesos, si no se hace nada, sigue ejecutándose igualmente.
Por eso hay que decidir de antemano:
- Si se quiere cancelar el resto
- Si se quieren observar las excepciones
Task.WhenAny es útil, pero requiere algo más de diseño que WhenAll.
Conviene elegirlo solo cuando «basta con el primero», así resulta más claro.
3.6. Si hay muchos elementos y quiere limitar el paralelismo, use Parallel.ForEachAsync o SemaphoreSlim
Task.WhenAll ejecuta a la vez todas las tareas creadas.
Por eso, si el número de elementos es alto, las conexiones HTTP, las conexiones a base de datos, el uso de memoria y la carga sobre servicios externos aumentan de golpe.
En estos casos es más estable decidir hasta cuántos se ejecutan a la vez.
Parallel.ForEachAsync deja bastante clara esa intención.
public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
urls.Select((url, index) => (url, index)),
options,
async (item, token) =>
{
string html = await _httpClient.GetStringAsync(item.url, token);
string path = Path.Combine("cache", $"{item.index}.html");
await File.WriteAllTextAsync(path, html, token);
});
}
Este patrón resulta adecuado cuando:
- El número de elementos es alto
- El procesamiento de cada elemento es independiente
- Aun así, se quiere evitar lanzarlos todos de golpe
Por otro lado, si se quiere un control más libre, también existe la opción de usar SemaphoreSlim.
Por ejemplo, para un control como «como máximo 4 llamadas simultáneas a determinada API externa».
En resumen:
- Con pocos elementos,
Task.WhenAll - Con muchos elementos,
Parallel.ForEachAsyncoSemaphoreSlim
Con esta distinción no se suele fallar demasiado.
3.7. Si quiere procesar en orden, use Channel<T>
A veces se quiere desacoplar del llamador un trabajo que «no necesita terminar ahora mismo, pero sí debe procesarse con seguridad». Por ejemplo, el envío de correos, el reenvío de logs, el posprocesamiento de webhooks o la conversión de archivos.
Si en estos casos se deja un Task.Run lanzado sin más, quedan ambiguos:
- Dónde se observan las excepciones
- Si se espera al finalizar
- Hasta dónde se acepta trabajo cuando aumenta el volumen
Este tipo de trabajo es más fácil de gestionar si se acumula en una cola y un consumidor dedicado lo procesa en orden.
flowchart LR
accTitle: Flujo de escritura y lectura en un Channel
accDescr: El producer escribe en el canal, si hay espacio libre el elemento entra al Channel y si no espera a que se libere, y el consumer lee y procesa los elementos en orden con await
p["producer"] --> w["WriteAsync"]
w --> q{"¿Hay espacio libre en la cola?"}
q -- "Sí" --> c["Entra al Channel"]
q -- "No" --> b["Espera a que se libere espacio"]
c --> d["consumer hace ReadAsync"]
d --> e["Procesa en orden con await"]
Channel<T> permite escribir la forma producer / consumer de manera bastante directa.
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
public ValueTask EnqueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workItem);
return _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
=> _queue.Reader.ReadAsync(cancellationToken);
}
En este ejemplo, BoundedChannelFullMode.Wait es una configuración que hace esperar al lado que escribe cuando la cola está llena.
Esto es el backpressure.
En ASP.NET Core, resulta claro consumir este tipo de colas combinándolas con un BackgroundService.
Comparado con un «fire-and-forget de verdad», esta forma facilita gestionar las excepciones, la parada, el grado de paralelismo y los límites.
3.8. Si quiere ejecutar a intervalos regulares, use PeriodicTimer
Para un proceso asíncrono a intervalos regulares, PeriodicTimer resulta bastante claro de leer.
public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(cancellationToken);
}
}
Las ventajas de esta forma de escribirlo son:
- El flujo es más fácil de seguir que con un Timer basado en callbacks
- Se puede escribir basándose en
await - Al detenerse, se puede usar
CancellationTokencon naturalidad
Como punto de atención, PeriodicTimer se usa bajo la premisa de no lanzar varias llamadas a WaitForNextTickAsync a la vez sobre un mismo temporizador.
Además, si el tiempo de procesamiento es mayor que el período, ese retraso hay que tratarlo como parte del diseño.
El temporizador no se paraleliza por sí solo para ponerse al día.
3.9. Si los datos llegan de forma secuencial, use IAsyncEnumerable<T>
Hay situaciones en las que se prefiere procesar lo que va llegando, en orden, en lugar de acumularlo todo en una List<T> antes de devolverlo.
- Leer en orden una API con paginación
- Leer las líneas de un archivo poco a poco
- Dejar fluir tal cual un resultado en streaming
En estos casos, IAsyncEnumerable<T> junto con await foreach resulta natural.
public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
{
await ProcessUserAsync(user, cancellationToken);
}
}
Esta forma es adecuada cuando:
- No se quiere esperar a que estén todos los elementos
- Se quiere procesar de uno en uno
- No se quiere acumular todo en memoria
Decidir si el valor de retorno debe ser Task<List<T>> o IAsyncEnumerable<T> resulta más claro si se piensa en si el resultado se va a usar completo de una vez, o en el orden en que va llegando.
3.10. Si necesita liberar recursos de forma asíncrona, use await using
Los tipos que necesitan un proceso asíncrono al liberarse, como un flush o el cierre de una comunicación, implementan IAsyncDisposable.
En ese caso se usa await using en lugar de using.
public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await stream.WriteAsync(data, cancellationToken);
}
Los puntos clave son:
- Si es
IAsyncDisposable, usarawait using - Es habitual que «abrir» sea síncrono pero «cerrar» sea asíncrono
Esto resulta útil cuando se quiere evitar la incoherencia de «escribir de forma async pero liberar de forma síncrona al final».
3.11. Si necesita exclusión mutua a través de un await, use SemaphoreSlim
En código que atraviesa un await, hay situaciones en las que se usa SemaphoreSlim en lugar de lock.
public sealed class CacheRefresher
{
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
await RefreshCoreAsync(cancellationToken);
}
finally
{
_gate.Release();
}
}
private static Task RefreshCoreAsync(CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
Lo importante son estos dos puntos:
- Entrar con
WaitAsync - Llamar siempre a
Releaseenfinally
En situaciones como «solo se quiere que entre uno a la vez» o «se quiere limitar a 3 llamadas simultáneas a una API externa», SemaphoreSlim resulta bastante práctico.
3.12. Diferencie la forma de escribir await según sea UI, código de aplicación o biblioteca
ConfigureAwait(false) no es algo que convenga añadir siempre, en cualquier caso.
A grandes rasgos, la distinción es esta:
flowchart LR
accTitle: Diferencia entre await normal y ConfigureAwait(false)
accDescr: El código de UI o de aplicación vuelve al contexto original tras el await, mientras que una biblioteca de propósito general no asume ningún contexto concreto al que volver
a["UI / código de aplicación"] --> b["await someAsync()"]
b --> c["Vuelve al contexto original y continúa"]
d["Biblioteca de propósito general"] --> e["await someAsync con ConfigureAwait(false)"]
e --> f["No asume que debe volver a un contexto concreto"]
- Código de UI / de aplicación
- Para empezar, basta con un
awaitnormal - Si después del await se hacen actualizaciones de UI o procesos que dependen del contexto de la aplicación, es más natural no añadir
ConfigureAwait(false)
- Para empezar, basta con un
- Código de aplicación de ASP.NET Core
- Normalmente basta con un
awaitnormal - No hace falta imponer
ConfigureAwait(false)como norma en todo el código
- Normalmente basta con un
- Código de biblioteca de propósito general
- Si no depende de la UI ni del modelo de la aplicación,
ConfigureAwait(false)resulta útil
- Si no depende de la UI ni del modelo de la aplicación,
En resumen:
- En el código de aplicación,
awaitnormal (plain) - En bibliotecas de propósito general, considerar
ConfigureAwait(false)
Con esto en mente, en la práctica diaria no debería haber mayor problema.
4. Reglas básicas de escritura
4.1. El valor de retorno: primero Task / Task<T>
El valor de retorno de un método async se decide, en primer lugar, con este orden.
| Valor de retorno | Criterio inicial |
|---|---|
Task |
Lo básico para un método async sin valor de retorno |
Task<T> |
Lo básico para un método async que devuelve un valor |
ValueTask / ValueTask<T> |
Se elige después de medir y ver que hace falta |
ValueTask parece útil, pero no siempre es mejor que Task.
Al ser un struct, tiene coste de copia y restricciones de uso.
Lo más importante es que ValueTask se diseña, en principio, para esperarse con await una sola vez.
No es apropiado guardarlo sin más en una variable local y esperarlo varias veces.
Por eso, en el código de aplicación del día a día, basta con Task / Task<T> para empezar.
Además, resulta más claro añadir el sufijo Async al nombre del método.
public Task SaveAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<int> CountAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_count);
}
Como en el ejemplo anterior, si no hay ningún proceso que esperar con await, es más natural devolver Task.CompletedTask o Task.FromResult en lugar de forzar el uso de async.
4.2. async void solo en controladores de eventos
Lo básico es evitar async void fuera de los controladores de eventos.
El motivo es simple:
- Quien llama no puede hacer await
- No se puede esperar a que termine
- El manejo de excepciones se vuelve difícil
- Es difícil de probar
Solo los controladores de eventos necesitan void por su firma, así que ahí es donde se usa exclusivamente.
private async void SaveButton_Click(object? sender, EventArgs e)
{
try
{
await SaveAsync(_saveCancellation.Token);
_statusLabel.Text = "Guardado.";
}
catch (OperationCanceledException)
{
_statusLabel.Text = "Cancelado.";
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Error al guardar");
}
}
En los controladores de eventos, es importante tener presente que hay que escribir uno mismo, hasta el final, la captura de la excepción dentro del método y su traslado de vuelta a la UI.
4.3. Reciba el CancellationToken y páselo aguas abajo
Si una operación se puede cancelar, se recibe el CancellationToken y se pasa directamente aguas abajo.
public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
Un caso frecuente aquí es que el nivel superior recibe el token pero no lo pasa aguas abajo. Esto suele terminar en código que «parece que se puede cancelar, pero no se detiene a mitad de camino».
Además, el significado del tiempo de espera (timeout) también cambia según si se quiere «poner un límite solo a la espera» o «detener también el proceso real».
- Poner un límite solo a la espera:
WaitAsync - Detener también el proceso real:
CancellationTokenSource.CancelAfterjunto con la propagación del token
Esta distinción tiende a convertirse en un problema más adelante si no se decide desde el principio, así que conviene fijarla de entrada.
4.4. Encadene las API asíncronas de forma asíncrona hasta el final
Si se usa async / await, lo más natural es encadenarlo de forma asíncrona hasta el final, en la medida de lo posible.
Como referencia para las sustituciones:
| Forma que tienta a escribir | Sustituir por |
|---|---|
Task.Result / Task.Wait() |
await |
Task.WaitAll() |
await Task.WhenAll(...) |
Task.WaitAny() |
await Task.WhenAny(...) |
Thread.Sleep(...) |
await Task.Delay(...) |
Especialmente en UI o en ASP.NET Core, si se mezcla una forma de esperar síncrona, se vuelve más difícil entender dónde se produce un bloqueo.
En el C# actual también se puede usar async Task Main(), así que en aplicaciones de consola hay muchas menos razones para forzar la sincronización.
4.5. Al crear tareas con LINQ, confírmelas con ToArray / ToList
Al combinar Task.WhenAll o Task.WhenAny con LINQ,
es más seguro confirmarlo antes con ToArray() o ToList().
Task<User>[] tasks = userIds
.Select(id => _userRepository.GetAsync(id, cancellationToken))
.ToArray();
User[] users = await Task.WhenAll(tasks);
El motivo es que LINQ tiene ejecución diferida. Es un peligro sutil leer el código dando por hecho que «ya está todo iniciado» cuando en realidad todavía no se ha enumerado nada.
- Si se va a esperar a todos juntos,
ToArray() - Si se quiere eliminar o sustituir elementos a mitad de camino,
ToList()
Recordarlo así facilita elegir entre ambos.
5. Antipatrones frecuentes
| Antipatrón | Qué lo hace problemático | Primera sustitución |
|---|---|---|
Task.Run(async () => await IoAsync()) |
Reenvía innecesariamente una espera de E/S | await IoAsync() |
Task.Result / Wait() |
Bloquea el hilo. Es fácil que se atasque | await |
Mezclar Thread.Sleep() en un flujo async |
Ocupa el hilo incluso durante la espera | Task.Delay() |
Usar async void en un método normal |
No se puede esperar, difícil de gestionar excepciones | Task / Task<T> |
await en serie donde correspondería Task.WhenAll |
Se vuelve lento sin necesidad | Iniciar todo primero y usar WhenAll |
Lanzar de golpe un gran volumen con WhenAll |
La carga se dispara | Parallel.ForEachAsync / SemaphoreSlim |
Intentar atravesar un await con lock |
No encaja con el propósito | SemaphoreSlim.WaitAsync |
Resolver un fire-and-forget con un Task.Run sin más |
La gestión de excepciones, parada y límites queda ambigua | Channel<T> / BackgroundService |
Añadir ConfigureAwait(false) de forma mecánica en código de UI |
La actualización de la UI tras el await se rompe con facilidad | await normal (plain) |
Convertir ValueTask en el estándar |
A menudo no compensa la complejidad que añade | Task primero |
De esta tabla, los tres casos que más se ven en la práctica son:
- Usar
Task.Runpara lo que en realidad es E/S - Hacer await en serie cuando en realidad es independiente
- No gestionar el ciclo de vida de un fire-and-forget
Con solo corregir estos tres puntos, la claridad del código mejora bastante.
6. Lista de verificación para revisiones
En una revisión de código en torno a async / await, se van comprobando estos puntos en orden, de arriba hacia abajo.
- ¿Se puede explicar de entrada, con palabras, si ese proceso es I/O-bound o CPU-bound?
- ¿Quedan restos de
Task.Result/Task.Wait()/Thread.Sleep()? - ¿Se está envolviendo una espera de E/S con
Task.Run? - ¿Se está haciendo un await en serie innecesario sobre procesos independientes?
- Al contrario, ¿se está lanzando un gran volumen sin límite con
WhenAll? - Si se recibe un
CancellationToken, ¿se pasa correctamente aguas abajo? - ¿Hay algún
async voidfuera de un controlador de eventos? - Si se usa
fire-and-forget, ¿está decidido quién gestiona las excepciones, la parada y los límites? - Si se usa
SemaphoreSlim, ¿estáReleasedentro de unfinally? - Si se usa
ValueTask, ¿hay una razón medida para ello y se asume que se espera una sola vez? - ¿La presencia o ausencia de
ConfigureAwait(false)encaja con el tipo de código?- En código de UI / de aplicación,
awaitnormal (plain) - En una biblioteca de propósito general, considerar
ConfigureAwait(false)
- En código de UI / de aplicación,
Esta lista de verificación también resulta útil para que el equipo alinee los criterios de revisión.
7. Resumen de cuándo usar cada opción
El resumen de cuándo usar cada opción está reunido en la tabla de decisión de 3.1. En lugar de repetir la misma tabla aquí, resulta más fácil volver a ella cuando haga falta, así que no se repite en este apartado.
- Para ver «qué usar primero» según la situación → la tabla de decisión de 3.1
- Para ver la forma de escribir cada patrón → de 3.2 a 3.12 (corresponden a las filas de la tabla de 3.1)
Hay una sola decisión que no está en la tabla de 3.1: el tipo del valor de retorno. Como esto no depende de la situación sino del diseño del método, se trata en 4.1. Si solo se quiere la conclusión: elegir primero Task / Task<T>, y ValueTask solo después de medir y ver que hace falta.
8. Resumen
Las mejores prácticas de async / await no consisten tanto en memorizar muchas técnicas puntuales, sino más bien en elegir el tipo según la clase de proceso; ese criterio es el que resulta más eficaz en la práctica.
El orden en que conviene mirarlo es, a grandes rasgos, este:
- Separar si es espera de E/S o cálculo de CPU
- Si es E/S, hacer
awaitdirectamente sobre la API async - Si es cálculo de CPU, decidir dónde debe ejecutarse
- Si hay varios procesos, elegir entre
WhenAll/WhenAny/ limitar el paralelismo - Si se quiere desacoplar del ciclo de vida de la solicitud, usar una cola en lugar de un fire-and-forget puro
- Alinear el tratamiento del valor de retorno, la cancelación, las excepciones, la exclusión mutua y el contexto
Como la propia forma de escribir async / await es concisa, usarla sin cuidado hace que el criterio se vuelva difícil de ver. En cambio,
- Tratar la E/S como E/S
- Tratar la CPU como CPU
- Gestionar el ciclo de vida del procesamiento en segundo plano como tal
Con solo separar estos tres aspectos, la legibilidad mejora bastante.
9. Referencias
- El conjunto completo de código de ejemplo de este artículo (biblioteca, demo y pruebas unitarias) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Buenas prácticas de multithreading en la práctica — Edición .NET: qué decidir antes de aumentar los hilos
Reglas de diseño en .NET/C# para evitar fallos y bloqueos intermitentes con hilos: usar Task en lugar de hilos propios, reducir el estado...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Cómo elegir la comunicación entre procesos en Windows — canalizaciones con nombre / TCP / gRPC / memoria compartida / COM: tabla de decisión
Cómo elegir la comunicación entre procesos en Windows: comparamos en una tabla de decisión las canalizaciones con nombre, el TCP local, g...
Cómo elegir el destino de almacenamiento de datos en una app de Windows ── Tabla de decisión: SQLite / JSON / Registro / Access
Dónde y con qué guardar los datos de una app de escritorio Windows: uso de AppData/ProgramData, ventajas y trampas de SQLite, JSON, Regis...
¿Qué es .NET Generic Host? - La base de DI, configuración y registro
Analiza el papel de Generic Host —DI, configuración, registro, IHostedService y BackgroundService— y resume, con criterio práctico, dónde...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Hilo de UI y temporizadores
Hilo de UI de WPF / WinForms, flujos asíncronos, Dispatcher y diseño de temporizadores.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
En aplicaciones de Windows que combinan UI, procesamiento en segundo plano y E/S, la forma de usar async/await incide directamente en la calidad de la implementación.
Consultoría técnica y revisión de diseño
Si desea ordenar las decisiones sobre Task.Run o ConfigureAwait junto con la separación de responsabilidades, esto conecta con la consultoría técnica y la revisión de diseño.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Cuándo debería usar Task.Run en C#?
- Task.Run resulta útil cuando quiere sacar un cálculo de CPU del hilo actual. Por ejemplo, si en un controlador de evento de UI de WinForms o WPF ejecuta un cálculo pesado directamente, la pantalla se congela, por lo que sacarlo del hilo de UI con Task.Run es la solución natural. En cambio, el procesamiento de solicitudes de ASP.NET Core ya se ejecuta sobre el ThreadPool, así que intercalar Task.Run y hacer await justo después tiende a añadir programación innecesaria sin ningún beneficio, y en general se evita. Para procesos largos o que quiere desacoplar del ciclo de vida de la solicitud, es más razonable enviarlos a una cola o a un HostedService.
- ¿No se debe envolver el procesamiento de E/S en await Task.Run()?
- Para esperas de E/S como HTTP, base de datos o lectura/escritura de archivos, lo básico es hacer await directamente sobre la versión async de la API, sin necesidad de envolverla en Task.Run. Envolver una E/S que ya es async dentro de Task.Run simplemente reenvía la espera de E/S a otro hilo, lo que complica el código sin aportar ninguna ventaja. Cabe señalar que, cuando se llama desde la UI a una API que solo tiene versión síncrona, sí se usa a veces Task.Run por motivos de capacidad de respuesta, pero eso no es E/S asíncrona: solo se está evitando el problema ocupando un hilo completo, una salida que en el lado del servidor tiende a no escalar bien.
- ¿Dónde debería colocar ConfigureAwait(false)?
- En código de UI o de aplicación, lo natural para empezar es un await normal. Si después del await realiza actualizaciones de UI o procesos que dependen del contexto de la aplicación, es más natural no añadir ConfigureAwait(false). El código de aplicación de ASP.NET Core también suele bastarse con un await normal, sin necesidad de aplicar ConfigureAwait(false) como norma estricta. Donde ConfigureAwait(false) resulta más útil es en código de biblioteca de propósito general que no depende de la UI ni del modelo de la aplicación. Recordar la regla «en el lado de la aplicación, await normal; en bibliotecas de propósito general, considere ConfigureAwait(false)» es suficiente en la práctica diaria.
- ¿Por qué se debe evitar async void fuera de los controladores de eventos?
- Porque con async void el código que llama no puede hacer await, no puede esperar la finalización, el manejo de excepciones se vuelve difícil y también es complicado de probar. Lo básico es que un método normal devuelva Task o Task<T>. Solo los controladores de eventos necesitan void por la firma del delegado, así que ahí es el único lugar donde se usa, y en ese caso es importante escribir usted mismo, dentro del controlador, la captura de la excepción con try/catch y su traslado de vuelta a la UI.
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.