DEV Community

Angelo Belchior
Angelo Belchior

Posted on • Edited on

Async/Await: Task.ConfigureAwait, Deadlock e Pink Floyd

No post anterior eu falei um pouco sobre como o compilador do csharp resolve métodos assíncronos por debaixo do capô. Eu não quis entrar no conceito em si dessa feature, quis apenas focar em como as coisas automágicamente eram resolvidas.


Antes de continuar, #VemCodar com a gente!!

Tá afim de criar APIs robustas com .NET?

Feito de dev para dev, direto das trincheiras, por quem coloca software em produção há mais de 20 anos, o curso "APIs robustas com ASP.NET Core 8, Entity Framework, Docker e Redis" traz de maneira direta, sem enrolação, tudo que você precisa saber para construir suas APIs de tal forma que elas sejam seguras, performáticas, escaláveis e, acima de tudo, dentro de um ambiente de desenvolvimento totalmente configurado utilizando Docker e Docker Compose.

Não fique de fora! Dê um Up na sua carreira!

O treinamento acontecerá nos dias 27/02, 28/02 e 29/02, online, das 19.30hs às 22.30hs

Acesse: https://vemcodar.com.br/


Porém chegou a hora de falar um pouco sobre algumas características de programação assíncrona e paralelismo em geral.

Quando falamos sobre métodos assíncronos, várias dúvidas surgem. Neste post eu abordo uma das mais interessantes: A função do método ConfigureAwait(true/false) da classe Task.

Provavelmente você já deve ter visto algo como:



var post = await blog.ObterPostPorIdAsync(1).ConfigureAwait(false);


Enter fullscreen mode Exit fullscreen mode

Mas por algum momento você parou para pensar qual é a real necessidade disso? Quais são os impactos de se passar um true ou um false como argumento, ou ainda, o que acontece se eu omitir a chamada desse método?

Pra começar precisamos falar sobre SynchronizationContext!

O que é o SynchronizationContext?

Em um processo assíncrono, o SynchronizationContext - contexto de sincronização - refere-se ao ambiente no qual uma determinada tarefa (Task / Thread) é executada. Ele inclui informações sobre a thread atual como por exemplo a cultura atual e a cultura da interface de usuário (caso seja um aplicativo desktop ou mobile), além de outros estados relacionados à execução da própria tarefa.

Esse contexto é especialmente importante em cenários em que várias tasks concorrem para acessar recursos compartilhados ou quando a interface de usuário precisa renderizar determinados resultados de tarefas, como por exemplo um processamento de um arquivo que a cada linha processada atualiza a porcentagem de uma barra de progresso na UI.

Nesse caso dado como exemplo a thread que gerencia a interface com o usuário (UI Thread) precisa estar disponível para gerenciar suas ações. Um processo assíncrono não deveria travar a tela. Aliás, se você utiliza o Windows ja deve ter reparado que algumas vezes a janela do sistema fica toda branca (congelada) ou fazendo aquele efeito psicodélico digno de uma capa de um disco do Pink Floyd.

Isso geralmente ocorre porque a Thread de UI, responsável por renderizar a tela, está travada, provavelmente aguardando a finalização de algum processo que pode ou não ser assíncrono.

Tela do Windows Travada

Acima a capa do disco raro do Pink Floyd chamado The frozen side of the Windows, lançado em 1995.

É importante entender em qual contexto de sincronização estamos executando uma Task porque influencia como uma tarefa assíncrona interage com seu ambiente.

Quando uma tarefa assíncrona é criada, por padrão, ela captura o contexto de sincronização atual, no exemplo anterior o contexto era a UI. Isso significa que, ao ser concluída, a tarefa tentará retornar ao contexto original no qual foi iniciada.

E como esse retorno ao contexto original é gerenciado no ConfigureAwait?

Indo um pouco mais afundo: TaskScheduler

O TaskScheduler não é algo exclusivo da feature async/await. Ele faz parte do modelo de programação baseado em tarefas (TPL) fornecido pelo namespace System.Threading.Tasks e ajuda no gerenciamento de alocação de threads e na escalabilidade de operações assíncronas, o que é importante para evitar os deadlocks (explico a seguir o que é isso) em aplicativos que dependem de I/O como leitura ou gravação de arquivos, solicitações de rede, operações de banco de dados, etc.

Um dos principais usos do TaskScheduler está relacionado ao método ConfigureAwait. Como disse acima, só que agora em outras palavras, esse método é usado para especificar em qual contexto de sincronização a continuação de uma tarefa assíncrona deve ser executada após a tarefa principal ser concluída.

