Tabla práctica de decisión de C# async/await - Task.Run y ConfigureAwait

· Actualizado el: · · 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.Run una espera de E/S que no lo necesita
  • Hacer await en serie, uno a uno, sobre procesos independientes
  • Introducir fire-and-forget sin 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 ValueTask solo 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

  1. Primero, la conclusión (en pocas palabras)
  2. Términos usados en este artículo
    • 2.1. Términos que conviene distinguir primero
    • 2.2. Términos frecuentes
  3. 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
  4. 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
  5. Antipatrones frecuentes
  6. Lista de verificación para revisiones
  7. Resumen de cuándo usar cada opción
  8. Resumen
  9. Referencias

1. Primero, la conclusión (en pocas palabras)

  • async / await es 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.Run puede ser útil, pero en el procesamiento de solicitudes de ASP.NET Core, en general se evita escribir un Task.Run seguido de un await inmediato
  • Para varios procesos independientes, conviene considerar primero Task.WhenAll antes 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-forget parece 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>. ValueTask se 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 un await normal
  • async void no 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:

  1. Qué es lo que ese proceso está esperando
  2. Quién posee el ciclo de vida de ese proceso
  3. 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), await captura el SynchronizationContext vigente en el momento de entrar en espera y ejecuta la continuación de vuelta en él (si no hay SynchronizationContext configurado, comprueba si se está usando un TaskScheduler distinto del predeterminado)
  • WinForms / WPF tienen un SynchronizationContext que reenvía el procesamiento al hilo de UI. Por eso, después de un await, 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 del await se 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
Árbol de decisión para elegir el patrón de async/awaitParte 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 casoNoEvento de UI / escritorioSolicitud de ASP.NET Coreworker / segundo planoNoEsperar a que terminen todosUsar el primero que termineHay muchos elementosProcesar en ordenIntervalo regularFlujo secuencialProceso que se desea realizar¿Espera una E/S externa?Hacer await directamente sobre la API async¿El cálculo de CPU es pesado?¿Dónde se ejecuta?Considerar Task.RunNo envolver con Task.RunSi hace falta, enviar a otro worker o colaEjecutar en el momentoo definir explícitamente el grado de paralelismo¿Se manejan varios trabajos?Task.WhenAllTask.WhenAnyParallel.ForEachAsynco SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

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.Run es eficaz
  • Procesamiento de solicitudes de ASP.NET Core: en general se evita escribir un Task.Run seguido de un await inmediato
  • 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.Run es 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, await normal (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.

Inicio y espera conjunta de tres tareas independientesEl llamador inicia las tres tareas y después espera con Task.WhenAll hasta que las tres se completanTarea 3Tarea 2Tarea 1LlamadorTarea 3Tarea 2Tarea 1LlamadorIniciarIniciarIniciarawait Task.WhenAll(...)CompletadaCompletadaCompletada

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.WhenAny es 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 en finally, 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 cancellationToken se dispara, todas las tareas terminan con OperationCanceledException; si esto se acumula en failures, al final se convierte en un AggregateException, 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 a ThrowIfCancellationRequested() al principio del catch, y la cancelación se devuelve tal cual como OperationCanceledException

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.ForEachAsync o SemaphoreSlim

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.

Flujo de escritura y lectura en un ChannelEl 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 awaitNoproducerWriteAsync¿Hay espacio libre en la cola?Entra al ChannelEspera a que se libere espacioconsumer hace ReadAsyncProcesa 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 CancellationToken con 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, usar await 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 Release en finally

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:

Diferencia entre await normal y ConfigureAwait(false)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 volverUI / código de aplicaciónawait someAsync()Vuelve al contexto original y continúaBiblioteca de propósito generalawait someAsync con ConfigureAwait(false)No asume que debe volver a un contexto concreto
  • Código de UI / de aplicación
    • Para empezar, basta con un await normal
    • 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)
  • Código de aplicación de ASP.NET Core
    • Normalmente basta con un await normal
    • No hace falta imponer ConfigureAwait(false) como norma en todo el código
  • 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

En resumen:

  • En el código de aplicación, await normal (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.CancelAfter junto 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:

  1. Usar Task.Run para lo que en realidad es E/S
  2. Hacer await en serie cuando en realidad es independiente
  3. 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 void fuera 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á Release dentro de un finally?
  • 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, await normal (plain)
    • En una biblioteca de propósito general, considerar ConfigureAwait(false)

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:

  1. Separar si es espera de E/S o cálculo de CPU
  2. Si es E/S, hacer await directamente sobre la API async
  3. Si es cálculo de CPU, decidir dónde debe ejecutarse
  4. Si hay varios procesos, elegir entre WhenAll / WhenAny / limitar el paralelismo
  5. Si se quiere desacoplar del ciclo de vida de la solicitud, usar una cola en lugar de un fire-and-forget puro
  6. 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

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.

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

Volver al blog