C# -  Programação Assíncrona - II


  Vamos recordar conceitos importantes da programação assíncrona na linguagem C#

Continuando o artigo anterior vamos agora por em prática a teoria da programação assíncrona.

Programação Síncrona

Antes de entramos na programação assíncrona, vamos entender o que é programação síncrona usando o seguinte exemplo de código em uma aplicação Console - AppSincrona -  no .NET 7.0 usando as instruções de alto nível :

Console.WriteLine("\n### Programação Síncrona ###");
Console.WriteLine("\nExecutando processo de longa duração...");
ProcessoLongaDuracao();
Console.WriteLine("\nExecutando processo de curta duração...");
ProcessoCurtaDuracao();
Console.WriteLine("\ntarefa encerrada.");
Console.ReadKey();

static void ProcessoLongaDuracao()
{
    Console.WriteLine("-Processo de longa duração iniciado...");
    System.Threading.Thread.Sleep(5000);
    Console.WriteLine("-Processo de longa duração concluído.");
}
static void ProcessoCurtaDuracao()
{
    Console.WriteLine("-Processo de curta duração iniciado...");
    System.Threading.Thread.Sleep(800);
    Console.WriteLine("-Processo de curta duração concluído.");
}

A execução deste código vai gerar o seguinte resultado:



No exemplo acima, o método
ProcessoLongaDuracao() é uma tarefa de execução longa, como ler um arquivo do servidor, chamar uma API da web que retorna uma grande quantidade de dados ou carregar ou baixar um arquivo grande.

Ela vai demorar um pouco mais para executar e para simular isso estamos retarando a execução em 5 segundos usando o código Thread.Sleep(5000) apenas para mostrar o longo tempo de execução.

O método ProcessoCurtaDuracao() é um método simples que é executado após o método ProcessoLongaDuracao() e que tem um retardo ou delay de apenas 0.8 segundos.

O programa acima é executado de forma síncrona. Isso significa que a execução começa a partir do método  método
ProcessoLongaDuracao() e depois o método ProcessoCurtaDuracao(). Durante a execução, um aplicativo é bloqueado e deixa de responder (você pode ver isso principalmente em aplicativos baseados no Windows). Isso é chamado de programação síncrona, onde a execução não vai para a próxima linha até que a linha atual seja executada completamente.

Programação Assíncrona

Agora vamos criar um novo projeto Console chamado AppAssincrona no VS 2022 usando o .NET 7.0.

Após criar o projeto vamos converter o modo Top Level Statament para o estilo Program.Main clicando com o botão direito do mouse sobre o código e selecionando esta opção :

Ao final teremos a seguinte exibição na IDE:

Vamos agora alterar este código incluindo o código abaixo na classe Program:

using System.Diagnostics;
internal class Program
{
    static readonly Stopwatch timer = new Stopwatch();
    public static void Main(string[] args)
    {
        timer.Start();
        Console.WriteLine("\nProgram Inicio - " + timer.Elapsed.ToString() + "\n");
        Task1();
        Task2();
        Console.WriteLine("\nProgram Fim - " + timer.Elapsed.ToString());
        timer.Stop();
        Console.ReadKey();
    }
    private static async Task Task1Async()
    {
        Console.WriteLine("Async Task 1 Iniciada - " + timer.Elapsed.TotalSeconds.ToString());
        await Task.Delay(2000);
        Console.WriteLine("Async Task 1 Completada - " + timer.Elapsed.TotalSeconds.ToString());
    }
    private static async Task Task2Async()
    {
        Console.WriteLine("Async Task 2 Iniciada - " + timer.Elapsed.TotalSeconds.ToString());
        await Task.Delay(3000);
        Console.WriteLine("Async Task 2 Completada - " + timer.Elapsed.TotalSeconds.ToString());
    }
    private static void Task1()
    {
        Console.WriteLine("Task 1 Iniciada - " + timer.Elapsed.TotalSeconds.ToString());
        Thread.Sleep(2000);
        Console.WriteLine("Task 1 Completada - " + timer.Elapsed.TotalSeconds.ToString());
    }
    private static void Task2()
    {
        Console.WriteLine("Task 2 Iniciada - " + timer.Elapsed.TotalSeconds.ToString());
        Thread.Sleep(3000);
        Console.WriteLine("Task 2 Completada - " + timer.Elapsed.TotalSeconds.ToString());
    }
}

