Skip to main content

Guia Async

  • Guia Async

Table of contents

Asynchronous Programming

A programação assíncrona existe há vários anos na plataforma .NET, mas historicamente tem sido muito difícil de implementar corretamente. Desde a introdução de async/await no C# 5, a programação assíncrona se tornou algo comum. Frameworks modernos (como o ASP.NET Core) são totalmente assíncronos, e é muito difícil evitar a palavra-chave async ao escrever serviços web. Como resultado, surgiu muita confusão sobre as melhores práticas para async e como utilizá-lo adequadamente. Esta seção tentará apresentar algumas orientações, com exemplos de padrões ruins e bons sobre como escrever código assíncrono.

Asynchrony is viral

Uma vez que você adota o modelo assíncrono, todos os seus chamadores DEVEM ser assíncronos, pois os esforços para ser assíncrono não têm efeito real a menos que toda a pilha de chamadas seja assíncrona. Em muitos casos, ser parcialmente assíncrono pode ser pior do que ser totalmente síncrono. Portanto, o melhor é ir até o fim e tornar tudo assíncrono de uma vez.


RUIM Este exemplo usa Task.Result e, como consequência, bloqueia a thread atual para aguardar o resultado. Isso é um exemplo de sync over async.

public int DoSomethingAsync()
{
var result = CallDependencyAsync().Result;
return result + 1;
}

BOM Este exemplo usa a palavra-chave await para obter o resultado de CallDependencyAsync.

public async Task<int> DoSomethingAsync()
{
var result = await CallDependencyAsync();
return result + 1;
}

Async void

O uso de async void em aplicações ASP.NET Core é SEMPRE ruim. Evite — nunca faça isso. Geralmente, ele aparece quando desenvolvedores tentam implementar padrões fire-and-forget disparados por uma ação de controller. Métodos async void farão o processo falhar caso uma exceção seja lançada. Vamos ver mais adiante outros padrões que levam os desenvolvedores a fazer isso em aplicações ASP.NET Core, mas aqui está um exemplo simples:


RUIM Métodos async void não podem ser rastreados e, portanto, exceções não tratadas podem causar falhas na aplicação.

public class MyController : Controller
{
[HttpPost("/start")]
public IActionResult Post()
{
BackgroundOperationAsync();
return Accepted();
}

public async void BackgroundOperationAsync()
{
var result = await CallDependencyAsync();
DoSomething(result);
}
}

BOM Métodos que retornam Task são melhores, pois exceções não tratadas disparam o evento TaskScheduler.UnobservedTaskException.

public class MyController : Controller
{
[HttpPost("/start")]
public IActionResult Post()
{
Task.Run(BackgroundOperationAsync);
return Accepted();
}

public async Task BackgroundOperationAsync()
{
var result = await CallDependencyAsync();
DoSomething(result);
}
}

Prefira Task.FromResult em vez de Task.Run para dados pré-computados ou trivialmente computados

Para resultados já pré-calculados, não há necessidade de chamar Task.Run, pois isso irá enfileirar um item de trabalho no thread pool que completará imediatamente com o valor já computado. Em vez disso, use Task.FromResult para criar uma task encapsulando o dado já disponível.


RUIM Este exemplo desperdiça uma thread do thread pool para retornar um valor trivialmente computado.

public class MyLibrary
{
public Task<int> AddAsync(int a, int b)
{
return Task.Run(() => a + b);
}
}

BOM Este exemplo usa Task.FromResult para retornar o valor trivialmente computado, sem usar threads adicionais.

public class MyLibrary
{
public Task<int> AddAsync(int a, int b)
{
return Task.FromResult(a + b);
}
}

💡 NOTA: Usar Task.FromResult ainda resulta em uma alocação de Task. O uso de ValueTask<T> pode remover completamente essa alocação.


BOM Este exemplo usa ValueTask<int> para retornar o valor trivialmente computado, sem usar threads adicionais e sem alocar objetos no managed heap.

public class MyLibrary
{
public ValueTask<int> AddAsync(int a, int b)
{
return new ValueTask<int>(a + b);
}
}

Avoid using Task.Run for long-running work that blocks the thread

Trabalho de longa duração, nesse contexto, refere-se a uma thread que permanece ativa durante toda a vida da aplicação executando tarefas em segundo plano (como processar itens de fila ou acordar periodicamente para tratar dados).

O método Task.Run coloca um item de trabalho na fila do thread pool. A suposição é que essa tarefa será concluída rapidamente (ou rápido o suficiente para permitir que essa thread seja reutilizada em um prazo razoável). "Roubar" uma thread do thread pool para um trabalho de longa duração é ruim, pois ela deixa de estar disponível para outras tarefas (como timer callbacks, continuações de tasks, etc). Em vez disso, crie manualmente uma nova thread para executar esse trabalho bloqueante e de longa duração.

💡 NOTA: O thread pool cresce quando você bloqueia threads, mas isso é uma má prática.

💡 NOTA: O método Task.Factory.StartNew possui a opção TaskCreationOptions.LongRunning, que internamente cria uma nova thread e retorna uma Task representando sua execução. Usar isso corretamente exige passar vários parâmetros não óbvios para garantir o comportamento correto em todas as plataformas.

💡 NOTA: Não use TaskCreationOptions.LongRunning com código assíncrono, pois isso criará uma nova thread que será destruída após o primeiro await.


RUIM Este exemplo "rouba" para sempre uma thread do thread pool para executar o trabalho enfileirado em um BlockingCollection<T>.

