SynchronizationContext und Konsolen-Apps

UI-Frameworks wie Windows Forms, WPF und .NET MAUI installieren ein SynchronizationContext auf ihrem UI-Thread. Wenn Sie eine Aufgabe in diesen Umgebungen await wird die Fortsetzung automatisch zurück auf den UI-Thread gepostet. Konsolen-Apps installieren keine SynchronizationContext, und daher laufen await-Fortsetzungen im Threadpool. In diesem Artikel werden die Folgen erläutert und gezeigt, wie Sie bei Bedarf eine Singlethread-Nachrichtenpumpe erstellen.

Standardverhalten in einer Konsolen-App

In einer Konsolen-App gibt SynchronizationContext.Currentnull zurück. Wenn eine Methode an einem await pausiert, läuft die Fortsetzung auf einem beliebigen verfügbaren Thread-Pool-Thread:

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);
}

Repräsentative Ausgabe beim Ausführen dieses Programms:

[1, 1]
[3, 2687]
[4, 2399]
[5, 2397]
[6, 2516]

Thread 1 (der Hauptthread) wird nur einmal angezeigt, während der ersten synchronen Iteration, bevor await Task.Yield() die Methode angehalten wird. Alle nachfolgenden Iterationen, werden auf Threadpool-Threads ausgeführt.

Moderne asynchrone Einstiegspunkte

Ab C# 7.1 können Sie Main als async Task oder async Task<int> deklarieren. In C# 9 und höher können Sie Top-Level-Anweisungen direkt mit await verwenden:

// Top-level statements (C# 9+)
await DemoAsync();
// async Task Main (C# 7.1+)
static async Task Main()
{
    await DemoAsync();
}

Diese Einstiegspunkte installieren keine SynchronizationContext. Die Laufzeit generiert einen Bootstrap, der Ihre asynchrone Methode aufruft und auf das zurückgegebene Task blockiert, ähnlich wie beim Aufruf von .GetAwaiter().GetResult(). Fortsetzungen werden weiterhin im Threadpool ausgeführt.

Wenn Sie Threadaffinität benötigen

Bei vielen Konsolenanwendungen ist das Ausführen von Fortsetzungen im Threadpool völlig in Ordnung. Einige Szenarien erfordern jedoch, dass alle Fortsetzungen in einem einzelnen Thread ausgeführt werden:

  • Serialisierte Ausführung: Mehrere gleichzeitige asynchrone Vorgänge teilen den Zustand ohne Sperren, indem sie ihre Fortsetzungen im selben Thread ausführen.
  • Bibliotheksanforderungen: Für einige Bibliotheken oder COM-Objekte ist eine Affinität zu einem bestimmten Thread erforderlich.
  • Komponententests: Testframeworks benötigen möglicherweise deterministische, Singlethread-Ausführung von asynchronem Code.

Erstellen Sie einen Single-Thread-Synchronisierungskontext

Um alle Fortsetzungen in einem Thread auszuführen, benötigen Sie zwei Dinge:

  1. Ein SynchronizationContext, dessen Post-Methode Arbeit in eine threadsichere Sammlung einreiht.
  2. Eine Nachrichtenpumpenschleife, die diese Warteschlange im Zielthread verarbeitet.

Der benutzerdefinierte Kontext

Der Kontext verwendet einen BlockingCollection<T>, um Produzenten (die asynchronen Fortsetzungen) und einen Verbraucher (die Pumpschleife) zu koordinieren:

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

Die AsyncPump.Run-Methode

AsyncPump.Run installiert den benutzerdefinierten Kontext, ruft die asynchrone Methode auf und pumpt Fortsetzungen auf dem aufrufenden Thread, bis die Methode abgeschlossen ist.

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

In Aktion erleben

Ersetzen Sie den Standardanruf durch 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

Ausgabe:

[1, 10000]

Die spezifische Thread-ID kann je nach Laufzeit und Plattform unterschiedlich sein, aber das Hauptergebnis besteht darin, dass alle 10.000 Iterationen in einem einzelnen Thread ausgeführt werden: dem Hauptthread.

Behandeln asynchroner Void-Methoden

Die Func<Task>-Überladung verfolgt die Fertigstellung über das zurückgegebene Task. Asynchrone void Methoden geben keine Aufgabe zurück. Stattdessen benachrichtigen sie den aktuellen SynchronizationContext Vorgang durch OperationStarted() und OperationCompleted(). Um asynchrone void Methoden zu unterstützen, erweitern Sie den Kontext, um ausstehende Vorgänge nachzuverfolgen:

    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

Wenn die Vorgangsverfolgung aktiviert ist, wird die Pump erst beendet, wenn alle ausstehenden asynchronen void-Methoden abgeschlossen sind, nicht nur die oberste Aufgabe.

Praktische Überlegungen

  • Deadlock-Risiko: Wenn Code, der innerhalb von AsyncPump.Run läuft, synchron blockiert (z. B. durch Aufruf von .Result oder .Wait() auf einer Aufgabe, deren Fortsetzung zurück an die Pump posten muss), kann der Pump-Thread diese Fortsetzung nicht verarbeiten. Das Ergebnis ist ein Deadlock. Das gleiche Problem wird in synchronen Wrappern für asynchrone Methoden beschrieben.
  • Leistung: Eine Singlethreadpumpe begrenzt den Durchsatz auf einen Thread. Verwenden Sie diesen Ansatz nur, wenn Threadaffinität wichtig ist.
  • Plattformübergreifend: Die AsyncPump hier gezeigte Implementierung verwendet nur Typen aus den System.Collections.Concurrent und System.Threading Namensräumen. Es funktioniert auf allen Plattformen, die .NET unterstützt.

Siehe auch