Escenarios de programación asincrónica

Si tiene cualquier necesidad enlazada a E/S (por ejemplo, solicitar datos de una red, acceder a una base de datos o leer y escribir un sistema de archivos), deberá usar la programación asincrónica. También podría tener código enlazado a la CPU, como realizar un cálculo costoso, que también es un buen escenario para escribir código asincrónico.

C# tiene un modelo de programación asincrónico de nivel de lenguaje que permite escribir fácilmente código asincrónico sin tener que hacer malabares con las devoluciones de llamada o ajustarse a una biblioteca que admita la asincronía. Sigue lo que se conoce como el modelo asincrónico basado en tareas (TAP).

Información general del modelo asincrónico

El núcleo de la programación asincrónica son los objetos Task y Task<T>, que modelan las operaciones asincrónicas. Son compatibles con las palabras clave async y await. El modelo es bastante sencillo en la mayoría de los casos:

  • Para el código enlazado a E/S, espera una operación que devuelva Task o Task<T> dentro de un método async.
  • Para el código enlazado a la CPU, espera una operación que se inicia en un subproceso en segundo plano con el método Task.Run.

La palabra clave await es donde ocurre la magia. Genera control para el autor de la llamada del método que ha realizado await, y permite en última instancia una interfaz de usuario con capacidad de respuesta o un servicio flexible. Aunque existen maneras de abordar el código asincrónico diferentes de async y await, este artículo se centra en las construcciones de nivel de lenguaje.

Nota:

En algunos de los ejemplos siguientes, se usa la clase System.Net.Http.HttpClient para descargar algunos datos de un servicio web. El objeto s_httpClient usado en estos ejemplos es un campo estático de clase Program (compruebe el ejemplo completo):

private static readonly HttpClient s_httpClient = new();

Ejemplo enlazado a E/S: descarga de datos de un servicio web

Puede que necesite descargar algunos datos de un servicio web cuando se presione un botón, pero no quiere bloquear el subproceso de interfaz de usuario. Puede conseguirlo de la siguiente forma:

s_downloadButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI as the request
    // from the web service is happening.
    //
    // The UI thread is now free to perform other work.
    var stringData = await s_httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

El código expresa la intención (descargar datos de forma asincrónica) sin verse obstaculizado en la interacción con objetos Task.

Ejemplo enlazado a la CPU: realizar un cálculo para un juego

Supongamos que está escribiendo un juego para móviles en el que se pueden infligir daños a muchos enemigos en la pantalla pulsando un botón. Realizar el cálculo del daño puede resultar costoso y hacerlo en el subproceso de interfaz de usuario haría que pareciera que el juego se pone en pausa mientras se lleva a cabo el cálculo.

La mejor manera de abordar esta situación consiste en iniciar un subproceso en segundo plano que realice la tarea mediante Task.Run y esperar su resultado mediante await. Esto permite que la interfaz de usuario funcione de manera fluida mientras se lleva a cabo la tarea.

static DamageResult CalculateDamageDone()
{
    return new DamageResult()
    {
        // Code omitted:
        //
        // Does an expensive calculation and returns
        // the result of that calculation.
    };
}

s_calculateButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI while CalculateDamageDone()
    // performs its work. The UI thread is free to perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

Este código expresa claramente la intención del evento de clic del botón, no requiere la administración manual de un subproceso en segundo plano y lo hace en un modo sin bloqueo.

Qué sucede en segundo plano

En lo que respecta a C#, el compilador transforma el código en una máquina de estados que realiza el seguimiento de acciones como la retención de la ejecución cuando se alcanza await y la reanudación de la ejecución cuando se ha finalizado un trabajo en segundo plano.

Para los más interesados en la teoría, se trata de una implementación del modelo de promesas de asincronía.