No código acima, adicionamos os métodos Task1 e Task2 e, para ambos os métodos, adicionamos versões sincronizadas e assíncronas dos métodos. Esses métodos de tarefa são métodos de tarefa fictícios que simulam uma tarefa adicionando atraso à execução. Em aplicações reais, estes serão códigos para operações de arquivo ou operação de banco de dados ou tarefas de computação.

Nestes métodos de tarefa (Task1 & Task2) também estamos imprimindo a hora de início do método e também a hora de conclusão do método para que possamos ter uma boa ideia de como está acontecendo a execução desses métodos.

No método principal, chamaremos esses métodos de tarefa (Task1 e Task2) um após o outro. Primeiro, chamaremos a versão de sincronização dos métodos de tarefa e verificaremos a saída

Também no método main, adicionamos código para imprimir a hora de início do programa e a hora de término para que saibamos quando esses métodos foram chamados após o início do aplicativo.

Para registrar o tempo no método Main do programa e também nos métodos Task, usamos a classe stopwatch para iniciar o cronômetro e, em seguida, imprimimos o tempo decorrido do cronômetro em cada evento.

Fizemos uso das seguintes palavras-chave para tornar os métodos assíncronos :

async – Quando adicionamos esta palavra-chave na definição do método(antes do nome do método), ela nos permite chamar este método de forma assíncrona, ou seja, a palavra-chave async é usada para qualificar uma função como uma função assíncrona

await – Quando queremos chamar um método assíncrona de forma assíncrona, usamos esta palavra-chave await. Essa palavra-chave é adicionada abaixo da chamada do método.

Vamos primeiro verificar a saída do código acima com a versão de sincronização dos métodos Task. Depois de executar o código acima, podemos ver a saída abaixo na janela do console.

Os resultados acima são os esperados, ou seja, Task1 começou assim que o programa foi iniciado e levou cerca de 2 segundos para ser concluído, pois tem um atraso de 2 segundos. Imediatamente após a conclusão da Task1, a Task2 começou a ser executada e levou cerca de 3 segundos para ser concluída, pois tem um atraso de 3 segundos. Portanto, a execução total do programa levou aproximadamente 5 segundos para ser concluída.

Modificar o método Main para aguardar em vez de bloquear

Vimos a saída do código acima com a versão de sincronização dos métodos Task, que é uma prática ruim de bloqueio, pois esse código impede que a thread que o executa faça qualquer outro trabalho.

Agora vamos modificar o método Main no Program.cs para chamar a versão assíncrona dos métodos Task (Task1Async & Task2Async) para que as threads não sejam bloqueados enquanto as tarefas estão em execução. Faremos uso da palavra-chave await com uma versão assíncrona dos métodos de tarefa que fornece uma maneira sem bloqueio de iniciar uma tarefa.

Modifique o código no método Main no arquivo Program.cs conforme mostrado abaixo.

    public async static Task Main(string[] args)
    {
        timer.Start();
        Console.WriteLine("Program Início - " + timer.Elapsed.ToString());
        await Task1Async();
        await Task2Async();
        Console.WriteLine("Program Fim - " + timer.Elapsed.ToString());
        timer.Stop();
    }

Modificamos o método Main para usar uma versão assíncrona dos métodos de tarefa (Task1Async e Task2Async). Para chamar esses métodos de forma assíncrona, estamos usando palavras-chave await antes de chamar os métodos conforme mostrado no código acima.

Nota: A partir do C# 7.1, você pode usar o modificador async no método Main, desde que o método retorne uma tarefa (Task) ou uma tarefa genérica (Task<T>).

Depois de executar o código acima, podemos ver a saída abaixo no console:

Os resultados acima são semelhantes à versão de sincronização, ou seja, a execução total do programa levou aproximadamente 5 segundos para ser concluída. ou seja, ambos os métodos Task1Async e Task2Async foram executados sequencialmente. Isso ocorre porque o código ainda precisa aproveitar a programação assíncrona no .NET.

O código acima não bloqueia durante a execução da Async Task1, mas não iniciará a Async Task2 até que a Async Task1 seja concluída. Se você não conseguir chamar Async Task1 e Async Task2 em paralelo devido à dependência, essa é a única alteração necessária para executar o aplicativo no modo sem bloqueio. Um aplicativo com uma interface gráfica ou GUI não entrará em um estado de não resposta apenas por essa alteração.

