Hinweis
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, sich anzumelden oder das Verzeichnis zu wechseln.
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, das Verzeichnis zu wechseln.
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:
- Ein SynchronizationContext, dessen Post-Methode Arbeit in eine threadsichere Sammlung einreiht.
- 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.Runläuft, synchron blockiert (z. B. durch Aufruf von.Resultoder.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
AsyncPumphier gezeigte Implementierung verwendet nur Typen aus denSystem.Collections.ConcurrentundSystem.ThreadingNamensräumen. Es funktioniert auf allen Plattformen, die .NET unterstützt.