Piezas clave que debe comprender

  • El código asincrónico puede usarse para código tanto enlazado a E/S como enlazado a la CPU, pero de forma distinta en cada escenario.
  • El código asincrónico usa Task<T> y Task, que son construcciones que se usan para modelar el trabajo que se realiza en segundo plano.
  • La palabra clave async convierte un método en un método asincrónico, lo que permite usar la palabra clave await en su cuerpo.
  • Cuando se aplica la palabra clave await, se suspende el método de llamada y se cede el control al autor de la llamada hasta que se completa la tarea esperada.
  • await solo puede usarse dentro de un método asincrónico.

Reconocer el trabajo enlazado a la CPU y el enlazado a E/S

En los dos primeros ejemplos de esta guía se ha explicado cómo podría usar async y await para trabajos enlazados a E/S y a la CPU. Resulta fundamental que pueda identificar si el trabajo que debe realizar está enlazado a E/S o a la CPU, ya que esto puede afectar en gran medida al rendimiento del código y podría dar lugar al uso inadecuado de ciertas construcciones.

A continuación, se indican dos preguntas que debe hacerse antes de escribir el código:

  1. ¿Estará su código "esperando" algo, como datos de una base de datos?

    Si la respuesta es "sí", su trabajo está enlazado a E/S.

  2. ¿Realizará el código un cálculo costoso?

    Si la respuesta es "sí", su trabajo está enlazado a la CPU.

Si el trabajo que tiene está enlazado a E/S, use async y awaitsinTask.Run. No debe usar la Biblioteca TPL.

Si el trabajo que tiene está enlazado a la CPU y le interesa la capacidad de respuesta, use async y await, pero genere el trabajo en otro subproceso conTask.Run. Si el trabajo es adecuado para la simultaneidad y el paralelismo, también debe plantearse el uso de la biblioteca TPL.

Además, siempre debe medir la ejecución del código. Por ejemplo, puede verse en una situación en la que el trabajo enlazado a la CPU no sea suficientemente costoso en comparación con la sobrecarga de cambios de contexto cuando realice multithreading. Cada opción tiene su compensación y debe elegir el equilibrio correcto para su situación.

Más ejemplos

En los ejemplos siguientes se muestran distintas maneras en las que puede escribir código asincrónico en C#. Abarcan algunos escenarios diferentes con los que puede encontrarse.

Extracción de datos de una red

Este fragmento de código descarga el HTML desde la dirección URL determinada y cuenta el número de veces que aparece la cadena ".NET" en el código HTML. Usa ASP.NET para definir un método de controlador Web API que realiza esta tarea y devuelve el número.

Nota

Si tiene previsto realizar un análisis HTML en el código de producción, no use expresiones regulares. Use una biblioteca de análisis en su lugar.

[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
    // Suspends GetDotNetCount() to allow the caller (the web server)
    // to accept another request, rather than blocking on this one.
    var html = await s_httpClient.GetStringAsync(URL);
    return Regex.Matches(html, @"\.NET").Count;
}

Este es el mismo escenario escrito para una aplicación Windows Universal, que realiza la misma tarea cuando se presiona un botón:

private readonly HttpClient _httpClient = new HttpClient();

private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
    // Capture the task handle here so we can await the background task later.
    var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");

    // Any other work on the UI thread can be done here, such as enabling a Progress Bar.
    // This is important to do here, before the "await" call, so that the user
    // sees the progress bar before execution of this method is yielded.
    NetworkProgressBar.IsEnabled = true;
    NetworkProgressBar.Visibility = Visibility.Visible;

    // The await operator suspends OnSeeTheDotNetsButtonClick(), returning control to its caller.
    // This is what allows the app to be responsive and not block the UI thread.
    var html = await getDotNetFoundationHtmlTask;
    int count = Regex.Matches(html, @"\.NET").Count;

    DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}";

    NetworkProgressBar.IsEnabled = false;
    NetworkProgressBar.Visibility = Visibility.Collapsed;
}

Esperar a que se completen varias tareas