O TaskScheduler é usado para agendar essa continuação!

Por exemplo, ao passar true para o ConfigureAwait, o que é o default (você poderia omitir a chamada ao método nesse caso), você está instruindo sua tarefa assíncrona a retornar ao contexto de sincronização original.
Se passar false, a continuação não terá a garantia de retornar ao contexto original, o que é útil quando você deseja evitar bloqueios (deadlocks), especialmente em operações de I/O demoradas.

Evitando Deadlocks entre Threads

Saber como o ConfigureAwait funciona pode (e vai) evitar problemas de deadlocks entre threads.

Um deadlock (também conhecido como pandemônio) ocorre quando duas ou mais threads aguardam indefinidamente por recursos que estão sendo mantidos (travados) por outras threads, resultando em uma situação de bloqueio mútuo. Isso ocorre quando as threads bloqueiam recursos e esperam por recursos que estão bloqueados por outras threads, impedindo que qualquer uma delas prossiga.

Esse conceito é muito comum em banco de dados. No MSSQL por exemplo, é muito recomendado usar o (NOLOCK) ao fazer consultas: Select Id, Nome From Pessoas (NOLOCK). Mais informações, acesse essa documentação.

Abaixo segue um programa onde causamos propositalmente um deadlock:



public static class Program
{
    public static async Task Main()
    {
        var recursoA = new Recurso();
        var recursoB = new Recurso();

        var task1 = Task.Run(() =>
        {
            Console.WriteLine("Task 1: Dando lock no recurso A para que seja possível manipulá-lo");
            lock (recursoA)
            {
                recursoA.SomaUm();
                Console.WriteLine("Task 1: Lock no recurso A efetuado. Agora tentando dar lock no recurso B");
                lock (recursoB)
                {
                    Console.WriteLine("Task 2: Recursos A e B estão locados, nunca vai chegar aqui");
                    recursoB.SubtraiUm();
                }
            }
        });

        var task2 = Task.Run(() =>
        {
            Console.WriteLine("Task 2: Dando lock no recurso B para que seja possível manipulá-lo");
            lock (recursoB)
            {
                recursoB.SomaUm();
                Console.WriteLine("Task 2: Lock no recurso B efetuado. Agora tentando dar lock no recurso A");
                lock (recursoA)
                {
                    Console.WriteLine("Task 2: Recursos B e A estão locados, nunca vai chegar aqui");
                    recursoA.SubtraiUm();
                }
            }
        });

        await task1;
        await task2;

        // Daqui pra baixo nada acontecerá, pois as tasks nunca vão terminar

        Console.WriteLine("Se tudo der certo e nada der errado, essa mensagem nunca será exibida");
    }
}

public class Recurso
{
    private int _total = 0;
    public int Total => _total;
    public void SomaUm()
    {
        _total++;
        Thread.Sleep(120); // simulação de um processo externo demorado
    }

    public void SubtraiUm()
    {
        _total--;
        Thread.Sleep(120); // simulação de um processo externo demorado
    }
}


Enter fullscreen mode Exit fullscreen mode

A saída de execução desse programa é:



Task 1: Dando lock no recurso A para que seja possível manipulá-lo
Task 2: Dando lock no recurso B para que seja possível manipulá-lo
Task 1: Lock no recurso A efetuado. Agora tentando dar lock no recurso B
Task 2: Lock no recurso B efetuado. Agora tentando dar lock no recurso A


Enter fullscreen mode Exit fullscreen mode

Você pode obter o código fonte desse exemplo clicando aqui.

Explicando!

Duas variáveis são criadas, recursoA e recursoB. Essas variáveis serão usadas para criar locks e simular a contenção de recursos compartilhados.

Em seguida são criadas duas Tasks, task1 e task2, onde é executado um método anônimo onde cada uma dessas funções tenta adquirir locks nos recursos compartilhados, mas em ordens diferentes.
Isso cria uma situação em que a task1 bloqueia recursoA e aguarda recursoB, enquanto a task2 bloqueia recursoB e aguarda recursoA.

Que bagunça!

Como resultado, ambas as Tasks ficam presas, aguardando que a outra libere o recurso que elas precisam para continuar, causando um deadlock.

O resultado da execução desse código será que as mensagens de bloqueio e espera serão impressas, mas o programa nunca chegará à mensagem "Se tudo der certo e nada der errado, essa mensagem nunca será exibida" porque as duas tasks estarão presas em um deadlock.

Esse tipo de situação pode resultar em um programa que fica congelado e não pode continuar a execução. Lembram do que citei acima sobre telas em branco ou efeitos psicodélicos? Então...