public class QueueProcessor
{
private readonly BlockingCollection<Message> _messageQueue = new BlockingCollection<Message>();

public void StartProcessing()
{
Task.Run(ProcessQueue);
}

public void Enqueue(Message message)
{
_messageQueue.Add(message);
}

private void ProcessQueue()
{
foreach (var item in _messageQueue.GetConsumingEnumerable())
{
ProcessItem(item);
}
}

private void ProcessItem(Message message) { }
}

BOM Este exemplo usa uma thread dedicada para processar a fila de mensagens em vez de uma thread do thread pool.

public class QueueProcessor
{
private readonly BlockingCollection<Message> _messageQueue = new BlockingCollection<Message>();

public void StartProcessing()
{
var thread = new Thread(ProcessQueue)
{
// Isso é importante para permitir que o processo finalize mesmo com a thread em execução
IsBackground = true
};
thread.Start();
}

public void Enqueue(Message message)
{
_messageQueue.Add(message);
}

private void ProcessQueue()
{
foreach (var item in _messageQueue.GetConsumingEnumerable())
{
ProcessItem(item);
}
}

private void ProcessItem(Message message) { }
}

BOM Este exemplo utiliza um TaskFactory com TaskCreationOptions.LongRunning para processar a fila de mensagens em vez de criar manualmente uma thread.

public class QueueProcessor
{
private readonly BlockingCollection<Message> _messageQueue = new BlockingCollection<Message>();

public Task StartProcessing() => Task.Factory.StartNew(ProcessQueue, TaskCreationOptions.LongRunning);

public void Enqueue(Message message)
{
_messageQueue.Add(message);
}

private void ProcessQueue()
{
foreach (var item in _messageQueue.GetConsumingEnumerable())
{
ProcessItem(item);
}
}

private void ProcessItem(Message message) { }
}

Vantagens de usar TaskCreationOptions.LongRunning em relação à criação manual de thread:

  • Pode ser facilmente combinado com await e APIs do TPL como Task.WhenAll, entre outras.
  • Proporciona um mecanismo de tratamento de exceções superior: em uma thread criada manualmente, uma exceção não tratada derruba a aplicação (a menos que seja tratada via AppDomain.CurrentDomain.UnhandledException), mas com .LongRunning, a exceção é encapsulada em uma Task como um AggregateException.

💡 NOTA: TaskCreationOptions.LongRunning é apenas uma recomendação para o TaskScheduler. Em TaskSchedulers personalizados, diferentes runtimes ou versões futuras do .NET, o comportamento pode variar. Se seu objetivo principal é garantir uma thread dedicada, considere a abordagem de criação manual de thread mostrada anteriormente.

Avoid using Task.Result and Task.Wait

Existem pouquíssimas formas corretas de usar Task.Result e Task.Wait, portanto, a recomendação geral é evitá-los completamente no seu código.


⚠️ Sync over async

Usar Task.Result ou Task.Wait para bloquear e aguardar a conclusão de uma operação assíncrona é MUITO pior do que chamar uma API verdadeiramente síncrona para bloquear. Esse fenômeno é conhecido como "Sync over async". De forma simplificada, acontece assim:

  1. Uma operação assíncrona é iniciada.
  2. A thread que chamou a operação é bloqueada aguardando sua conclusão.
  3. Quando a operação assíncrona termina, ela libera o código que estava bloqueado — mas isso acontece em outra thread.

Resultado: são usadas duas threads para completar algo que poderia ser feito em apenas uma se fosse síncrono. Isso frequentemente leva à exaustão do thread-pool (thread-pool starvation), causando quedas no serviço.


⚠️ Deadlocks

O SynchronizationContext é uma abstração que permite que modelos de aplicação controlem onde as continuações assíncronas irão rodar. Plataformas como ASP.NET (não Core), WPF e Windows Forms têm implementações que podem causar deadlocks se Task.Wait ou Task.Result forem usados na main thread.

Essa limitação levou a vários trechos de código “engenhosos” que supostamente evitam o problema, mas a realidade é: não existe uma maneira realmente segura de bloquear esperando por uma Task.

💡 NOTA: ASP.NET Core não tem SynchronizationContext e, portanto, não sofre com o problema de deadlock dessa forma — mas ainda sofre com sync over async e desperdício de threads.


RUIM Todos os exemplos abaixo tentam, de formas diferentes, evitar o deadlock, mas ainda caem nos problemas de sync over async.

public string DoOperationBlocking()
{
// Bloqueia a thread que entrou.
// Remove o risco de deadlock, mas ainda sofre com sync over async.
// Em caso de exceção, lança AggregateException.
return Task.Run(() => DoAsyncOperation()).Result;
}

public string DoOperationBlocking2()
{
// Mesmo problema, mas lança exceção sem AggregateException.
return Task.Run(() => DoAsyncOperation()).GetAwaiter().GetResult();
}

public string DoOperationBlocking3()
{
// Bloqueia a thread externa e também a thread do thread-pool internamente.
// Pode resultar em AggregateException dentro de outro AggregateException.
return Task.Run(() => DoAsyncOperation().Result).Result;
}

public string DoOperationBlocking4()
{
// Mesma lógica, mas usando GetAwaiter().GetResult() internamente e externamente.
return Task.Run(() => DoAsyncOperation().GetAwaiter().GetResult()).GetAwaiter().GetResult();
}

public string DoOperationBlocking5()
{
// Bloqueia sem se preocupar com SynchronizationContext.
// Pode causar deadlock e lança AggregateException.
return DoAsyncOperation().Result;
}