No entanto, neste programa, podemos executar Async Task1 e Async Task2 em paralelo, pois não há dependência entre eles, ou seja, você não deseja que cada método de Async Task seja executado sequencialmente. Então, vamos fazer a alteração do código para iniciar a Asycn Task 2 antes de aguardar a conclusão de Async Task 1.

Iniciando Async Task1 e Async Task2 simultaneamente

Em aplicativos reais, se você tiver tarefas que podem ser executadas independentemente, você deseja iniciar essas tarefas imediatamente para que possam ser executadas em paralelo. A execução de operações em paralelo fará tudo em menos tempo em comparação com a execução sequencial.

Para poder executar as operações em segundo plano temos que usar a classe Task.

A classe Task é uma das principais classes utilizadas na programação assíncrona do C#. Ela permite que uma tarefa seja executada em segundo plano, sem bloquear a thread principal do programa. Isso é especialmente útil em aplicações que precisam realizar operações longas ou bloqueantes, como leitura de arquivos, acesso a banco de dados, ou chamadas de rede.

Ao utilizar a classe Task, o programador pode criar uma nova tarefa, que será executada em segundo plano

Assim vamos precisar usar a classe Task e manter o objeto Task (System.Threading.Tasks.Task) que representa a operação.

Você terá que aguardar usando a palavra-chave await cada objeto de Task (tarefa) antes de trabalhar para a conclusão do programa.

Vamos fazer essas alterações no método Main em Program.cs conforme mostrado abaixo

    public  static async Task Main(string[] args)
    {
        timer.Start();
        Console.WriteLine("Program Inicio - " + timer.Elapsed.ToString());
        Task task1Task = Task1Async();
        Task task2Task = Task2Async();
        await task1Task;
        await task2Task;
        Console.WriteLine("Program Fim - " + timer.Elapsed.ToString());
        timer.Stop();
        Console.ReadKey();
    }

Esse código em C# cria duas tarefas assíncronas que serão executadas em segundo plano: task1Task e task2Task, através das chamadas dos métodos Task1Async() e Task2Async(), respectivamente.

Armazenamos as tarefas (task1Task e task2Task) para as operações (Task1Async e Task2Async) quando elas iniciam.

Em seguida, o código aguarda a conclusão da primeira tarefa, task1Task, usando a palavra-chave await, o que significa que o restante do código aguardará até que essa tarefa termine de executar.

Depois que a primeira tarefa é concluída, o código aguarda a conclusão da segunda tarefa, task2Task, também usando a palavra-chave await.

Depois de executar o código acima, podemos ver a saída abaixo no console

Agora, vemos na saída acima que temos o benefício de desempenho da programação assíncrona, ou seja, nosso programa total foi concluído em aproximadamente 3 segundos, em vez dos 5 segundos que foram obtidos anteriormente.

Podemos ver que async task1 e async task2 iniciaram quase ao mesmo tempo quando o programa foi iniciado. Tanto a Task1Async quanto a Task2Async foram executadas em paralelo, portanto houve uma melhora no tempo de execução do código.

Aguardando a tarefa com eficiência

Vamos agora modificar o método Main para aguardar a tarefa com eficiência fazendo a seguinte alteração:

No código acima, estamos aguardando várias tarefas, portanto, no código, há mais de 1 instrução usando a palavra-chave await para aguardar a execução do método.

Agora, se tivermos muitas tarefas, haverá uma série de instruções de com await. Este código pode ser melhorado fazendo uso de métodos disponíveis na classe Task.

 public  static async Task Main(string[] args)
    {
        timer.Start();
        Console.WriteLine("Program Início - " + timer.Elapsed.ToString() + "\n");
        Task task1Task = Task1Async();
        Task task2Task = Task2Async();
        await Task.WhenAll(task1Task, task2Task);
        Console.WriteLine("\nProgram Fim - " + timer.Elapsed.ToString());
        timer.Stop();
        Console.ReadKey();
    }

No código acima, modificamos o código para usar o método WhenAll da classe System.Threading.Tasks.Task que retorna uma Task que é concluída quando todas as tarefas em sua lista de argumentos foram concluídas.

O método Task.WhenAll é um método estático da classe Task que permite que várias tarefas assíncronas sejam executadas simultaneamente e que a conclusão de todas elas seja aguardada antes de continuar a execução do código.

Esse método recebe uma coleção de objetos Task (ou qualquer subclasse de Task, como Task<T>) como parâmetro e retorna uma nova tarefa que representa a conclusão de todas as tarefas da coleção. Ou seja, o método Task.WhenAll aguarda a conclusão de todas as tarefas passadas como parâmetro antes de concluir a tarefa retornada.

