DEV Community

Angelo Belchior
Angelo Belchior

Posted on • Edited on

Por debaixo do capô: async/await e as mágicas do compilador csharp

No post anterior eu falei um pouco sobre Açúcar Sintático e como o compilador do csharp trabalha para facilitar nossas vidas.


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/


Propositalmente eu deixei de fora a feature async/await, queria fazer algo mais elaborado para explicar como as coisas funcionam por debaixo do capô quando utilizamos métodos assíncronos.

O cenário que eu trago aqui é simples e muito comum: Uma classe BlogService que contém um método chamado ObterPostPorIdAsync no qual faz uma requisição assíncrona a uma API, utilizando o HttpClient.

Eu escolhi justamente esse cenário porque o método que faz a requisição (GetAsync) e o método que obtém o conteúdo de resposta como string (ReadAsStringAsync) são assíncronos.

Nesse ponto eu assumo que você conheça o mínimo sobre async/await, isso é fundamental! Caso contrário, recomendo fortemente estudar o assunto. Esse link da Microsfoft vai te ajudar: https://learn.microsoft.com/pt-br/dotnet/csharp/asynchronous-programming/

Abaixo segue nossa classe.

// Escrito por mim
public class BlogService
{
    public async Task<Post?> ObterPostPorIdAsync(int postId)
    {
        var endpoint = $"https://jsonplaceholder.typicode.com/posts/{postId}";

        using var httpClient = new HttpClient();
        using var response = await httpClient.GetAsync(endpoint);
        var json = await response.Content.ReadAsStringAsync();
        var post = JsonSerializer.Deserialize<Post>(json);
        return post;
    }
}
Enter fullscreen mode Exit fullscreen mode

Se você já tem um certo conhecimento sobre csharp, consumo de apis etc., não deve ter nenhuma dúvida sobre como esse código funciona.

Porém, em resumo, temos:

  • Um cliente Http é criado;
  • Fazemos uma requisição ao endpoint;
  • Obtemos a resposta dessa requisição e extraímos seu conteúdo como string (nesse caso, essa string vem no formato Json) ;
  • Desserializamos esse conteúdo como um objeto Post.

Reforço aqui que meu objetivo é ser didático, por isso não temos validações do response, políticas de retentativas e etc.

Se você leu atentamente o post anterior notou que o compilador do csharp ao gerar o código IL acaba por adicionar caracteres especiais em nome de métodos, classes e etc, algo como:

private struct <Main>d__0
{
    //...
}
Enter fullscreen mode Exit fullscreen mode

Claramente esse código não compila! Porém, ao obter o código gerado pelo compilador através do site https://sharplab.io/ resolvi ajustá-lo para que fosse possível sua execução.

Respira fundo! Vai com calma e não se preocupe! Eu vou explicar direitinho o que acontece por debaixo do capô:

// Gerado pelo compilador, ajustado por mim
public class BlogService
{
    private struct ObterPostPorIdAsyncStateMachine : IAsyncStateMachine
    {
        public int State;
        public AsyncTaskMethodBuilder<Post> Builder;
        public int PostId;

        private HttpClient _httpClient;
        private HttpResponseMessage _httpResponseMessage;
        private TaskAwaiter<HttpResponseMessage> _awaiterHttpResponseMessage;
        private TaskAwaiter<string> _awaiterContentString;

