Nota
O acesso a esta página requer autorização. Pode tentar iniciar sessão ou alterar os diretórios.
O acesso a esta página requer autorização. Pode tentar alterar os diretórios.
Frameworks de interface, como Windows Forms, WPF e .NET MAUI, instalam um SynchronizationContext na sua thread de interface. Quando fazes await uma tarefa nesses ambientes, a continuação é automaticamente publicada de volta para o tópico da interface. As aplicações de consola não instalam um SynchronizationContext, o que significa await que as continuações correm no pool de threads. Este artigo explica as consequências e mostra como construir uma bomba de mensagens single-threaded quando precisar de uma.
Comportamento padrão numa aplicação de consola
Numa aplicação de consola, SynchronizationContext.Current devolve null. Quando um método resulta em um await, a continuação é executada na thread do pool de threads disponível.
static void DefaultBehaviorDemo()
{
DemoAsync().GetAwaiter().GetResult();
}
static async Task DemoAsync()
{
var d = new Dictionary<int, int>();
for (int i = 0; i < 10_000; i++)
{
int id = Thread.CurrentThread.ManagedThreadId;
d[id] = d.TryGetValue(id, out int count) ? count + 1 : 1;
await Task.Yield();
}
foreach (var pair in d)
Console.WriteLine(pair);
}
Saída representativa da execução deste programa:
[1, 1]
[3, 2687]
[4, 2399]
[5, 2397]
[6, 2516]
O thread 1 (o thread principal) aparece apenas uma vez, durante a primeira iteração síncrona antes await Task.Yield() de suspender o método. Todas as iterações subsequentes executam em threads do pool de threads.
Pontos de entrada assíncronos modernos
A partir de C# 7.1, pode declarar Main como async Task ou async Task<int>. Em C# 9 e posteriores, pode usar instruções de topo com await diretamente:
// Top-level statements (C# 9+)
await DemoAsync();
// async Task Main (C# 7.1+)
static async Task Main()
{
await DemoAsync();
}
Estes pontos de entrada não instalam um SynchronizationContext. O runtime gera um bootstrap que chama o seu método assíncrono e espera pelo retorno de Task, de maneira similar a uma chamada para .GetAwaiter().GetResult(). As continuações continuam a ser executadas no conjunto de threads.
Quando precisas de afinidade com fios
Para muitas aplicações de consola, executar continuações no pool de threads é aceitável. No entanto, alguns cenários exigem que todas as continuações corram num único thread:
- Execução serializada: Múltiplas operações assíncronas concorrentes partilham o estado sem bloqueios, executando as suas continuações no mesmo thread.
- Requisitos de biblioteca: Algumas bibliotecas ou objetos COM requerem afinidade com um determinado thread.
- Testes unitários: Os frameworks de teste podem necessitar de execução determinística e de um único thread de código assíncrono.
Construir um SynchronizationContext de thread única
Para executar todas as continuações num só tópico, precisas de duas coisas:
- A SynchronizationContext cujo Post método faz filas para uma coleção thread-safe.
- Um ciclo de pump de mensagens que processa essa fila na thread de destino.
O contexto personalizado
O contexto usa a BlockingCollection<T> para coordenar produtores (as continuações assíncronas) e um consumidor (o ciclo de bombeamento):
sealed class SingleThreadSynchronizationContext : SynchronizationContext
{
private readonly
BlockingCollection<KeyValuePair<SendOrPostCallback, object?>> _queue = new();
public override void Post(SendOrPostCallback d, object? state)
{
_queue.Add(new KeyValuePair<SendOrPostCallback, object?>(d, state));
}
public void RunOnCurrentThread()
{
while (_queue.TryTake(out KeyValuePair<SendOrPostCallback, object?> workItem,
Timeout.Infinite))
{
workItem.Key(workItem.Value);
}
}
public void Complete() => _queue.CompleteAdding();
}
Class SingleThreadSynchronizationContext
Inherits SynchronizationContext
Private ReadOnly _queue As New _
BlockingCollection(Of KeyValuePair(Of SendOrPostCallback, Object))()
Public Overrides Sub Post(d As SendOrPostCallback, state As Object)
_queue.Add(New KeyValuePair(Of SendOrPostCallback, Object)(d, state))
End Sub
Public Sub RunOnCurrentThread()
Dim workItem As New KeyValuePair(Of SendOrPostCallback, Object)(Nothing, Nothing)
While _queue.TryTake(workItem, Timeout.Infinite)
workItem.Key.Invoke(workItem.Value)
End While
End Sub
Public Sub Complete()
_queue.CompleteAdding()
End Sub
End Class
O método AsyncPump.Run
AsyncPump.Run instala o contexto personalizado, invoca o método assíncrono e faz continuações no thread que chama até que o método seja concluído:
static class AsyncPump
{
public static void Run(Func<Task> func)
{
SynchronizationContext? prevCtx = SynchronizationContext.Current;
try
{
var syncCtx = new SingleThreadSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(syncCtx);
Task t;
try
{
t = func();
}
catch
{
syncCtx.Complete();
throw;
}
t.ContinueWith(
_ => syncCtx.Complete(), TaskScheduler.Default);
syncCtx.RunOnCurrentThread();
t.GetAwaiter().GetResult();
}
finally
{
SynchronizationContext.SetSynchronizationContext(prevCtx);
}
}
Class AsyncPump
Public Shared Sub Run(func As Func(Of Task))
Dim prevCtx As SynchronizationContext = SynchronizationContext.Current
Try
Dim syncCtx As New SingleThreadSynchronizationContext()
SynchronizationContext.SetSynchronizationContext(syncCtx)
Dim t As Task
Try
t = func()
Catch
syncCtx.Complete()
Throw
End Try
t.ContinueWith(
Sub(unused) syncCtx.Complete(), TaskScheduler.Default)
syncCtx.RunOnCurrentThread()
t.GetAwaiter().GetResult()
Finally
SynchronizationContext.SetSynchronizationContext(prevCtx)
End Try
End Sub
Veja isso em ação
Substitua a chamada por defeito por AsyncPump.Run:
static void AsyncPumpDemo()
{
AsyncPump.Run(async () =>
{
var d = new Dictionary<int, int>();
for (int i = 0; i < 10_000; i++)
{
int id = Thread.CurrentThread.ManagedThreadId;
d[id] = d.TryGetValue(id, out int count) ? count + 1 : 1;
await Task.Yield();
}
foreach (var pair in d)
Console.WriteLine(pair);
});
}
Sub AsyncPumpDemo()
AsyncPump.Run(
Async Function() As Task
Dim d As New Dictionary(Of Integer, Integer)()
For i As Integer = 0 To 9999
Dim id As Integer = Thread.CurrentThread.ManagedThreadId
Dim count As Integer
If d.TryGetValue(id, count) Then
d(id) = count + 1
Else
d(id) = 1
End If
Await Task.Yield()
Next
For Each pair In d
Console.WriteLine(pair)
Next
End Function)
End Sub
Output:
[1, 10000]
O ID específico do thread pode variar consoante o tempo de execução e a plataforma, mas o resultado chave é que todas as 10.000 iterações correm num único thread: o thread principal.
Lidar com métodos assíncronos sem retorno
A Func<Task> sobrecarga acompanha a conclusão através do retorno Task. Os métodos assíncronos void não devolvem uma tarefa; em vez disso, notificam o atual SynchronizationContext através de OperationStarted() e OperationCompleted(). Para suportar métodos assíncronos void , estenda o contexto para acompanhar operações pendentes:
public static void Run(Action asyncMethod)
{
SynchronizationContext? prevCtx = SynchronizationContext.Current;
try
{
var syncCtx = new AsyncVoidSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(syncCtx);
Exception? caughtException = null;
syncCtx.OperationStarted();
try
{
asyncMethod();
}
catch (Exception ex)
{
caughtException = ex;
syncCtx.Complete();
}
finally
{
syncCtx.OperationCompleted();
}
syncCtx.RunOnCurrentThread();
if (caughtException is not null)
{
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(caughtException).Throw();
}
}
finally
{
SynchronizationContext.SetSynchronizationContext(prevCtx);
}
}
}
sealed class AsyncVoidSynchronizationContext : SynchronizationContext
{
private readonly
BlockingCollection<KeyValuePair<SendOrPostCallback, object?>> _queue = new();
private int _operationCount;
public override void Post(SendOrPostCallback d, object? state)
{
_queue.Add(new KeyValuePair<SendOrPostCallback, object?>(d, state));
}
public override void OperationStarted() =>
Interlocked.Increment(ref _operationCount);
public override void OperationCompleted()
{
if (Interlocked.Decrement(ref _operationCount) == 0)
Complete();
}
public void RunOnCurrentThread()
{
while (_queue.TryTake(out KeyValuePair<SendOrPostCallback, object?> workItem,
Timeout.Infinite))
{
workItem.Key(workItem.Value);
}
}
public void Complete() => _queue.CompleteAdding();
}
Public Shared Sub Run(asyncMethod As Action)
Dim prevCtx As SynchronizationContext = SynchronizationContext.Current
Try
Dim syncCtx As New AsyncVoidSynchronizationContext()
SynchronizationContext.SetSynchronizationContext(syncCtx)
syncCtx.OperationStarted()
Try
asyncMethod()
Catch
syncCtx.Complete()
Throw
Finally
syncCtx.OperationCompleted()
End Try
syncCtx.RunOnCurrentThread()
Finally
SynchronizationContext.SetSynchronizationContext(prevCtx)
End Try
End Sub
End Class
Class AsyncVoidSynchronizationContext
Inherits SynchronizationContext
Private ReadOnly _queue As New _
BlockingCollection(Of KeyValuePair(Of SendOrPostCallback, Object))()
Private _operationCount As Integer
Public Overrides Sub Post(d As SendOrPostCallback, state As Object)
_queue.Add(New KeyValuePair(Of SendOrPostCallback, Object)(d, state))
End Sub
Public Overrides Sub OperationStarted()
Interlocked.Increment(_operationCount)
End Sub
Public Overrides Sub OperationCompleted()
If Interlocked.Decrement(_operationCount) = 0 Then
Complete()
End If
End Sub
Public Sub RunOnCurrentThread()
Dim workItem As New KeyValuePair(Of SendOrPostCallback, Object)(Nothing, Nothing)
While _queue.TryTake(workItem, Timeout.Infinite)
workItem.Key.Invoke(workItem.Value)
End While
End Sub
Public Sub Complete()
_queue.CompleteAdding()
End Sub
End Class
Com o rastreio de operação ativado, a bomba só sai quando todos os métodos assíncronos void pendentes estão concluídos, não apenas a tarefa de topo.
Considerações práticas
-
Risco de deadlock: Se o código a correr dentro
AsyncPump.Rundos blocos sincrónicos (por exemplo, ao chamar.Resultou.Wait()numa tarefa cuja continuação deve ser posta de volta para a bomba), o thread da bomba não consegue processar essa continuação. O resultado é um impasse. O mesmo problema é descrito em encapsuladores síncronos para métodos assíncronos. - Desempenho: Uma bomba mono-thread limita o débito a uma thread. Use esta abordagem apenas quando a afinidade a encadeamentos for importante.
-
Multiplataforma: A implementação aqui mostrada utiliza apenas tipos dos namespaces
System.Collections.ConcurrenteSystem.Threading. Funciona em todas as plataformas que o .NET suporta.