Outra opção disponível é usar o método WhenAny de System.Threading.Tasks.Task, que retorna uma Task<Task> que é concluída quando qualquer um de seus argumentos é concluído. Você pode usar este WhenAny quando tiver um cenário como o de querer esperar apenas até que qualquer uma das tarefas seja concluída.

Depois de executar o código acima, podemos ver a saída abaixo no console:



Os resultados acima são semelhantes à versão assíncrona, ou seja, a execução total do programa levou aproximadamente 3 segundos para ser concluída. Não há alteração na execução do código, mas simplificamos o código usando os métodos disponíveis na classe Task.

Até agora, abordamos como usar técnicas de programação assíncrona para melhorar o desempenho do código C#. A seguir, vamos ver como lidar com exceções lançadas por métodos assíncronos.

Manipulando exceções de métodos assíncronos

Os métodos assíncronos também lançam exceções da mesma forma que os métodos síncronas. Uma Task lança exceções quando não pode ser concluída com êxito devido a alguma exceção.

Para manipulação de exceções, você precisa escrever o código da mesma forma que se lê como uma série de instruções síncronas. O código de chamada pode manipular/capturar essas exceções quando a tarefa é aguardada usando await.

Para demonstração, vamos supor que Task1Async lance alguma exceção de forma que não seja capaz de concluir com sucesso. Podemos simular essa exceção modificando o código no método Task1Async para gerar algumas exceções, conforme mostrado abaixo :

    private static async Task Task1Async()
    {
        Console.WriteLine("Async Task 1 Iniciada - " + timer.Elapsed.TotalSeconds.ToString());
        await Task.Delay(2000);
        throw new Exception("Async Task 1 Falhou na execução");
        //Console.WriteLine("Async Task 1 Completada - " + timer.Elapsed.TotalSeconds.ToString());
    }

Em seguida, precisamos tratar/capturar essa exceção no método Main onde estamos aguardando a chamada para esse método assíncrono Task1Async. Para isso altere o método Main em Program.cs conforme mostrado abaixo:

public  static async Task Main(string[] args)
    {
        timer.Start();
        Console.WriteLine("Program Início - " + timer.Elapsed.ToString());
        Task task1Task = Task1Async();
        Task task2Task = Task2Async();
        try
        {
            await Task.WhenAll(task1Task, task2Task);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message.ToString());
        }
        Console.WriteLine("Program Fim - " + timer.Elapsed.ToString());
        timer.Stop();
        Console.ReadKey();
    }

Esse código aguarda (await)  a conclusão de duas tarefas assíncronas (task1Task e task2Task) usando o método estático Task.WhenAll que recebe uma coleção de objetos Task (ou qualquer subclasse de Task) como parâmetro e retorna uma nova tarefa que representa a conclusão de todas as tarefas da coleção.

O uso da palavra-chave await antes da chamada do método Task.WhenAll indica que a execução do código deve ser suspensa até que todas as tarefas sejam concluídas. Quando todas as tarefas estiverem concluídas, a execução do código será retomada.

Se alguma das tarefas assíncronas gerar uma exceção durante sua execução, a execução do código será interrompida e a exceção será capturada pelo bloco catch. O bloco catch captura a exceção gerada e imprime a mensagem da exceção no console usando o método Console.WriteLine.

Depois de executar o código acima, podemos ver a saída abaixo na janela do console.

Podemos ver na saída acima que Task1Async lançou uma exceção que foi tratada pelo bloco catch do código de chamada e, consequentemente, a mensagem de exceção foi impressa no console. Podemos ver essa task whenAll esperando que todas as tarefas sejam concluídas e, em seguida, manipulamos a exceção do método Task1Async.

Com isso aprendemos o que é programação assíncrona e como usar as palavras-chave async e await no C#  para implementar técnicas de programação assíncrona. Além disso, vimos como a programação assíncrona nos ajuda a melhorar o desempenho do código chamando métodos em paralelo e, assim, reduzindo o tempo geral de execução.

Continuamos no próximo artigo.

E estamos conversados...

"Então Jesus disse: "Quando vocês levantarem o Filho do homem, saberão que Eu Sou, e que nada faço de mim mesmo, mas falo exatamente o que o Pai me ensinou."
João 8:28

Referências:


José Carlos Macoratti