É claro que esse exemplo é um caso extremo, e nem sequer adiantaria utilizar o ConfigureAwait(false) já que o contexto de sincronização está sendo executado pelo lock (mais a frente eu explico melhor o que seria isso);

É muito complexo criar uma simulação real desse tipo de cenário, por isso, para fins didáticos, optei por usar esse exemplo. Mas acredite, no mundo real é diferente! Quando temos uma aplicação grande, acessada por milhares e milhares de pessoas, caso não seja tomado os devidos cuidados, podemos cair num cenário de deadlock. Lembrem-se de que as ruas não são como a Disneylândia! Produção é mundo real!

É por isso que precisamos ter algo que "orquestre" a interação entre as threads para que esse tipo de coisa não ocorra.

Deadlock é algo tão ruim, mas tão ruim, que consegue sair do mundo virtual e ferrar nossas vidas no mundo real. Não acredita?

Saca só:

Imagem retirada do site https://exame.com/brasil/falha-em-semaforo-causa-engarrafamento-homerico-em-sp/

Uma falha no semáforo entre as avenidas Faria Lima e Juscelino Kubitschek travou a movimentação de carros. CET levou 20 minutos para resolver problema - Revista Exame

Na imagem temos um cruzamento onde todas as saídas estão bloqueadas por carros.

E tem empresa querendo voltar ao trabalho presencial... enfim... segue o velório...

Imagine que cada carro da imagem acima é uma thread.

Imaginou? Então... acho que agora ficou claro, certo?

Para evitar deadlocks, é importante coordenar o acesso a recursos compartilhados usando mecanismos de sincronização (SynchronizationContext). Na foto acima esse mecanismo seria um semáforo (farol pra quem é de São Paulo) ou um guarda da CET. Isso garantiria que as threads liberariam recursos quando terminarem de usá-los.

Ainda temos a possibilidade de usarmos locks, semáforos ou mutex, mas não quero expandir mais esse post, já que cada um desses itens de sincronização mereceria uma atenção especial. Dediquem tempo para lerem a documentação de cada um deles, lá tem tudo que vocês precisam para entender como as coisas funcionam.

Destaco aqui que, mesmo usando as técnicas que eu citei acima, não é garantido que seu código vai rodar sem nenhum deadlock. A prova disso foi o exemplo de código dado anteriormente.

O mais importante aqui é entendermos que é possível informar a Task que não é necessário se preocupar em voltar ao contexto inicial - ConfigureAwait(false) - e assim evitarmos de cair em um deadlock maléfico.

Cenários interessantes e outras abordagens

Aplicações Desktop e Mobile

Como disse acima, se o contexto capturado for bloqueado por outra operação que aguarda a conclusão de uma tarefa, ocorrerá um deadlock.

Aplicações com interface visual (UI) são os exemplos mais interessantes para explorarmos já que se a thread da UI esperar de forma síncrona a conclusão de uma tarefa ou ocorrer um deadlock, ela não vai conseguir renderizar os controles de tela, por exemplo.

Porém, quando falamos em aplicações desse tipo, temos implementações específicas que cuidam dessa gestão de sincronismo para nós. Em aplicativos com interface de usuário, é crucial garantir que as operações assíncronas não bloqueiem a Thread de UI. Uma aplicação fluída é aquela que é possível utilizar sem que haja nenhum engasgo no processamento ou na renderização dos componentes visuais.

Em Windows Forms, WPF, UWP, WinUI, Xamarin Forms e MAUI temos o conceito do Dispatcher .

O Dispatcher é responsável por gerenciar a thread principal da interface de usuário (Thread de UI). Ele fornece um mecanismo para executar operações nessa thread de forma segura e garantindo que não ocorra deadlocks.

Basicamente, as plataformas citadas acima implementam seu próprio modelo de Dispatcher, porém todos tem a mesma característica: um método Invoke e um método BeginInvoke (pode ser que os nomes não sejam esses, mas a ideia é a mesma).

O método Invoke executa de forma síncrona o código na thread de UI e aguarda seu término.
Já o método BeginInvoke coloca o código na fila para ser executado na thread de UI, de forma assíncrona (lembram do TaskScheduler? Ele é quem vai garantir que o processo retorne ao contexto correto assim que for tirado da fila e executado).

No final, cada plataforma vai ter a sua própria implementação do SynchronizationContext, mas ideia por debaixo do capô é sempre a mesma: Evitar que ocorra deadlocks.

ASP.NET Core (Web e API)

