Guia Async
- Guia Async
Table of contents
- Asynchronous Programming
- Asynchrony is viral
- Async void
- Prefer Task.FromResult over Task.Run for pre-computed or trivially computed data
- Avoid using Task.Run for long-running work that blocks the thread
- Avoid using Task.Result and Task.Wait
- Prefer await over ContinueWith
- Always create TaskCompletionSource<T> with TaskCreationOptions.RunContinuationsAsynchronously
- Always dispose CancellationTokenSource(s) used for timeouts
- Always flow CancellationToken(s) to APIs that take a CancellationToken
- Cancelling uncancellable operations
- Always call FlushAsync on StreamWriter(s) or Stream(s) before calling Dispose
- Prefer async/await over directly returning Task
- AsyncLocal<T>
- ConfigureAwait
- Scenarios
- Timer callbacks
- Implicit async void delegates
- ConcurrentDictionary.GetOrAdd
- Constructors
- WindowsIdentity.RunImpersonated
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
awaite APIs do TPL comoTask.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 umaTaskcomo umAggregateException.
💡 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:
- Uma operação assíncrona é iniciada.
- A thread que chamou a operação é bloqueada aguardando sua conclusão.
- 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:
TaskCreationOptions.RunContinuationsAsynchronously— usado na criação deTaskCompletionSource<T>.TaskContinuationOptions.RunContinuationsAsynchronously— usado para configurar continuações comContinueWith. Não confunda o uso.
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
usingou 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
Taskretornada, 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:
- Não descartável (
IDisposable) - 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:
TimerCancellationToken.Registernew FileSystemWatcherSocketAsyncEventArgsTask.RunThreadPool.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.ChunkyObjectmanté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.Registerdentro doNumberCache.
Ou seja:
Ao invés de apenas armazenar o número e um
CancellationTokenSource, estamos capturando e guardando todos os async locals ligados aoExecutionContextatual — 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
}