Cenários de programação assíncrona

Se você tiver qualquer necessidade vinculada à E/S (como a solicitação de dados de uma rede, o acesso a um banco de dados ou a leitura e gravação em um sistema de arquivos), você pode querer usar a programação assíncrona. Você também pode ter código vinculado à CPU, como a execução de um cálculo dispendioso, que também é um bom cenário para escrever código assíncrono.

O C# tem um modelo de programação assíncrona em nível de linguagem que permite escrever facilmente código assíncrono sem precisar manipular retornos de chamada ou estar em conformidade com uma biblioteca que dê suporte à assincronia. Ele segue o que é conhecido como TAP (Padrão assíncrono baseado em tarefa).

Visão geral do modelo assíncrono

O núcleo da programação assíncrona são os objetos Task e Task<T>, que modelam as operações assíncronas. Eles têm suporte das palavras-chave async e await. O modelo é bastante simples na maioria dos casos:

  • Para código vinculado à E/S, você aguarda uma operação que retorna um Task ou Task<T> dentro de um método async.
  • Para o código vinculado à CPU, você aguarda uma operação iniciada em um thread em segundo plano com o método Task.Run.

É na palavra-chave await que a mágica acontece. Ela cede o controle para o chamador do método que executou await e, em última instância, permite que uma interface do usuário tenha capacidade de resposta ou que um serviço seja elástico. Embora existam maneiras de abordar o código assíncrono diferentes de async e await, este artigo se concentra nos constructos no nível da linguagem.

Observação

Em alguns dos exemplos a seguir, a classe System.Net.Http.HttpClient é utilizada para fazer download de alguns dados de um serviço Web. O objeto s_httpClient utilizado nesses exemplos é um campo estático da classe Program (verifique o exemplo concluído):

private static readonly HttpClient s_httpClient = new();

Exemplo vinculado à E/S: baixar dados de um serviço Web

Talvez você queira baixar alguns dados de um serviço Web quando um botão for pressionado, mas não deseja bloquear o thread da interface do usuário. Isso pode ser realizado da seguinte 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);
};

O código expressa a intenção (baixar alguns dados de forma assíncrona) sem se prender à interação com objetos Task.

Exemplo vinculado à CPU: executar um cálculo para um jogo

Digamos que você está escrevendo um jogo para dispositivo móvel em que, ao pressionar um botão, poderá causar danos a muitos inimigos na tela. A realização do cálculo de dano pode ser dispendiosa e fazê-lo no thread da interface do usuário faria com que o jogo parecesse pausar durante a realização do cálculo!

A melhor maneira de lidar com isso é iniciar um thread em segundo plano que faz o trabalho usando Task.Run, e aguardar o resultado usando await. Isso permitirá que a interface do usuário funcione de maneira suave enquanto o trabalho está sendo feito.

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);
};

Esse código expressa claramente a intenção do evento de clique do botão. Ele não requer o gerenciamento manual de um thread em segundo plano, e faz isso sem bloqueios.

O que acontece nos bastidores

No lado C# das coisas, o compilador transforma seu código em uma máquina de estado que mantém o controle de situações como transferir a execução quando uma await é alcançada e retomar a execução quando um trabalho em segundo plano for concluído.

Para os que gostam da teoria, essa é uma implementação do Modelo Promise de assincronia.

Informações importantes para entender

  • O código assíncrono pode ser usado tanto para o código vinculado à E/S quanto vinculado à CPU, mas de maneira diferente para cada cenário.
  • O código assíncrono usa Task<T> e Task, que são constructos usados para modelar o trabalho que está sendo feito em segundo plano.
  • A palavra-chave async transforma um método em um método assíncrono, o que permite que você use a palavra-chave await em seu corpo.
  • Quando a palavra-chave await é aplicada, ela suspende o método de chamada e transfere o controle de volta ao seu chamador até que a tarefa em espera seja concluída.
  • A await só pode ser usada dentro de um método assíncrono.

Reconhecer trabalho vinculado à CPU e vinculado à E/S