Curiosamente nesse cenário não existe uma implementação do SynchronizationContext!
Stephen Cleary vai mais a fundo nesse assunto neste post!

Ele diz que um dos principais motivos para não existir essa abordagem é a performance:

Quando um mecanismo assíncrono retoma a execução no ASP.NET legado (WebForms, etc.), a continuação é enfileirada no contexto da solicitação. A continuação deve aguardar por quaisquer outras continuações que já tenham sido enfileiradas (apenas uma pode ser executada de cada vez). Quando estiver pronto para ser executado, uma thread é retirada do pool de threads, entra no contexto da solicitação e, em seguida, retoma a execução do manipulador. Esse "reingresso" no contexto da solicitação envolve várias tarefas de manutenção, como definir HttpContext.Current e a identidade e cultura da thread atual.

Com o sincronismo de contexto, se faz necessário redefinir as informações do HttpContext.Current, e ai temos um ponto crítico: Isso leva um bom tempo.

Essas redefinições são necessárias porque o contexto pode ter sido alterado em algum momento. Por isso existe essa gestão de sincronização de tasks. Não existe almoço grátis!

Com o avanço dessa plataforma, o time do ASP.NET Core focou em performance. Nem preciso dizer o quão absurdamente performáticas as últimas versões estão (no momento em que escrevo estamos a poucas semanas do lançamento final do .Net 8).

Você mesmo pode avaliar todo o resultado dessa obsessão por performance lendo os posts que o time disponibiliza. Eu separei o mais atual: Performance Improvements in ASP.NET Core 8 - .NET Blog (microsoft.com).

Certo, se não existe SyncronizationContext, como que os métodos assíncronos funcionam?

Simples: Códigos que estão sendo executados no "ambiente" ASP.NET Core não precisam mais configurar qual contexto uma Task deve retornar após sua finalização. Isso é feito internamente. Logo, para nós não é exposta a possibilidade de alterar essa configuração. O time de desenvolvimento resolveu isso sincronizando automágicamente por debaixo do capô.

Isso é algo que eu ainda preciso entender mais a fundo, porém só lendo o código fonte do ASP.NET Core mesmo para ter mais noção. Quem sabe eu crie um post falando sobre, e se você sabe como funciona, deixe nos comentários :)

Porém, isso não quer dizer que você, pessoa desenvolvedora backend não deva se preocupar com isso. Muito pelo contrário! Em um "ambiente" ASP.NET Core isso está resolvido, mas quando criamos/utilizamos bibliotecas (de terceiros ou não), precisamos ter total noção de como as coisas funcionam para saber se devemos ou não utilizar o ConfigureAwait(false).

Nesses cenários ainda corremos sérios riscos de deadlock.

ASP.NET Core Blazor (Server e WebAssembly)

Saca só que interessante: O Blazor utiliza seu próprio contexto de sincronização para garantir uma única thread lógica de execução. Os métodos do ciclo de vida de um componente e os callbacks de eventos gerados pelo Blazor são executados nesse contexto.

O contexto de sincronização no lado do servidor tenta emular um ambiente single-thread, assim como é feito no lado do navegador (WebAssembly), que trabalha com uma thread única. Qualquer processo é forçado a ser realizado nessa única thread. Não existe processamentos simultâneos e por isso devemos evitar usar métodos/propriedades bloqueantes como o .Result, .Wait() ou o GetResult() da classe Task.

Mais informações (que aliás são bem escassas sobre esse assunto no Blazor) aqui: Contexto de sincronização do Blazor ASP.NET Core | Microsoft Learn


Eu gostaria que com esse post, você meu caro leitor, minha cara leitora, entenda que conhecer como o ConfigureAwait funciona é crucial para otimizar o desempenho e evitar bloqueios desnecessários em aplicativos assíncronos

Essa é a mensagem que eu gostaria que ficasse na sua cabeça! Espero que tenham entendido a importância de conhecer bem como funciona o ConfigureAwait, porém o melhor post do universo que trata sobre esse assunto é esse:
ConfigureAwait FAQ - .NET Blog (microsoft.com).

Leitura mais do que obrigatória!

Era isso. Espero que tenham gostado e até a próxima!

Top comments (2)

Collapse
 
cristian_lopes profile image
Cristian Lopes

Aposto que o DeadLock da imagem era dia de jogo no Itaquera
Corinhtians X Protuguesa.

Collapse
 
angelobelchior profile image
Angelo Belchior

Eu acho que foi o dia do jogo que o Grêmio foi rebaixado ;p Agora qual dos rebaixamentos eu não sei ;p