Vinicius Quinafelex Alves

🌐English version

[C#] Programação assíncrona, I/O e escalabilidade

Programação assíncrona é uma técnica de programação que faz com que as threads principais de um processo não executem comandos demorados. Esses comandos são enviadas para um handler separado para serem executados em paralelo, permitindo que as threads principais fiquem livres para executar outros comandos.

Geralmente é usado em conjunto com interrupções, de forma que uma thread é liberada quando o comando assíncrono inicia, e uma thread é solicitada quando o comando assíncrono é concluído. Assim, o processo garante que a retomada da execução continue dentro do mesmo contexto, mantendo os mesmos recursos e valores de variáveis.

Esse conceito é uma das bases de plataformas como NodeJS, que roda em cima de uma única thread principal.

Programação síncrona

Sem assíncronia, uma thread que precisa executar múltiplos métodos só consegue executá-los em sequência, como demonstrado abaixo. Cada execução demora 10 segundos, então as 3 execuções no total demoram 30 segundos.

public Result Execute(string input)
{
    // Command A - CPU-only - 2 seconds
    var value = TransformInput(input);

    // Command B - I/O - 6 seconds
    var saveResult = SaveToDatabase(value);

    // Command C - CPU-only - 2 second
    return TransformOutput(saveResult);
}
Thread
Call 1
A
B
C
Call 2
A
B
C
Call 3
A
B
C

Operações de I/O

A principal função do comando "SaveToDatabase" é apenas a comunicação com um recurso externo, tornando ele um comando de I/O. Em comandos de I/O, a thread não faz nada além de esperar por um resultado, e continua marcada como "ocupada".

Alguns dos comandos de I/O mais comuns são: conectar e enviar comandos para um banco de dados, ler ou escrever arquivos no disco rígido, e comunicar com serviços externos através da rede, por exemplo com APIs.

Programação assíncrona

No C#, alguns comandos, incluindo comandos de I/O, podem ser reescritos para rodar de forma assíncrona, usando a biblioteca de async/await

Quando a thread encontra um comando com await, o comando será enviado para um handler e a thread será liberada para executar outra coisa. O processo será notificado quando o comando for concluído, e ele poderá alocar uma thread desocupada para continuar a execução do método.

public async Task<Result> ExecuteAsync(string input)
{
    // Operation A - CPU-only - 2 seconds
    var value = TransformInput(input);

    // Operation B - I/O - 6 seconds
    var saveResult = await SaveToDatabaseAsync(value);

    // Operation C - CPU-only - 2 second
    return TransformOutput(saveResult);
}
Thread
Call 1
A
C
Call 2
A
C
Call 3
A
C
Handler
B
B
B

Como o comando de I/O é encaminhado pro handler, a thread fica livre pra começar a executar a próxima chamada imediatamente. Assim, as 3 chamadas que levavam 30 segundos na programação síncrona agora levam apenas 14 segundos, sem necessidade de criação de novas threads ou melhoria de hardware.