Os primeiros dois exemplos deste guia mostraram como você pode usar async e await para trabalho vinculado à E/S e vinculado à CPU. É fundamental que você saiba identificar quando um trabalho que você precisa fazer é vinculado à E/S ou vinculado à CPU, porque isso pode afetar significativamente o desempenho do seu código e poderia potencialmente levar ao uso indevido de determinados constructos.

Aqui estão duas perguntas que devem ser feitas antes de escrever qualquer código:

  1. Seu código ficará em "espera" por alguma coisa, como dados de um banco de dados?

    Se a resposta é "sim", seu trabalho é vinculado à E/S.

  2. Seu código executará uma computação dispendiosa?

    Se você respondeu "sim", seu trabalho é vinculado à CPU.

Se o seu trabalho for vinculado à E/S, use async e awaitsemTask.Run. Você não deve usar a biblioteca de paralelismo de tarefas.

Se o seu trabalho for vinculado à CPU e você se importa com a capacidade de resposta, use async e await, mas gere o trabalho em outro thread comTask.Run. Se o trabalho for adequado para a simultaneidade e paralelismo, você também deverá considerar o uso da Biblioteca de paralelismo de tarefas.

Além disso, você sempre deve medir a execução do seu código. Por exemplo, talvez você tenha uma situação em que seu trabalho vinculado à CPU não é caro o suficiente em comparação com os custos gerais das trocas de contexto ao realizar o multithreading. Cada opção tem vantagens e desvantagens e você deve escolher o que é correto para a sua situação.

Mais exemplos

Os exemplos a seguir demonstram várias maneiras para escrever código assíncrono no C#. Elas abordam alguns cenários diferentes que você pode encontrar.

Extrair dados de uma rede

Esse trecho baixa o HTML da URL fornecida e conta o número de vezes que a cadeia de caracteres ".NET" ocorre no HTML. Ele usa o ASP.NET para definir um método do controlador da API Web que realiza essa tarefa, retornando o número.

Observação

Se você pretende fazer análise de HTML no código de produção, não use expressões regulares. Use uma biblioteca de análise.

[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;
}

Aqui está o mesmo cenário escrito para um aplicativo universal do Windows, que executa a mesma tarefa quando um botão for pressionado:

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;
}

Aguardar a conclusão de várias tarefas

Você pode encontrar em uma situação em que precisa recuperar várias partes de dados simultaneamente. A API Task contém dois métodos, Task.WhenAll e Task.WhenAny, que permitem escrever um código assíncrono que realiza uma espera sem bloqueio em vários trabalhos em segundo plano.

Este exemplo mostra como você pode obter os dados User para um conjunto de userIds.

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);
}

Aqui está outro jeito de escrever isso de forma mais sucinta usando LINQ:

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

Embora seja menos código, tome cuidado ao misturar LINQ com código assíncrono. Como o LINQ utiliza a execução adiada (lenta), as chamadas assíncronas não acontecerão imediatamente como em um loop foreach, a menos que você force a sequência gerada a iterar com uma chamada a .ToList() ou .ToArray(). O exemplo acima usa Enumerable.ToArray para executar a consulta ansiosamente e armazenar os resultados em uma matriz. Isso força o código id => GetUserAsync(id) a executar e iniciar a tarefa.

Conselhos e informações importantes

