1. Introdução
Imagine que você é o maestro da orquestra (do seu aplicativo). Se toda vez que o violinista afina o violino (um I/O demorado) você fica esperando antes de seguir para outros instrumentos, a orquestra inteira vai travar. Mas se o violinista diz: "Eu vou afinar, vocês continuem tocando, eu aviso quando estiver pronto", — isso é assincronia!
No mundo do C# e .NET 9 existem ferramentas específicas pra esse "multitasking sem travar". Os protagonistas de hoje são as versões assíncronas dos métodos Read e Write, chamadas ReadAsync e WriteAsync.
Elas permitem iniciar uma operação de leitura ou escrita e imediatamente "liberar" a thread atual pra fazer outra coisa. Quando a operação de I/O terminar (por exemplo, os dados forem lidos do disco ou escritos nele), seu código "acorda" e continua de onde parou.
Pra usar esses métodos precisamos de duas palavrinhas mágicas que chegaram no C# lá em 2012 com a versão 5.0 (e agora, no C# 14, já são velhas conhecidas!):
- async: É um modificador que você coloca no método pra dizer ao compilador: "Dentro desse método pode ter operações assíncronas e eu vou usar await".
- await: É o operador que você usa antes de chamar uma operação assíncrona (tipo ReadAsync ou WriteAsync). Significa: "Comece essa operação, mas não fique esperando aqui. Devolva o controle para quem chamou e volte quando a operação terminar".
Não se preocupe se isso parecer meio abstrato por enquanto. Vamos ter um nível inteiro dedicado ao async e await (Nível 58), onde vamos desmontar tudo. Agora o importante é entender que eles ajudam a não bloquear a thread principal.
2. ReadAsync: lendo com calma
O método ReadAsync permite ler dados de um stream de forma assíncrona. Em vez de esperar os bytes serem lidos do disco, você inicia a leitura e já parte pra outras tarefas.
Aqui está a assinatura principal para leitura em um buffer:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Ou, mais usado no C# moderno (e .NET 9), usando Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Vamos analisar os parâmetros:
- buffer: É um array de byte (ou Memory<byte>) onde os dados serão lidos. Lembra dos buffers pra otimizar? Aqui é a mesma ideia, só que pra operações assíncronas.
- offset: Offset no buffer onde começar a escrever os bytes lidos.
- count: Quantidade máxima de bytes a serem lidos.
- CancellationToken cancellationToken: Parâmetro muito útil pra cancelar a operação se ela não for mais necessária (ex.: usuário fechou o app ou apertou o botão "Cancelar").
- ValueTask<int>: É uma "promessa" de que, quando a operação terminar, vai retornar um inteiro (int) com o número de bytes lidos. ValueTask é uma versão otimizada de Task pra cenários onde o resultado pode estar disponível sincronamente ou assincronamente.
Exemplo 1: Leitura assíncrona de arquivo
Imagina que temos um arquivo de texto grande e queremos ler sem bloquear a thread principal. Exemplo básico:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Função assíncrona para ler o arquivo e contar linhas
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Abrir arquivo de forma assíncrona
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"No arquivo {filename} linhas: {count}");
}
}
Comentários sobre o código:
- Repare no parâmetro useAsync: true no FileStream. Isso é importante pra verdadeira assincronicidade.
- Usamos await em ReadLineAsync pra não bloquear a thread enquanto a linha é lida.
- O método Main agora é assíncrono (C# 7+ permite isso).
Se fosse um app gráfico, durante a leitura do arquivo (enquanto ReadAsync espera os dados do disco) o usuário poderia clicar em botões, rolar a tela e fazer outras coisas, porque a thread de UI não estaria bloqueada. Em um app de console isso fica menos visível, mas o princípio é o mesmo.
3. WriteAsync: escrevendo sem demora
Assim como o ReadAsync, o método WriteAsync permite escrever dados no stream de forma assíncrona. É muito útil quando você tem que gravar muitos dados sem fazer o app esperar a conclusão da escrita no disco.
Assinaturas principais:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
E usando ReadOnlyMemory<byte> (na escrita não modificamos o buffer):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
Os parâmetros são parecidos com ReadAsync:
- buffer: Array de byte (ou ReadOnlyMemory<byte>) com os dados a serem escritos.
- offset: Offset no buffer de onde começar a ler os dados para escrita.
- count: Quantidade de bytes a escrever.
- CancellationToken cancellationToken: Pra cancelar a operação.
- ValueTask: Não retorna valor, já que a quantidade escrita é informada pelo parâmetro count.
Exemplo 2: Escrita assíncrona de arquivo
Agora vamos escrever algo num arquivo — também de forma assíncrona.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Linha número {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"Gravação {filename} concluída.");
}
}
Aqui o loop escreve 10000 linhas, e a thread principal não fica bloqueada: se fosse um app GUI, a interface não ficaria "travada".
Graças ao async e await, nosso app de console agora pode, por exemplo, copiar arquivos e continuar responsivo ao input do usuário (no nosso caso, apertar Enter pra cancelar). Esse é um princípio fundamental pra apps modernos, performáticos e responsivos em C#.
4. Nuances úteis
Visualização: como funciona a leitura/escrita assíncrona
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Seu código (UI/lógica)│ ─────────────────────→ │ OS/E/S: operação assíncrona │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(faz outras coisas) │(lê o arquivo, espera o disco)
│<────────────────────────────────────────→│
└─ Aguarda Task, recebe resultado ←──────────┘
Esquema aproximado: enquanto o disco trabalha devagar, seu código pode fazer outras coisas. Só quando os dados forem realmente necessários — a execução espera o Task.
Aplicações práticas: onde isso é realmente necessário?
- Aplicativos desktop: se seu app lê ou grava coisas grandes (logs, bancos de dados, vídeo), assincronia é must-have. Mesmo numa máquina rápida, o usuário pode abrir um arquivo pela rede e a velocidade ser de tartaruga.
- Backend ou web apps: dezenas ou até milhares de usuários podem bater no servidor ao mesmo tempo. Se cada thread ficar bloqueada em leitura de arquivos — adeus performance e olá 502 Bad Gateway.
- Apps móveis: abrir ou gravar arquivo pode demorar — usuário percebe lag. Use assincronia!
- Qualquer processamento em massa de arquivos: apps que trabalham com coleções de arquivos (compactadores, parsers, analisadores) ganham muito com I/O assíncrono.
Leitura/Escrita síncrona vs assíncrona
| Método | Bloqueia a thread? | Fácil de implementar? | Melhor performance? | Conforto pra UI/servidores |
|---|---|---|---|---|
| Síncrono (Read/Write) | Sim | Sim | Não | Não |
| Assíncrono (ReadAsync) | Não | Quase | Sim | Sim |
5. Nuances e melhores práticas
Buffering continua importante: Mesmo com ReadAsync e WriteAsync, ler ou escrever um byte por vez continua extremamente ineficiente. Assincronia remove bloqueios, mas não acelera magicamente cada byte. Bons tamanhos iniciais de buffer são 4096-8192 bytes; pra arquivos grandes vale testar 65536 ou 131072.
"Asincronia deve estar em todo lugar" (Async All The Way Down): Se você começou a usar async/await em um ponto, normalmente convém estender esse padrão por toda a cadeia de chamadas: C faz algo assíncrono — então C é async Task, B também async Task, e A — async Task. Caso contrário podem surgir bloqueios e até deadlocks em apps com UI.
Tratamento de exceções: No código assíncrono use os usuais try-catch. Exceções comuns são OperationCanceledException e IOException — trate-as explicitamente.
Liberação de recursos (await using): Para streams e outros objetos IDisposable libere recursos corretamente. Se o tipo implementar IAsyncDisposable, então await using chamará DisposeAsync(); se for só IDisposable — será chamado Dispose().
O que acontece por baixo (resumido): Ao usar await o compilador transforma o método num autômato: a operação é iniciada, o método "pausa", o controle volta pra quem chamou. Quando o resultado fica pronto, o SynchronizationContext (na UI) ou o ThreadPool (em console/servidor) retoma a execução do ponto onde parou. Isso permite que uma thread atenda muitas tarefas "suspensas" sem bloquear.
Pra concluir, programação assíncrona com async e await é uma ferramenta poderosa pra criar apps responsivos e escaláveis. Ela permite que seu código use recursos do sistema de forma eficiente, sem bloquear UI ou threads no servidor. Pode parecer estranho no começo, mas vale a pena aprender! Nas próximas palestras vamos nos aprofundar ainda mais no mundo da assincronia e paralelismo. Até a próxima!
GO TO FULL VERSION