Es posible que se vea en una situación en la que necesite recuperar varios fragmentos de datos al mismo tiempo. La API Task contiene dos métodos, Task.WhenAll y Task.WhenAny, que permiten escribir código asincrónico que realiza una espera sin bloqueo en varios trabajos en segundo plano.

En este ejemplo se muestra cómo podría captar datos User de un conjunto de elementos userId.

private static async Task<User> GetUserAsync(int userId)
{
    // Code omitted:
    //
    // Given a user Id {userId}, retrieves a User object corresponding
    // to the entry in the database with {userId} as its Id.

    return await Task.FromResult(new User() { id = userId });
}

private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
{
    var getUserTasks = new List<Task<User>>();
    foreach (int userId in userIds)
    {
        getUserTasks.Add(GetUserAsync(userId));
    }

    return await Task.WhenAll(getUserTasks);
}

Aquí tiene otra manera de escribir lo mismo de una forma más sucinta, con LINQ:

private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
    var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
    return await Task.WhenAll(getUserTasks);
}

Aunque es menos código, tenga cuidado al combinar LINQ con código asincrónico. Dado que LINQ usa la ejecución diferida, las llamadas asincrónicas no se realizarán inmediatamente, como lo hacen en un bucle foreach, a menos que fuerce la secuencia generada a procesar una iteración con una llamada a .ToList() o .ToArray(). En el ejemplo anterior se usa Enumerable.ToArray para realizar la consulta diligentemente y almacenar los resultados en una matriz. Esto obliga al código id => GetUserAsync(id) a ejecutar e iniciar la tarea.

Consejos e información importante