public string DoOperationBlocking6()
{
// Igual ao anterior, mas lança a exceção original.
return DoAsyncOperation().GetAwaiter().GetResult();
}

public string DoOperationBlocking7()
{
// Combina Wait() e GetResult() — duplicando o problema.
var task = DoAsyncOperation();
task.Wait();
return task.GetAwaiter().GetResult();
}

Prefer await over ContinueWith

Task já existia antes das palavras-chave async/await serem introduzidas e, por isso, oferecia formas de executar continuações (continuations) sem depender de suporte direto da linguagem. Embora esses métodos ainda sejam válidos, a recomendação geral é preferir async/await em vez de usar ContinueWith.

Outro detalhe importante: ContinueWith não captura o SynchronizationContext, o que significa que ele se comporta de forma semanticamente diferente do async/await.


RUIM Usa ContinueWith em vez de async.

public Task<int> DoSomethingAsync()
{
return CallDependencyAsync().ContinueWith(task =>
{
return task.Result + 1;
});
}

BOM Usa await para obter o resultado de CallDependencyAsync.

public async Task<int> DoSomethingAsync()
{
var result = await CallDependencyAsync();
return result + 1;
}

Always create TaskCompletionSource<T> with TaskCreationOptions.RunContinuationsAsynchronously

TaskCompletionSource<T> é um componente essencial para bibliotecas que precisam tornar algo que não é naturalmente "awaitable" em algo que pode ser aguardado com Task. Ele também é usado para construir operações de nível mais alto (como batching ou combinadores) em cima de APIs assíncronas existentes.

Por padrão, as continuações de uma Task são executadas inline — ou seja, na mesma thread que chama TrySetResult, TrySetException ou TrySetCanceled. Para autores de bibliotecas, isso significa que o código chamador pode retomar diretamente na sua thread, o que é perigoso e pode causar:

  • Deadlocks
  • Thread-pool starvation
  • Corrupção de estado (se o código executar de forma inesperada)
  • Outros problemas difíceis de depurar

Sempre use TaskCreationOptions.RunContinuationsAsynchronously ao criar um TaskCompletionSource<T>. Isso garante que as continuações serão executadas em uma thread do thread pool, não na mesma que chamou o SetResult.


RUIM Não usa TaskCreationOptions.RunContinuationsAsynchronously — o código aguardando essa Task continuará na mesma thread.

public Task<int> DoSomethingAsync()
{
var tcs = new TaskCompletionSource<int>();

var operation = new LegacyAsyncOperation();
operation.Completed += result =>
{
// Código aguardando essa task vai continuar nesta mesma thread!
tcs.SetResult(result);
};

return tcs.Task;
}

BOM Usa TaskCreationOptions.RunContinuationsAsynchronously para garantir que a continuação ocorra em uma thread diferente do thread pool.

public Task<int> DoSomethingAsync()
{
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);

var operation = new LegacyAsyncOperation();
operation.Completed += result =>
{
// A continuação será executada em outra thread do thread-pool
tcs.SetResult(result);
};

return tcs.Task;
}

💡 ATENÇÃO: Existem dois enums parecidos:


Sempre descarte (Dispose) CancellationTokenSource usado para timeouts

Quando CancellationTokenSource é criado com temporizador (via construtor ou método CancelAfter), ele mantém um timer na fila. Se você não fizer o dispose, esse timer permanece na fila até expirar, consumindo recursos desnecessariamente.


RUIM Não faz dispose do CancellationTokenSource, deixando o timer na fila por 10 segundos após cada requisição.

public async Task<Stream> HttpClientAsyncWithCancellationBad()
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

using (var client = _httpClientFactory.CreateClient())
{
var response = await client.GetAsync("http://backend/api/1", cts.Token);
return await response.Content.ReadAsStreamAsync();
}
}

BOM Usa using para garantir que o CancellationTokenSource seja descartado, removendo o timer imediatamente.

public async Task<Stream> HttpClientAsyncWithCancellationGood()
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
{
using (var client = _httpClientFactory.CreateClient())
{
var response = await client.GetAsync("http://backend/api/1", cts.Token);
return await response.Content.ReadAsStreamAsync();
}
}
}

Always flow CancellationToken(s) to APIs that take a CancellationToken

O cancelamento em .NET é cooperativo — nada é cancelado "automaticamente". Para que funcione corretamente, todo o encadeamento de chamadas precisa receber explicitamente o CancellationToken.

Isso significa que, se você deseja que o cancelamento seja realmente efetivo, é necessário propagar o token para todas as APIs que o aceitam.


RUIM Não passa o CancellationToken para o Stream.ReadAsync, tornando a operação não cancelável.

public async Task<string> DoAsyncThing(CancellationToken cancellationToken = default)
{
byte[] buffer = new byte[1024];
// Esquecemos de passar o cancellationToken
int read = await _stream.ReadAsync(buffer, 0, buffer.Length);
return Encoding.UTF8.GetString(buffer, 0, read);
}

BOM Passa corretamente o CancellationToken para o Stream.ReadAsync.

public async Task<string> DoAsyncThing(CancellationToken cancellationToken = default)
{
byte[] buffer = new byte[1024];
// Passamos corretamente o cancellationToken
int read = await _stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
return Encoding.UTF8.GetString(buffer, 0, read);
}

Cancelling uncancellable operations

Ao fazer programação assíncrona em .NET, um padrão recorrente é cancelar uma operação que não é originalmente cancelável. Isso normalmente envolve criar uma Task que conclui quando um timeout ou CancellationToken dispara e, em seguida, usar Task.WhenAny para detectar se a operação foi concluída ou cancelada.