        private void MoveNext()
        {
            var num = State;
            Post result;
            try
            {
                var requestUri = default(string);
                if (num < 0)
                {
                    var defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(43, 1);
                    defaultInterpolatedStringHandler.AppendLiteral("https://jsonplaceholder.typicode.com/posts/");
                    defaultInterpolatedStringHandler.AppendFormatted(PostId);
                    requestUri = defaultInterpolatedStringHandler.ToStringAndClear();
                    _httpClient = new HttpClient();
                }
                try
                {
                    TaskAwaiter<HttpResponseMessage> awaiter;
                    if (num != 0)
                    {
                        if (num == 1)
                        {
                            goto IL_00b6;
                        }
                        awaiter = _httpClient.GetAsync(requestUri).GetAwaiter();
                        if (!awaiter.IsCompleted)
                        {
                            num = (State = 0);
                            _awaiterHttpResponseMessage = awaiter;
                            Builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                            return;
                        }
                    }
                    else
                    {
                        awaiter = _awaiterHttpResponseMessage;
                        _awaiterHttpResponseMessage = default(TaskAwaiter<HttpResponseMessage>);
                        num = (State = -1);
                    }
                    var httpResponseMessage = (_httpResponseMessage = awaiter.GetResult());
                    goto IL_00b6;
                    IL_00b6:
                    try
                    {
                        TaskAwaiter<string> awaiter2;
                        if (num != 1)
                        {
                            awaiter2 = _httpResponseMessage.Content.ReadAsStringAsync().GetAwaiter();
                            if (!awaiter2.IsCompleted)
                            {
                                num = (State = 1);
                                _awaiterContentString = awaiter2;
                                Builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                                return;
                            }
                        }
                        else
                        {
                            awaiter2 = _awaiterContentString;
                            _awaiterContentString = default(TaskAwaiter<string>);
                            num = (State = -1); //Reinicia a máquina de estados...
                        }
                        result = JsonSerializer.Deserialize<Post>(awaiter2.GetResult());
                    }
                    finally
                    {
                        if (num < 0 && _httpResponseMessage != null)
                        {
                            ((IDisposable)_httpResponseMessage).Dispose();
                        }
                    }
                }
                finally
                {
                    if (num < 0 && _httpClient != null)
                    {
                        ((IDisposable)_httpClient).Dispose();
                    }
                }
            }
            catch (Exception exception)
            {
                State = -2;
                _httpClient = null;
                _httpResponseMessage = null;
                Builder.SetException(exception);
                return;
            }
            State = -2;
            _httpClient = null;
            _httpResponseMessage = null;
            Builder.SetResult(result);
        }

        void IAsyncStateMachine.MoveNext()
        {
            //ILSpy generated this explicit interface implementation from .override directive in MoveNext
            this.MoveNext();
        }

        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            Builder.SetStateMachine(stateMachine);
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    public Task<Post> ObterPostPorIdAsync(int postId)
    {
        var stateMachine = default(ObterPostPorIdAsyncStateMachine);
        stateMachine.Builder = AsyncTaskMethodBuilder<Post>.Create();
        stateMachine.PostId = postId;
        stateMachine.State = -1;
        stateMachine.Builder.Start(ref stateMachine);
        return stateMachine.Builder.Task;
    }
}

public record Post(
    [property: JsonPropertyName("id")] int Id,
    [property: JsonPropertyName("title")] string Title,
    [property: JsonPropertyName("body")] string Body);

public static class Program
{
    public static async Task Main()
    {
        var blog = new BlogService();
        var post = await blog.ObterPostPorIdAsync(1);
        Console.WriteLine(post);
    }
}
Enter fullscreen mode Exit fullscreen mode

Jackie Chan e Cris Rock no filme "A hora do Rush" numa cena dentro de um taxy fazendo cara de pânico

Sem pânico!

Antes de tudo, vamos nos concentrar apenas na classe BlogService! A classe Program e o record Post servem só de apoio!

Numa primeira análise notamos o quanto de código o compilador gerou e fica nítido que o foco é que ele seja performático e gere a menor quantidade de alocações possível.

Aliás, a complexidade cognitiva do método MoveNext da struct ObterPostPorIdAsyncStateMachine é de 18, sendo que o aceitável é 10.

Eu uso um plugin no Rider que me dá essa informação:

Image description

Mas faço questão de reforçar mais uma vez que esse é o código mais otimizado possível para essa situação. O time de desenvolvimento do compilador do csharp trabalha árduamente para que a cada versão sejam incluídas mais e mais otimizações em todo o processo de compilação!

Porém, diferente do Baianinho de Mauá, as mágicas que o compilador faz NÃO SÃO INFINITAS. Se seu código não for minimante bem escrito, não vai adiantar nada, por isso é importante entendermos o que se passa por debaixo do capô!.

Vamos ao que interessa!

A primeira coisa que precisamos entender é que o processo assíncrono é gerenciado por uma máquina de estados.

Dentro da classe BlogService é criada uma struct privada chamada ObterPostPorIdAsyncStateMachine. É nessa struct que toda mágica acontece. Cada método assíncrono existente na classe BlogService teria sua própria máquina de estados. Nesse caso criei apenas um método para fins didáticos.

Essa struct implementa a interface IAsyncStateMachine que fica dentro do namespace System.Runtime.CompilerServices e contém os seguintes métodos:

public interface IAsyncStateMachine
{
    void MoveNext();
    void SetStateMachine(IAsyncStateMachine stateMachine);
}
Enter fullscreen mode Exit fullscreen mode

Essa interface representa uma máquina de estados geradas para métodos assíncronos e é destinada apenas ao uso do compilador.

O método MoveNext() move a máquina de estados para o próximo estado.

Já o método SetStateMachine(IAsyncStateMachine stateMachine) seta a máquina de estados com uma réplica alocada na memória heap.

Essa máquina tem 4 estados que são armazenados na variável global do tipo int chamada State. Note que dentro do método MoveNext o valor da variável State é copiado para a variável num. Alterar diretamente o valor de uma variável global numa máquina de estados pode ser perigoso e trazer efeitos colaterais indesejados. Essa variável global só vai receber um valor quando seu estado, de fato, mudar.

Estado Inicial: State = -1:

Ao invocar o método MoveNext pela primeira vez, a máquina de estados é iniciada. É nesse momento onde as variáveis são criadas.

if (num < 0)
{
    var defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(43, 1);
    defaultInterpolatedStringHandler.AppendLiteral("https://jsonplaceholder.typicode.com/posts/");
    defaultInterpolatedStringHandler.AppendFormatted(PostId);
    requestUri = defaultInterpolatedStringHandler.ToStringAndClear();
    _httpClient = new HttpClient();
}
Enter fullscreen mode Exit fullscreen mode

Após a criação das variáveis, é solicitada a requisição. Note que eu usei a palavra solicitada e não efetuada, afinal, como é mostrado no código, a variável awaiter está aguardando o processamento...

awaiter = _httpClient.GetAsync(requestUri).GetAwaiter();
Enter fullscreen mode Exit fullscreen mode

... e com isso, damos início ao segundo estado...

Estado Em Execução: State = 0:

if (!awaiter.IsCompleted)
{
    num = (State = 0);
    _awaiterHttpResponseMessage = awaiter;
    Builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}
Enter fullscreen mode Exit fullscreen mode

A variável awaiter tem uma propriedade chamada IsCompleted que indica se o processo está completo ou não (true/false).
Como estamos na primeira rodada do processamento (o método MoveNext foi acionado apenas uma vez), esse IsCompleted é falso, fazendo com que o estado (State) fique com o valor 0 e que seja invocado o método Builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);.