Con la programación asincrónica, hay algunos detalles que debe tener en cuenta para evitar un comportamiento inesperado.

  • Los métodos async deben tener una palabra claveawait en el cuerpo o nunca proporcionarán resultados.

    Es importante que tenga esto en cuenta. Si no se usa await en el cuerpo de un método async, el compilador de C# genera una advertencia, pero el código se compila y se ejecuta como si se tratara de un método normal. Esto sería muy ineficaz, ya que la máquina de estados generada por el compilador de C# para el método asincrónico no realiza nada.

  • Agregue "Async" como el sufijo de todos los métodos asincrónicos que escriba.

    Se trata de la convención que se usa en .NET para distinguir más fácilmente los métodos sincrónicos de los asincrónicos. No se aplican necesariamente ciertos métodos a los que el código no llame explícitamente (como controladores de eventos o métodos de controlador web). Puesto que el código no los llama explícitamente, resulta importante explicitar sus nombres.

  • async voidsolo se debe usar para controladores de eventos.

    async void es la única manera de permitir a los controladores de eventos asincrónicos trabajar, ya que los eventos no tienen tipos de valor devuelto (por lo tanto, no pueden hacer uso de Task y Task<T>). Cualquier otro uso de async void no sigue el modelo de TAP y puede resultar difícil de usar, como:

    • Las excepciones producidas en un método async void no se pueden detectar fuera de ese método.
    • Los métodos async void resultan muy difíciles de probar.
    • Los métodos async void pueden provocar efectos secundarios negativos si el autor de la llamada no espera que sean asincrónicos.
  • Tenga cuidado al usar lambdas asincrónicas en las expresiones de LINQ.

    Las expresiones lambda de LINQ usan la ejecución aplazada, lo que implica que el código podría acabar ejecutándose en un momento en que no se lo espere. La introducción de las tareas de bloqueo puede dar lugar a un interbloqueo si no se han escrito correctamente. Además, el anidamiento de código asincrónico de esta manera también puede hacer que resulte más difícil razonar sobre la ejecución del código. Async y LINQ son eficaces, pero deben usarse conjuntamente con el mayor cuidado y claridad posible.

  • Escriba código que espere las tareas sin bloqueo.

    Bloquear el subproceso actual como un medio para esperar que se complete Task puede dar lugar a interbloqueos y subprocesos de contexto bloqueados, y puede requerir un control de errores más complejo. En la tabla siguiente se ofrece orientación sobre cómo abordar la espera de las tareas de una manera que no produzca un bloqueo:

    Use esto... En vez de esto... Cuando quiera hacer esto...
    await Task.Wait o Task.Result Recuperar el resultado de una tarea en segundo plano
    await Task.WhenAny Task.WaitAny Esperar que finalice cualquier tarea
    await Task.WhenAll Task.WaitAll Esperar que finalicen todas las tareas
    await Task.Delay Thread.Sleep Esperar un período de tiempo
  • Considere la posibilidad de usarValueTasksiempre que sea posible

    La devolución de un objeto Task desde métodos asincrónicos puede presentar cuellos de botella de rendimiento en determinadas rutas de acceso. Task es un tipo de referencia, por lo que su uso implica la asignación de un objeto. En los casos en los que un método declarado con el modificador async devuelva un resultado en caché o se complete sincrónicamente, las asignaciones adicionales pueden suponer un costo considerable de tiempo en secciones críticas para el rendimiento del código. Esas asignaciones pueden resultar costosas si se producen en bucles ajustados. Para obtener más información, consulte Tipos de valor devueltos asincrónicos generalizados.

  • Considere la posibilidad de usar ConfigureAwait(false)

    Una pregunta habitual es "¿Cuándo debo usar el método Task.ConfigureAwait(Boolean)?". El método permite a una instancia de Task configurar su elemento awaiter. Este es un aspecto importante que debe tenerse en cuenta, y su configuración incorrecta podría tener implicaciones de rendimiento e incluso interbloqueos. Para obtener más información sobre ConfigureAwait, consulte las preguntas más frecuentes sobre ConfigureAwait.

  • Escriba código con menos estados.

    No dependa del estado de los objetos globales o la ejecución de ciertos métodos. En su lugar, dependa únicamente de los valores devueltos de los métodos. ¿Por qué?

    • Le resultará más fácil razonar sobre el código.
    • Le resultará más fácil probar el código.
    • Resulta mucho más sencillo mezclar código asincrónico y sincrónico.
    • Normalmente se pueden evitar por completo las condiciones de carrera.
    • Depender de los valores devueltos facilita la coordinación de código asincrónico.
    • (Extra) Funciona muy bien con la inserción de dependencias.

Un objetivo recomendado es lograr una transparencia referencial completa o casi completa en el código. Esto se traducirá en un código base predecible, que se puede probar y es fácil de mantener.

Ejemplo completo

El código siguiente es el texto completo del archivo Program.cs para el ejemplo.

using System.Text.RegularExpressions;
using System.Windows;
using Microsoft.AspNetCore.Mvc;

class Button
{
    public Func<object, object, Task>? Clicked
    {
        get;
        internal set;
    }
}

class DamageResult
{
    public int Damage
    {
        get { return 0; }
    }
}

class User
{
    public bool isEnabled
    {
        get;
        set;
    }

    public int id
    {
        get;
        set;
    }
}

public class Program
{
    private static readonly Button s_downloadButton = new();
    private static readonly Button s_calculateButton = new();

    private static readonly HttpClient s_httpClient = new();

    private static readonly IEnumerable<string> s_urlList = new string[]
    {
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/shows/net-core-101/what-is-net",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://dotnetfoundation.org",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            "https://learn.microsoft.com/xamarin"
    };