Usando CancellationToken

RUIM Usa Task.Delay(-1, token) para criar uma Task que conclui quando o CancellationToken dispara, mas não há como liberar o CancellationTokenRegistration criado internamente. Se o cancelamento não acontecer, isso pode causar memory leak.

public static async Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken)
{
// Não há como liberar o registration
var delayTask = Task.Delay(-1, cancellationToken);

var resultTask = await Task.WhenAny(task, delayTask);
if (resultTask == delayTask)
{
throw new OperationCanceledException();
}

return await task;
}

BOM Libera o CancellationTokenRegistration assim que uma das Tasks é concluída.

public static async Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);

using (cancellationToken.Register(state =>
{
((TaskCompletionSource<object>)state).TrySetResult(null);
}, tcs))
{
var resultTask = await Task.WhenAny(task, tcs.Task);
if (resultTask == tcs.Task)
{
throw new OperationCanceledException(cancellationToken);
}

return await task;
}
}

BOM (no .NET 6+) Prefira Task.WaitAsync.


Usando timeout

RUIM Não cancela o timer se a operação concluir com sucesso — isso pode acumular timers e sobrecarregar a fila de timers.

public static async Task<T> TimeoutAfter<T>(this Task<T> task, TimeSpan timeout)
{
var delayTask = Task.Delay(timeout);

var resultTask = await Task.WhenAny(task, delayTask);
if (resultTask == delayTask)
{
throw new OperationCanceledException();
}

return await task;
}

BOM Cancela o timer quando a operação conclui com sucesso.

public static async Task<T> TimeoutAfter<T>(this Task<T> task, TimeSpan timeout)
{
using (var cts = new CancellationTokenSource())
{
var delayTask = Task.Delay(timeout, cts.Token);

var resultTask = await Task.WhenAny(task, delayTask);
if (resultTask == delayTask)
{
throw new OperationCanceledException();
}
else
{
cts.Cancel();
}

return await task;
}
}

BOM (no .NET 6+) Prefira Task.WaitAsync com timeout.


Sempre chamar FlushAsync antes de Dispose em StreamWriter ou Stream

Mesmo usando métodos assíncronos de escrita, o stream pode manter dados em buffer. Ao chamar Dispose (sincrono), esses dados são gravados/flushed de forma bloqueante, podendo causar thread-pool starvation. Prefira DisposeAsync (via await using) ou chame FlushAsync antes de Dispose.

💡 NOTA: Isso só é problemático se o stream fizer IO.

RUIM O Dispose síncrono bloqueia a requisição gravando no corpo da resposta.

app.Run(async context =>
{
using (var streamWriter = new StreamWriter(context.Response.Body))
{
await streamWriter.WriteAsync("Hello World");
}
});

BOM Usa await using para fazer o flush assíncrono no DisposeAsync.

app.Run(async context =>
{
await using (var streamWriter = new StreamWriter(context.Response.Body))
{
await streamWriter.WriteAsync("Hello World");
}
});

BOM Faz FlushAsync antes de Dispose.

app.Run(async context =>
{
using (var streamWriter = new StreamWriter(context.Response.Body))
{
await streamWriter.WriteAsync("Hello World");
await streamWriter.FlushAsync();
}
});

Prefer async/await over directly returning Task

Usar async/await em vez de simplesmente retornar a Task traz vantagens importantes:

  • Exceções síncronas e assíncronas são normalizadas — sempre serão observadas de forma assíncrona.
  • Facilidade para modificar o código — por exemplo, inserir um using ou adicionar mais lógica no método.
  • Diagnóstico mais simples — especialmente para identificar hangs e problemas de execução.
  • Tratamento de exceções previsível — qualquer exceção é automaticamente encapsulada na Task retornada, sem surpreender o chamador com uma exceção lançada imediatamente.
  • Evita vazamento de async locals — se você definir um async local num método não assíncrono, ele pode “vazar” para fora da chamada.

RUIM Retorna diretamente a Task, sem os benefícios do state machine gerado por async.

public Task<int> DoSomethingAsync()
{
return CallDependencyAsync();
}

BOM Usa async/await, mantendo os benefícios de exceção normalizada, diagnóstico e segurança de async locals.

public async Task<int> DoSomethingAsync()
{
return await CallDependencyAsync();
}

💡 NOTA: Há considerações de desempenho — retornar diretamente a Task é mais rápido por evitar a criação de uma async state machine, mas isso muda o comportamento e pode fazer você perder as vantagens listadas acima.

Se quiser, posso te preparar uma tabela comparativa mostrando quando retornar direto e quando usar async/await para facilitar decisões de design.

AsyncLocal<T>

Async locals são uma forma de armazenar/recuperar estado ambiente (ambient state) em toda a aplicação. Isso pode ser uma alternativa muito útil para evitar passar estado explicitamente por todos os lugares — especialmente em pontos de chamada sobre os quais você não tem muito controle.

Apesar de poderoso, também é perigoso se usado incorretamente. Async locals estão ligados ao ExecutionContext, que flui implicitamente para todos os lugares. Desabilitar esse fluxo exige uso de APIs avançadas (normalmente prefixadas com Unsafe). Na prática, você tem pouquíssimo controle sobre qual código vai tentar acessar esses valores.


Criando um AsyncLocal<T>

Se puder evitar async locals, prefira passar o estado explicitamente ou usar técnicas como inversão de controle.