Esse é o ponto chave de todo o fluxo!

Esse método recebe o objeto que está aguardando a finalização de um processo (awaiter) e qual objeto que o invocou (this), sendo esse um tipo que implemente a interface IAsyncStateMachine. Tudo via referência.

Basicamente o AwaitUnsafeOnCompleted vai esperar por alguma mudança de estado no processamento do método aguardado pelo awaiter (_httpClient.GetAsync(requestUri)) e em seguida invoca o método MoveNext da struct que o invocou (ObterPostPorIdAsyncStateMachine).

Temos aqui um looping: Enquanto o awaiter.IsCompleted não for true, State vai ficar como 0 e o método Builder.AwaitUnsafeOnCompleted vai ser invocado novamente.

Esse processo todo também vai ocorrer quando estamos lendo o conteúdo do response após a requisição ser efetuada com sucesso:

awaiter2 = _httpResponseMessage.Content.ReadAsStringAsync().GetAwaiter();
if (!awaiter2.IsCompleted)
{
    num = (State = 1);
    _awaiterContentString = awaiter2;
    Builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
    return;
}
Enter fullscreen mode Exit fullscreen mode

Perceba que o fluxo é exatamente o mesmo!

Após os awaiters derem o sinal de que foram executados (IsCompleted == true) passamos para o próximo estado da nossa máquina.

Estado Obtendo Resultado: State = 1:

Aqui chegamos ao final do nosso processo.

Com o json obtido através do response vamos desserializa-lo e criar um objeto Post.

if (num != 1)
{
    // *solitita* o conteúdo do response... 
}
else
{
    awaiter2 = _awaiterContentString;
    _awaiterContentString = default(TaskAwaiter<string>);
    num = (State = -1); //Reinicia a máquina de estados...
}
result = JsonSerializer.Deserialize<Post>(awaiter2.GetResult());
Enter fullscreen mode Exit fullscreen mode

É possível notar o uso do go to (que me lembra o saudoso e famigerado VB6: On Error go to Hell).

...
if (num == 1)
{
    goto IL_00b6;
}
...