Com a programação assíncrona, há alguns detalhes para ter em mente que podem impedir um comportamento inesperado.

  • asyncOs métodos precisam ter uma palavra-chaveawait no corpo ou eles nunca transferirão!

    É importante ter isso em mente. Se await não for usado no corpo de um método async, o compilador do C# gerará um aviso, mas o código será compilado e executado como se fosse um método normal. Isso também seria extremamente ineficiente, pois a máquina de estado gerada pelo compilador do C# para o método assíncrono não realizaria nada.

  • Você deve adicionar "Async" como o sufixo de cada nome de método assíncrono que escrever.

    Essa é a convenção usada no .NET para diferenciar mais facilmente os métodos síncronos e assíncronos. Isso não se aplica, necessariamente, a alguns métodos que não são explicitamente chamados pelo seu código (como manipuladores de eventos ou métodos do controlador da Web). Como eles não são chamados explicitamente pelo seu código, não é tão importante ser explícito em relação à nomenclatura.

  • async void O só deve ser usado para manipuladores de eventos.

    O async void é a única maneira de permitir que os manipuladores de eventos assíncronos trabalhem, pois os eventos não têm tipos de retorno (portanto, não podem fazer uso de Task e Task<T>). Qualquer outro uso de async void não segue o modelo TAP e pode ser um desafio utilizá-lo, como:

    • As exceções lançadas em um método async void não podem ser capturadas fora desse método.
    • Métodos async void são difíceis de testar.
    • Métodos async void poderão causar efeitos colaterais indesejados se o chamador não estiver esperando que eles sejam assíncronos.
  • Vá com cuidado ao usar lambdas assíncronas em expressões LINQ

    As expressões lambda em LINQ usam a execução adiada, o que significa que o código poderia acabar executando em um momento que você não está esperando. A introdução de tarefas de bloqueio no meio disso poderia facilmente resultar em um deadlock, se não estivessem escritas corretamente. Além disso, o aninhamento de código assíncrono dessa maneira também pode dificultar a ponderação a respeito da execução do código. A assíncrona e a LINQ são poderosas, mas devem ser usadas de uma maneira mais cuidadosa e clara possível.

  • Escrever código que aguarda tarefas de uma maneira sem bloqueio

    Bloquear o thread atual como um meio de aguardar a conclusão de uma Task pode resultar em deadlocks e threads de contexto bloqueados e pode exigir tratamento de erros significativamente mais complexo. A tabela a seguir fornece diretrizes de como lidar com a espera de tarefas de uma forma sem bloqueio:

    Use isto... Em vez disto... Quando desejar fazer isso...
    await Task.Wait ou Task.Result Recuperação do resultado de uma tarefa em segundo plano
    await Task.WhenAny Task.WaitAny Aguardar a conclusão de qualquer tarefa
    await Task.WhenAll Task.WaitAll Aguardar a conclusão de todas as tarefas
    await Task.Delay Thread.Sleep Aguardar por um período de tempo
  • Considere usarValueTasksempre que possível

    Retornar um objeto Task de métodos assíncronos pode introduzir gargalos de desempenho em determinados caminhos. Task é um tipo de referência, portanto, usá-lo significa alocar um objeto. Em casos em que um método declarado com o modificador async retorna um resultado armazenado em cache ou é concluído de forma síncrona, as alocações extras podem se tornar um custo de tempo significativo em seções críticas de desempenho de código. Isso pode se tornar caro se essas alocações ocorrem em loops rígidos. Para obter mais informações, consulte Tipos de retorno assíncronos generalizados.

  • Considere usarConfigureAwait(false)

    Uma pergunta comum é: "quando devo usar o método Task.ConfigureAwait(Boolean)?". O método permite que uma instância Task configure seu awaiter. Essa é uma consideração importante, e defini-la incorretamente pode potencialmente ter implicações de desempenho e até deadlocks. Para obter mais informações sobre ConfigureAwait, consulte FAQ do ConfigureAwait.

  • Escrever código com menos monitoração de estado

    Não depender do estado de objetos globais ou da execução de determinados métodos. Em vez disso, depender apenas dos valores retornados dos métodos. Por quê?

    • Será mais fácil raciocinar sobre o código.
    • O código será mais fácil de testar.
    • Misturar código assíncrono e síncrono será muito mais simples.
    • As condições de corrida poderão, normalmente, ser completamente evitadas.
    • Dependendo dos valores retornados, a coordenação de código assíncrono se tornará simples.
    • (Bônus) funciona muito bem com a injeção de dependência.

Uma meta recomendada é alcançar a Transparência referencial completa ou quase completa em seu código. Isso resultará em uma base de código previsível, testável e de fácil manutenção.

Exemplo completo

O código a seguir é o texto completo do arquivo Program.cs para o exemplo.

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/maui"
    };

    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/maui: 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.

Outros recursos