Se não puder evitar, garanta que qualquer coisa colocada em um async local seja:

  1. Não descartável (IDisposable)
  2. Imutável / somente leitura / thread-safe

1. ❌ RUIM — Objeto descartável armazenado em um async local

using (var thing = new DisposableThing())
{
// Disponibiliza o objeto descartável como estado ambiente
DisposableThing.Current = thing;

Dispatch();

// Antes de descartar, tenta garantir que ninguém capture a instância
DisposableThing.Current = null;
}

void Dispatch()
{
// Task.Run captura o ExecutionContext atual (e, portanto, os async locals)
_ = Task.Run(async () =>
{
await Task.Delay(1000);
Log();
});
}

void Log()
{
try
{
var thing = DisposableThing.Current;
if (thing is not null)
{
Console.WriteLine($"Logging ambient value {thing.Value}");
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}

class DisposableThing : IDisposable
{
private static readonly AsyncLocal<DisposableThing?> _current = new();
private bool _disposed;

public static DisposableThing? Current
{
get => _current.Value;
set => _current.Value = value;
}

public int Value
{
get
{
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
return 1;
}
}

public void Dispose() => _disposed = true;
}

Esse código sempre acaba lançando ObjectDisposedException. Mesmo que Log() verifique se o valor não é null, ele pode manter uma referência para um objeto já descartado.

Definir DisposableThing.Current = null; não afeta o valor capturado anteriormente no contexto de execução — pois o ExecutionContext é copy-on-write. Ou seja: leituras futuras verão null, mas as capturas anteriores não são alteradas.


Se você fizer:

DisposableThing.Current = new DisposableThing();
Console.WriteLine("Depois de definir: " + ExecutionContext.Capture().GetHashCode());

DisposableThing.Current = null;
Console.WriteLine("Depois de definir null: " + ExecutionContext.Capture().GetHashCode());

O hash code do contexto muda, indicando que um novo contexto foi criado — não que o anterior foi modificado.


Tentativa de mutar o valor existente

Pode parecer tentador alterar a lógica para mutar o valor existente em vez de substituir o AsyncLocal — usando StrongBox<T>:

class DisposableThing : IDisposable
{
private static readonly AsyncLocal<StrongBox<DisposableThing?>> _current = new();
private bool _disposed;

public static DisposableThing? Current
{
get => _current.Value?.Value;
set
{
var box = _current.Value;
if (box is not null)
{
// Mutar o valor em todos os ExecutionContexts que tenham cópia
box.Value = null;
}

if (value is not null)
{
_current.Value = new StrongBox<DisposableThing?>(value);
}
}
}

public int Value
{
get
{
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
return 1;
}
}

public void Dispose() => _disposed = true;
}

Isso funciona para limpar o valor em todos os contextos que compartilham a referência.


Problema de corrida (race condition)

Mesmo assim, ainda há risco: a referência pode ser capturada antes de ser limpa.

void Dispatch()
{
_ = Task.Run(async () =>
{
var current = DisposableThing.Current;
await Task.Delay(1000);
Log(current);
});
}

void Log(DisposableThing thing)
{
try
{
Console.WriteLine($"Logging ambient value {thing.Value}");
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}

Se o DisposableThing for descartado no meio disso, você tem uma referência inválida — causando falhas aleatórias.

Conclusão: não armazene objetos descartáveis em AsyncLocal<T>.

2. ❌ RUIM — Objeto não thread-safe armazenado em um async local

AmbientValues.Current = new Dictionary<int, string>();

Parallel.For(0, 10, i =>
{
AmbientValues.Current[i] = "processing";
LogCurrentValues();
AmbientValues.Current[i] = "done";
});


void LogCurrentValues()
{
foreach (var pair in AmbientValues.Current)
{
Console.WriteLine(pair);
}
}

class AmbientValues
{
private static readonly AsyncLocal<Dictionary<int, string>> _current = new();

public static Dictionary<int, string> Current
{
get => _current.Value!;
set => _current.Value = value;
}
}

O exemplo acima armazena um Dictionary<int, string> normal em um async local e executa processamento paralelo sobre ele. Embora o risco seja óbvio nesse caso, é importante lembrar que async locals permitem que código arbitrário em threads arbitrárias acesse o ExecutionContext, e portanto, qualquer async local associado a esse contexto.

Isso significa que você deve assumir que os dados podem ser acessados concorrentemente e, portanto, precisam ser thread-safe.

BOM — Use ConcurrentDictionary<int, string> para garantir segurança em acesso concorrente.

class AmbientValues
{
private static readonly AsyncLocal<ConcurrentDictionary<int, string>> _current = new();

public static ConcurrentDictionary<int, string> Current
{
get => _current.Value!;
set => _current.Value = value;
}
}

Não deixe seu AsyncLocal<T> vazar

Async locals fluem automaticamente entre awaits e também podem ser capturados por qualquer API que chame explicitamente ExecutionContext.Capture. Isso pode causar vazamentos de memória em alguns cenários.

APIs comuns que capturam o ExecutionContext

Muitas APIs que executam callbacks de usuário capturam o contexto de execução para preservar async locals entre o momento do registro do callback e sua execução. Exemplos:

  • Timer
  • CancellationToken.Register
  • new FileSystemWatcher
  • SocketAsyncEventArgs
  • Task.Run
  • ThreadPool.QueueUserWorkItem

RUIM — Vazamento de ExecutionContext causando pressão de memória

using System.Collections.Concurrent;

// Cache singleton
var cache = new NumberCache(TimeSpan.FromHours(1));

var executionContext = ExecutionContext.Capture();

// Simula 10000 requisições concorrentes
Parallel.For(0, 10000, i =>
{
// Restaura o ExecutionContext inicial por "requisição"
ExecutionContext.Restore(executionContext!);

ChunkyObject.Current = new ChunkyObject();

cache.Add(i);
});

Console.WriteLine("Before GC: " + BytesAsString(GC.GetGCMemoryInfo().HeapSizeBytes));
Console.ReadLine();

GC.Collect();
GC.WaitForPendingFinalizers();

Console.WriteLine("After GC: " + BytesAsString(GC.GetGCMemoryInfo().HeapSizeBytes));
Console.ReadLine();

static string BytesAsString(long bytes)
{
string[] suffix = { "B", "KB", "MB", "GB", "TB" };
int i;
double doubleBytes = 0;

for (i = 0; bytes / 1024 > 0; i++, bytes /= 1024)
{
doubleBytes = bytes / 1024.0;
}

return string.Format("{0:0.00} {1}", doubleBytes, suffix[i]);
}

public class NumberCache
{
private readonly ConcurrentDictionary<int, CancellationTokenSource> _cache = new();
private TimeSpan _timeSpan;

public NumberCache(TimeSpan timeSpan)
{
_timeSpan = timeSpan;
}

public void Add(int key)
{
var cts = _cache.GetOrAdd(key, _ => new CancellationTokenSource());
// Remove entrada na expiração
cts.Token.Register((_, _) => _cache.TryRemove(key, out _), null);

// Inicia contagem regressiva
cts.CancelAfter(_timeSpan);
}
}

class ChunkyObject
{
private static readonly AsyncLocal<ChunkyObject?> _current = new();

// Armazena muitos dados (deveria ser coletado no Gen0)
private readonly string _data = new string('A', 1024 * 32);

public static ChunkyObject? Current
{
get => _current.Value;
set => _current.Value = value;
}

public string Data => _data;
}

Neste exemplo:

  • NumberCache é um singleton que armazena números por 1 hora.
  • ChunkyObject mantém uma string de 32 KB e é exposto via um async local.
  • Esse objeto deveria ser coletado pelo GC, mas não é — porque está sendo capturado implicitamente via CancellationToken.Register dentro do NumberCache.

Ou seja:

Ao invés de apenas armazenar o número e um CancellationTokenSource, estamos capturando e guardando todos os async locals ligados ao ExecutionContext atual — por 1 hora.

O resultado: pressão de memória e objetos que não são liberados no tempo esperado.

Em vez de apenas armazenar o número e um CancellationTokenSource, o código do exemplo está capturando e armazenando todos os async locals associados ao ExecutionContext atual por uma hora inteira.

Se você rodar esse exemplo localmente, verá algo parecido com:

Before GC: 654.65 MB
After GC: 659.68 MB

Ao inspecionar o heap, é possível perceber que existem 10.000 instâncias de ChunkyObject com strings armazenadas nelas. A cadeia de referência é:

CancellationTokenSource -> ExecutionContext -> AsyncLocalValueMap -> ChunkyObject -> string

Isso significa que, mesmo quando você acha que está só armazenando um número, na prática está segurando referências para todo o contexto de execução.


BOM — Usar CancellationToken.UnsafeRegister

Uma forma de evitar essa captura implícita do ExecutionContext e dos async locals é usar UnsafeRegister:

public class NumberCache
{
private readonly ConcurrentDictionary<int, CancellationTokenSource> _cache = new();
private TimeSpan _timeSpan;

public NumberCache(TimeSpan timeSpan)
{
_timeSpan = timeSpan;
}

public void Add(int key)
{
var cts = _cache.GetOrAdd(key, _ => new CancellationTokenSource());
// Remove entrada ao expirar, sem capturar ExecutionContext
cts.Token.UnsafeRegister((_, _) => _cache.TryRemove(key, out _), null);

// Inicia contagem regressiva
cts.CancelAfter(_timeSpan);
}
}

Após essa mudança, os números de memória ficam assim:

Before GC: 10.32 MB
After GC: 5.10 MB

No heap, não há captura de ExecutionContext — e, portanto, ChunkyObject não é mais retido na memória.


💡 NOTA: Você não tem controle sobre como as APIs armazenam o contexto de execução, mas sabendo disso, é possível minimizar vazamentos limpando a memória manualmente com a técnica mostrada na seção Criando Async Local.


Limpando AsyncLocals explicitamente

Uma forma de atenuar o problema é “anular” o valor logo após o uso:

using System.Collections.Concurrent;

// Cache singleton
var cache = new NumberCache(TimeSpan.FromHours(1));

var executionContext = ExecutionContext.Capture();

// Simula 10000 requisições concorrentes
Parallel.For(0, 10000, i =>
{
ExecutionContext.Restore(executionContext!);

ChunkyObject.Current = new ChunkyObject();

cache.Add(i);

// Limpa para liberar memória
ChunkyObject.Current = default;
});

class ChunkyObject
{
private static readonly AsyncLocal<StrongBox<ChunkyObject?>> _current = new();
private readonly string _data = new string('A', 1024 * 32);

public static ChunkyObject? Current
{
get => _current.Value?.Value;
set
{
var box = _current.Value;
if (box is not null)
{
// Anula valor em todos os ExecutionContexts que compartilham
box.Value = null;
}

if (value is not null)
{
_current.Value = new StrongBox<ChunkyObject?>(value);
}
}
}

public string Data => _data;
}

Isso reduz significativamente o uso de memória:

Before GC: 7.91 MB
After GC: 5.66 MB

Ainda existe o objeto StrongBox<ChunkyObject> mantido pelo contexto, mas o ChunkyObject em si é coletado.


Evite definir valores de AsyncLocal<T> fora de métodos assíncronos

Métodos assíncronos têm um comportamento especial: eles garantem que valores definidos em async locals não se propaguem para fora do método quando ele termina.

RUIM — Definir valor em método síncrono faz o estado “vazar”:

var local = new AsyncLocal<int>();
MethodA();
Console.WriteLine(local.Value); // 2

void MethodA()
{
local.Value = 1;
MethodB();
Console.WriteLine(local.Value); // 2
}

void MethodB()
{
local.Value = 2;
Console.WriteLine(local.Value); // 2
}

BOM — Definir valor dentro de método assíncrono isola o estado:

var local = new AsyncLocal<int>();
await MethodA();
Console.WriteLine(local.Value); // 0

async Task MethodA()
{
local.Value = 1;
await MethodB();
Console.WriteLine(local.Value); // 1
}

async Task MethodB()
{
local.Value = 2;
Console.WriteLine(local.Value); // 2
}

Nesse caso, o valor é restaurado automaticamente ao sair do método.

Aqui está a tradução completa do trecho que você enviou, mantendo o formato e comentários:


RUIM — Evite definir valores de async locals fora de métodos assíncronos:

var local = new AsyncLocal<int>();
MethodA();
Console.WriteLine(local.Value);

void MethodA()
{
local.Value = 1;
MethodB();
Console.WriteLine(local.Value);
}

void MethodB()
{
local.Value = 2;
Console.WriteLine(local.Value);
}

O código acima imprime 2, 2, 2. As mutações do execution context estão sendo propagadas para fora do método. Isso pode causar um comportamento extremamente confuso e bugs difíceis de rastrear.


BOM — Defina async locals em métodos assíncronos:

var local = new AsyncLocal<int>();
await MethodA();
Console.WriteLine(local.Value);

async Task MethodA()
{
local.Value = 1;
await MethodB();
Console.WriteLine(local.Value);
}

async Task MethodB()
{
local.Value = 2;
Console.WriteLine(local.Value);
}

O código acima imprime 2, 1, 0. Isso acontece porque o método assíncrono restaura o execution context original ao sair.


ConfigureAwait

success

.ConfigureAwait(false) fala para retornar qualquer thread, agora com true ele tenta pegar a thread que estava executando o processo antes para manter contexto. Dificilmente em API vai importar o contexto. Thread de UI é um caso que usaria o true


Cenários

A explicação acima tenta fornecer uma orientação geral, mas não cobre totalmente as situações reais que levam à escrita de código ruim. A seguir, alguns exemplos concretos simplificados de problemas comuns.


Callbacks de Timer

RUIM — O callback de Timer retorna void, mas temos trabalho assíncrono. Aqui foi usado async void, o que pode causar a queda do processo se ocorrer uma exceção.

public class Pinger
{
private readonly Timer _timer;
private readonly HttpClient _client;

public Pinger(HttpClient client)
{
_client = client;
_timer = new Timer(Heartbeat, null, 1000, 1000);
}

public async void Heartbeat(object state)
{
await _client.GetAsync("http://mybackend/api/ping");
}
}

RUIM — Tentativa de bloquear o callback do Timer. Isso pode causar thread-pool starvation e é um caso de sync over async.

public class Pinger
{
private readonly Timer _timer;
private readonly HttpClient _client;

public Pinger(HttpClient client)
{
_client = client;
_timer = new Timer(Heartbeat, null, 1000, 1000);
}

public void Heartbeat(object state)
{
_client.GetAsync("http://mybackend/api/ping").GetAwaiter().GetResult();
}
}

BOM — Método baseado em async Task, descartando o Task no callback do Timer. Se falhar, não derruba o processo; dispara o evento TaskScheduler.UnobservedTaskException.

public class Pinger
{
private readonly Timer _timer;
private readonly HttpClient _client;

public Pinger(HttpClient client)
{
_client = client;
_timer = new Timer(Heartbeat, null, 1000, 1000);
}

public void Heartbeat(object state)
{
_ = DoAsyncPing();
}

private async Task DoAsyncPing()
{
await _client.GetAsync("http://mybackend/api/ping");
}
}

BOM — Usar PeriodicTimer do .NET 6:

public class Pinger : IDisposable
{
private readonly PeriodicTimer _timer;
private readonly HttpClient _client;

public Pinger(HttpClient client)
{
_client = client;
_timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
_ = Task.Run(DoAsyncPings);
}

public void Dispose()
{
_timer.Dispose();
}

private async Task DoAsyncPings()
{
while (await _timer.WaitForNextTickAsync())
{
// TODO: Tratar exceções
await _client.GetAsync("http://mybackend/api/ping");
}
}
}

Delegados async void implícitos

Imagine uma BackgroundQueue com FireAndForget que recebe um callback a ser executado futuramente.

RUIM — Força quem chama a bloquear ou usar async void.

public class BackgroundQueue
{
public static void FireAndForget(Action action) { }
}

RUIM — O código abaixo cria implicitamente um método async void.

public class Program
{
public void Main(string[] args)
{
var httpClient = new HttpClient();
BackgroundQueue.FireAndForget(async () =>
{
await httpClient.GetAsync("http://pinger/api/1");
});

Console.ReadLine();
}
}

BOM — Fornecer sobrecargas para callbacks síncronos e assíncronos.

public class BackgroundQueue
{
public static void FireAndForget(Action action) { }
public static void FireAndForget(Func<Task> action) { }
}

ConcurrentDictionary.GetOrAdd

É comum cachear o resultado de uma operação assíncrona usando ConcurrentDictionary. GetOrAdd facilita pegar ou adicionar um item, mas a função é síncrona — pode ser tentador usar .Result, causando bloqueio.

RUIM — Pode causar thread-pool starvation se o dado não estiver no cache.

public class PersonController : Controller
{
private AppDbContext _db;
private static ConcurrentDictionary<int, Person> _cache = new();

public PersonController(AppDbContext db)
{
_db = db;
}

public IActionResult Get(int id)
{
var person = _cache.GetOrAdd(id, (key) => _db.People.FindAsync(key).Result);
return Ok(person);
}
}

BOM — Guardar o Task em vez do resultado evita bloqueio.

⚠️ Atenção: ConcurrentDictionary.GetOrAdd, em acessos concorrentes, pode executar o delegate mais de uma vez.

public class PersonController : Controller
{
private AppDbContext _db;
private static ConcurrentDictionary<int, Task<Person>> _cache = new();

public PersonController(AppDbContext db)
{
_db = db;
}

public async Task<IActionResult> Get(int id)
{
var person = await _cache.GetOrAdd(id, (key) => _db.People.FindAsync(key));
return Ok(person);
}
}

BOM — Usar async lazy pattern evita múltiplas execuções desnecessárias do delegate.

public class PersonController : Controller
{
private AppDbContext _db;

// This cache needs expiration
private static ConcurrentDictionary<int, AsyncLazy<Person>> _cache = new ConcurrentDictionary<int, AsyncLazy<Person>>();

public PersonController(AppDbContext db)
{
_db = db;
}

public async Task<IActionResult> Get(int id)
{
var person = await _cache.GetOrAdd(id, (key) => new AsyncLazy<Person>(() => _db.People.FindAsync(key))).Value;
return Ok(person);
}

private class AsyncLazy<T> : Lazy<Task<T>>
{
public AsyncLazy(Func<Task<T>> valueFactory) : base(valueFactory)
{
}
}
}

Construtores

Construtores são síncronos. Se você precisar inicializar alguma lógica que possa ser assíncrona, existem alguns padrões para lidar com isso.

Aqui está um exemplo usando uma API de cliente que precisa se conectar de forma assíncrona antes de ser utilizada:

public interface IRemoteConnectionFactory
{
Task<IRemoteConnection> ConnectAsync();
}

public interface IRemoteConnection
{
Task PublishAsync(string channel, string message);
Task DisposeAsync();
}

RUIM — Este exemplo usa Task.Result para obter a conexão no construtor. Isso pode levar a thread-pool starvation e deadlocks.

public class Service : IService
{
private readonly IRemoteConnection _connection;

public Service(IRemoteConnectionFactory connectionFactory)
{
_connection = connectionFactory.ConnectAsync().Result;
}
}

BOM — Essa implementação usa o padrão de fábrica estática para permitir a construção assíncrona:

public class Service : IService
{
private readonly IRemoteConnection _connection;

private Service(IRemoteConnection connection)
{
_connection = connection;
}

public static async Task<Service> CreateAsync(IRemoteConnectionFactory connectionFactory)
{
return new Service(await connectionFactory.ConnectAsync());
}
}

WindowsIdentity.RunImpersonated

Essa API executa a ação especificada como a identidade do Windows impersonada. Uma versão assíncrona do callback foi introduzida no .NET 5.0.

RUIM — Este exemplo tenta executar a consulta de forma assíncrona e depois aguardar o resultado fora da chamada para RunImpersonated. Isso lançará uma exceção, pois a consulta pode estar sendo executada fora do contexto de impersonation.

public async Task<IEnumerable<Product>> GetDataImpersonatedAsync(SafeAccessTokenHandle safeAccessTokenHandle)
{
Task<IEnumerable<Product>> products = null;
WindowsIdentity.RunImpersonated(
safeAccessTokenHandle,
context =>
{
products = _db.QueryAsync("SELECT Name from Products");
});
return await products;
}

RUIM — Este exemplo usa Task.Result para executar a consulta de forma síncrona (sync over async). Isso pode levar a thread-pool starvation e deadlocks.

public IEnumerable<Product> GetDataImpersonated(SafeAccessTokenHandle safeAccessTokenHandle)
{
return WindowsIdentity.RunImpersonated(
safeAccessTokenHandle,
context => _db.QueryAsync("SELECT Name from Products").Result);
}

BOM — Este exemplo aguarda (await) o resultado de RunImpersonated (o delegate aqui é Func<Task<IEnumerable<Product>>>). Essa é a prática recomendada em frameworks anteriores ao .NET 5.0.

public async Task<IEnumerable<Product>> GetDataImpersonatedAsync(SafeAccessTokenHandle safeAccessTokenHandle)
{
return await WindowsIdentity.RunImpersonated(
safeAccessTokenHandle,
context => _db.QueryAsync("SELECT Name from Products"));
}

BOM — Este exemplo usa a função assíncrona RunImpersonatedAsync e aguarda seu resultado. Disponível no .NET 5.0 ou mais recente.

public async Task<IEnumerable<Product>> GetDataImpersonatedAsync(SafeAccessTokenHandle safeAccessTokenHandle)
{
return await WindowsIdentity.RunImpersonatedAsync(
safeAccessTokenHandle,
context => _db.QueryAsync("SELECT Name from Products"));
}