IL_00b6:  // <--------
try  
{  
    TaskAwaiter<string> awaiter2;  
    if (num != 1)  
    { 
        awaiter2 = _httpResponseMessage.Content.ReadAsStringAsync().GetAwaiter();  
        if (!awaiter2.IsCompleted)  
        { 
        num = (State = 1);  
            _awaiterContentString = awaiter2;  
            Builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);  
            return;  
        } 
    }  
    else  
    {  
        awaiter2 = _awaiterContentString;  
        _awaiterContentString = default(TaskAwaiter<string>);  
        num = (State = -1);  
    }
    result = JsonSerializer.Deserialize<Post>(awaiter2.GetResult());  
}  
finally  
{  
    if (num < 0 && _httpResponseMessage != null)  
    { 
        ((IDisposable)_httpResponseMessage).Dispose();  
    }
}
Enter fullscreen mode Exit fullscreen mode

O compilador utiliza o go to de maneira estratégica, fazendo com que o código desvie para o trecho onde o segundo awaiter está: awaiter2 = _httpResponseMessage.Content.ReadAsStringAsync().GetAwaiter();. Imagine que cada método async dentro desse processo poderia ter o seu go to justamente para que o código consiga alcançá-lo a partir do momento que o método MoveNext fosse executado. Normalmente usaríamos um if ou quebraríamos o método em várias partes, mas como disse, o compilador escolhe a melhor maneira para ele e não para quem está lendo o código.


E se por acaso explodir um erro?

Estado Ocorreu um Erro: State = -2:

Todo processo é envolvido por um try/catch e caso ocorra um erro, o State é mudado para -2, as variáveis são setadas como nulo e o builder armazena a exception gerada.

catch (Exception exception)  
{  
  State = -2;  
  _httpClient = null;  
  _httpResponseMessage = null;  
  Builder.SetException(exception);  
  return;  
}
Enter fullscreen mode Exit fullscreen mode

Ainda temos trechos de código que são utilizados para darem dispose nos objetos. Se você acompanhou o post anterior deve ter notado que a instrução using se torna um try/finally, e é no finally que os objetos são "descartados".

...

finally  
{  
    if (num < 0 && _httpResponseMessage != null)  
    { 
        ((IDisposable)_httpResponseMessage).Dispose();  
    }
 }
...

finally  
{  
    if (num < 0 && _httpClient != null)  
    { 
        ((IDisposable)_httpClient).Dispose();  
    }
}
Enter fullscreen mode Exit fullscreen mode

Por fim, e não menos importante temos o método: ObterPostPorIdAsync:

public Task<Post> ObterPostPorIdAsync(int postId)
{
    var stateMachine = default(ObterPostPorIdAsyncStateMachine);
    stateMachine.Builder = AsyncTaskMethodBuilder<Post>.Create();
    stateMachine.PostId = postId;
    stateMachine.State = -1;
    stateMachine.Builder.Start(ref stateMachine);
    return stateMachine.Builder.Task;
}
Enter fullscreen mode Exit fullscreen mode

Esse trecho não tem segredo! É nele onde os valores iniciais são setados e a máquina de estados é criada e inicializada.


Se você leu com atenção deve ter notado duas coisas:

  • O async não existe! Essa keyword simplesmente some no código gerado.
  • A única Task que temos é a que o builder da máquina de estados gera de retorno para o método ObterPostPorIdAsync: return stateMachine.Builder.Task;.

E é aqui aonde quero chegar: eu quis com esse post mostrar como o compilador lida com o async/await, como que o código é gerado e qual é a estratégia que ele usa para saber quando um processo terminou ou lançou um erro.

A classe Task por si só mereceria um post todinho só para ela.

Fora que ainda existem outros cenários que eu não cobri aqui, mas cobrirei em breve, como por exemplo o uso maléfico, maligno, molecular e abominável do .Result, CancellationToken em métodos assíncronos, a função do .ConfigureAwait(true/false) e o tratamento de exceções.

Quero escrever um post para cada um desses itens dando a devida atenção.


Para entender melhor todo o fluxo disponibilizei no github o código fonte!
Coloque um break point dentro do método MoveNext e vá navegando linha a linha.

Quer ir mais afundo nesse assunto?
Esse é, sem dúvidas, um dos melhores posts sobre async/await: https://devblogs.microsoft.com/pfxteam/asyncawait-faq/.
E a melhor parte está nos comentários. Leitura obrigatória.

Era isso, até a próxima!

Top comments (2)

Collapse
 
tadeutoledo profile image
Tadeu Toledo Mathias

Excelente post!
É sempre interessante saber como as coisas funcionam por baixo dos panos.

Collapse
 
angelobelchior profile image
Angelo Belchior

Muito obrigado! Quero fazer mais posts desse tipo \o/