    private static void Calculate()
    {
        // <PerformGameCalculation>
        static DamageResult CalculateDamageDone()
        {
            return new DamageResult()
            {
                // Code omitted:
                //
                // Does an expensive calculation and returns
                // the result of that calculation.
            };
        }

        s_calculateButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI while CalculateDamageDone()
            // performs its work. The UI thread is free to perform other work.
            var damageResult = await Task.Run(() => CalculateDamageDone());
            DisplayDamage(damageResult);
        };
        // </PerformGameCalculation>
    }

    private static void DisplayDamage(DamageResult damage)
    {
        Console.WriteLine(damage.Damage);
    }

    private static void Download(string URL)
    {
        // <UnblockingDownload>
        s_downloadButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI as the request
            // from the web service is happening.
            //
            // The UI thread is now free to perform other work.
            var stringData = await s_httpClient.GetStringAsync(URL);
            DoSomethingWithData(stringData);
        };
        // </UnblockingDownload>
    }

    private static void DoSomethingWithData(object stringData)
    {
        Console.WriteLine("Displaying data: ", stringData);
    }

    // <GetUsersForDataset>
    private static async Task<User> GetUserAsync(int userId)
    {
        // Code omitted:
        //
        // Given a user Id {userId}, retrieves a User object corresponding
        // to the entry in the database with {userId} as its Id.

        return await Task.FromResult(new User() { id = userId });
    }

    private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
    {
        var getUserTasks = new List<Task<User>>();
        foreach (int userId in userIds)
        {
            getUserTasks.Add(GetUserAsync(userId));
        }

        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDataset>

    // <GetUsersForDatasetByLINQ>
    private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
    {
        var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDatasetByLINQ>

    // <ExtractDataFromNetwork>
    [HttpGet, Route("DotNetCount")]
    static public async Task<int> GetDotNetCount(string URL)
    {
        // Suspends GetDotNetCount() to allow the caller (the web server)
        // to accept another request, rather than blocking on this one.
        var html = await s_httpClient.GetStringAsync(URL);
        return Regex.Matches(html, @"\.NET").Count;
    }
    // </ExtractDataFromNetwork>

    static async Task Main()
    {
        Console.WriteLine("Application started.");

        Console.WriteLine("Counting '.NET' phrase in websites...");
        int total = 0;
        foreach (string url in s_urlList)
        {
            var result = await GetDotNetCount(url);
            Console.WriteLine($"{url}: {result}");
            total += result;
        }
        Console.WriteLine("Total: " + total);

        Console.WriteLine("Retrieving User objects with list of IDs...");
        IEnumerable<int> ids = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        var users = await GetUsersAsync(ids);
        foreach (User? user in users)
        {
            Console.WriteLine($"{user.id}: isEnabled={user.isEnabled}");
        }

        Console.WriteLine("Application ending.");
    }
}

// Example output:
//
// Application started.
// Counting '.NET' phrase in websites...
// https://learn.microsoft.com: 0
// https://learn.microsoft.com/aspnet/core: 57
// https://learn.microsoft.com/azure: 1
// https://learn.microsoft.com/azure/devops: 2
// https://learn.microsoft.com/dotnet: 83
// https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio: 31
// https://learn.microsoft.com/education: 0
// https://learn.microsoft.com/shows/net-core-101/what-is-net: 42
// https://learn.microsoft.com/enterprise-mobility-security: 0
// https://learn.microsoft.com/gaming: 0
// https://learn.microsoft.com/graph: 0
// https://learn.microsoft.com/microsoft-365: 0
// https://learn.microsoft.com/office: 0
// https://learn.microsoft.com/powershell: 0
// https://learn.microsoft.com/sql: 0
// https://learn.microsoft.com/surface: 0
// https://dotnetfoundation.org: 16
// https://learn.microsoft.com/visualstudio: 0
// https://learn.microsoft.com/windows: 0
// https://learn.microsoft.com/xamarin: 6
// Total: 238
// Retrieving User objects with list of IDs...
// 1: isEnabled= False
// 2: isEnabled= False
// 3: isEnabled= False
// 4: isEnabled= False
// 5: isEnabled= False
// 6: isEnabled= False
// 7: isEnabled= False
// 8: isEnabled= False
// 9: isEnabled= False
// 0: isEnabled= False
// Application ending.

Otros recursos