ExecutionContext und SynchronizationContext

Wenn Sie mit async und await arbeiten, spielen zwei Kontexttypen wichtige, aber sehr unterschiedliche Rollen: ExecutionContext und SynchronizationContext. Sie lernen, wie jeder funktioniert, wie jeder mit async/await interagiert, und warum SynchronizationContext.Current nicht über die Await-Punkte fließt.

Was ist ExecutionContext?

ExecutionContext ist ein Container für den Umgebungszustand, der mit dem logischen Kontrollfluss Ihres Programms fließt. In einer synchronen Welt leben Umgebungsinformationen im threadlokalen Speicher (TLS), und der gesamte Code, der in einem bestimmten Thread ausgeführt wird, sieht diese Daten. In einer asynchronen Welt kann ein logischer Vorgang in einem Thread gestartet, angehalten und auf einem anderen Thread fortgesetzt werden. Thread-lokale Daten folgen nicht automatisch - ExecutionContext sorgt dafür, dass sie folgen.

So fließt ExecutionContext

Erfassen ExecutionContext mithilfe von ExecutionContext.Capture(). Stellen Sie sie während der Ausführung eines Delegaten wieder her, indem Sie ExecutionContext.Run verwenden:

static void ExecutionContextCaptureDemo()
{
    // Capture the current ExecutionContext
    ExecutionContext? ec = ExecutionContext.Capture();

    // Later, run a delegate within that captured context
    if (ec is not null)
    {
        ExecutionContext.Run(ec, _ =>
        {
            // Code here sees the ambient state from the point of capture
            Console.WriteLine("Running inside captured ExecutionContext.");
        }, null);
    }
}
Sub ExecutionContextCaptureExample()
    ' Capture the current ExecutionContext
    Dim ec As ExecutionContext = ExecutionContext.Capture()

    ' Later, run a delegate within that captured context
    If ec IsNot Nothing Then
        ExecutionContext.Run(ec,
            Sub(state)
                ' Code here sees the ambient state from the point of capture
                Console.WriteLine("Running inside captured ExecutionContext.")
            End Sub, Nothing)
    End If
End Sub

Alle asynchronen APIs in .NET, die einen Fork ausführen – Run, QueueUserWorkItem, BeginRead und andere – erfassen ExecutionContext und verwenden den gespeicherten Kontext, wenn Sie Ihren Callback aufrufen. Dieser Prozess der Erfassung des Zustands in einem Thread und das Wiederherstellen des Zustands auf einem anderen ist das, was "flowing ExecutionContext" bedeutet.

Was ist SynchronizationContext?

SynchronizationContext ist eine Abstraktion, die eine Zielumgebung darstellt, in der Sie arbeiten möchten. Verschiedene Benutzeroberflächenframeworks bieten eigene Implementierungen:

  • Windows Forms stellt WindowsFormsSynchronizationContext bereit, die Post außer Kraft setzt, um Control.BeginInvoke aufzurufen.
  • WPF stellt DispatcherSynchronizationContext bereit, die Post außer Kraft setzt, um Dispatcher.BeginInvoke aufzurufen.
  • ASP.NET (im .NET Framework) stellte einen eigenen Kontext bereit, der sicherstellte, dass HttpContext.Current verfügbar war.

Anstatt framework-spezifische Marshaling-APIs zu verwenden, können Sie mit SynchronizationContext Komponenten schreiben, die über Benutzeroberflächen-Frameworks hinweg funktionieren.

static class SyncContextExample
{
    public static void DoWork()
    {
        // Capture the current SynchronizationContext
        SynchronizationContext? sc = SynchronizationContext.Current;

        ThreadPool.QueueUserWorkItem(_ =>
        {
            // ... do work on the ThreadPool ...

            if (sc is not null)
            {
                sc.Post(_ =>
                {
                    // This runs on the original context (e.g. UI thread)
                    Console.WriteLine("Back on the original context.");
                }, null);
            }
        });
    }
}
Class SyncContextExample
    Public Shared Sub DoWork()
        ' Install a custom SynchronizationContext for demonstration
        Dim customContext As New SimpleSynchronizationContext()
        SynchronizationContext.SetSynchronizationContext(customContext)

        ' Capture the current SynchronizationContext
        Dim sc As SynchronizationContext = SynchronizationContext.Current

        ThreadPool.QueueUserWorkItem(
            Sub(state)
                ' ... do work on the ThreadPool ...

                If sc IsNot Nothing Then
                    sc.Post(
                        Sub(s)
                            ' This runs on the original context (e.g. UI thread)
                            Console.WriteLine("Back on the original context.")
                        End Sub, Nothing)
                Else
                    Console.WriteLine("No SynchronizationContext was captured.")
                End If
            End Sub)
    End Sub
End Class

' A minimal SynchronizationContext for demonstration purposes
Class SimpleSynchronizationContext
    Inherits SynchronizationContext

    Public Overrides Sub Post(d As SendOrPostCallback, state As Object)
        ' Queue the callback to run on a thread pool thread
        ThreadPool.QueueUserWorkItem(
            Sub(s)
                d(state)
            End Sub)
    End Sub
End Class

Ressourcensynchronisierung

Wenn Sie ein SynchronizationContext erfassen, lesen Sie die Referenz aus SynchronizationContext.Current und speichern sie zur späteren Verwendung. Dann rufen Sie Post mit der erfassten Referenz auf, um die Arbeit in dieser Umgebung zu planen.

Flowing ExecutionContext vs. Verwendung des SynchronizationContext

Obwohl beide Mechanismen das Erfassen des Zustands aus einem Thread umfassen, dienen sie unterschiedlichen Zwecken:

  • Flowing ExecutionContext bedeutet, dass der Umgebungszustand erfasst und derselbe Zustand während der Ausführung eines Delegaten wiederhergestellt wird. Der Delegat wird dort ausgeführt, wo er ausgeführt wird – der Zustand folgt ihm.
  • Die Verwendung von SynchronizationContext bedeutet, dass ein Schedulingziel erfasst und verwendet wird, um zu bestimmen, wo ein Delegat ausgeführt wird. Der erfasste Kontext steuert, wo der Delegat ausgeführt wird.

Kurz gesagt: ExecutionContext Antwort : "Welche Umgebung sollte sichtbar sein?" und SynchronizationContext antwortt auf "Wo sollte der Code ausgeführt werden?"

Wie async/await mit verschiedenen Kontexten interagiert

Die async/await Infrastruktur interagiert automatisch mit beiden Kontexten, aber auf unterschiedliche Weise.

ExecutionContext wird immer weitergegeben

Immer wenn ein await eine Methode unterbricht (weil der IsCompleted des Awaiters false zurückgibt), erfasst die Infrastruktur einen ExecutionContext. Wenn die Methode fortgesetzt wird, wird die Fortsetzung im erfassten Kontext ausgeführt. Dieses Verhalten ist in die asynchronen Methoden-Builder (z. B. AsyncTaskMethodBuilder) integriert und gilt unabhängig davon, welche Art von Awaitable Sie verwenden.

SuppressFlow() existiert, aber es ist kein für await spezifischer Schalter wie ConfigureAwait(false). Unterdrückt die Erfassung von ExecutionContext für Arbeit, die Sie während der aktiven Unterdrückung in die Warteschlange einreihen. Es stellt keine per-awaitProgramming-Model-Option bereit, die den Async-Methoden-Buildern mitteilt, die Wiederherstellung des erfassten ExecutionContext für eine Fortsetzung zu überspringen. Dieses Design ist beabsichtigt, da es sich um ExecutionContext Unterstützung auf Infrastrukturebene handelt, die threadlokale Semantik in einer asynchronen Welt simuliert und die meisten Entwickler nie darüber nachdenken müssen.

Task awaiters capture SynchronizationContext

Die Awaiter für Task und Task<TResult> enthalten Unterstützung für SynchronizationContext. Die asynchronen Methoden-Generatoren enthalten diese Unterstützung nicht.

Wenn Sie await eine Aufgabe ausführen:

  1. Der Awaiter überprüft SynchronizationContext.Current.
  2. Wenn ein Kontext vorhanden ist, erfasst der Awaiter ihn.
  3. Wenn die Aufgabe abgeschlossen ist, wird die Fortsetzung wieder in den erfassten Kontext zurückgesendet, anstatt auf dem abschließenden Thread oder im Threadpool ausgeführt zu werden.

Mit diesem Verhalten bringt await Sie "dorthin zurück, wo Sie waren". Zum Beispiel die Fortsetzung auf dem UI-Thread in einer Desktop-Anwendung.

ConfigureAwait steuert die SynchronizationContext-Erfassung

Wenn Sie das Marshaling-Verhalten nicht wünschen, rufen Sie ConfigureAwait mit false auf:

await task.ConfigureAwait(false);

Wenn Sie continueOnCapturedContext auf false setzen, prüft der Awaiter nicht, ob ein SynchronizationContext vorhanden ist, und die Fortsetzung wird dort ausgeführt, wo die Aufgabe abgeschlossen wird (normalerweise auf einem Threadpool-Thread). Bibliotheksautoren sollten ConfigureAwait(false) bei jedem Await verwenden, es sei denn, der Code muss explizit auf dem erfassten Kontext fortgesetzt werden.

SynchronizationContext.Current wird nicht über Awaits hinweg weitergegeben

Dieser Punkt ist der wichtigste: SynchronizationContext.Currentwird nicht wird nicht über Await-Punkte hinweg weitergegeben. Die asynchronen Methoden-Ersteller im Laufzeitsystem verwenden interne Überladungen, die explizit SynchronizationContext daran hindern, als Teil von ExecutionContext zu fließen.

Warum das wichtig ist

Technisch gesehen ist SynchronizationContext einer der Unterkontexte, die ExecutionContext enthalten kann. Wenn der Code als Teil von ExecutionContext übertragen wurde, könnte Code, der in einem Thread im Threadpool ausgeführt wird, eine Benutzeroberfläche SynchronizationContext als Current sehen, nicht weil dieser Thread der UI-Thread ist, sondern weil der Kontext durch die Übertragung "durchsickerte". Diese Änderung würde die Bedeutung von SynchronizationContext.Current "der Umgebung, in der ich gerade bin" in "die Umgebung ändern, die historisch irgendwo in der Anrufkette existierte.".

Beispiel "Task.Run"

Erwägen Sie Code, der die Arbeit in den Thread-Pool auslagert. Das hier beschriebene Verhalten des UI-Threads gilt nur, wenn SynchronizationContext.Current nicht null ist, etwa in einer UI-App:

static class TaskRunExample
{
    public static async Task ProcessOnUIThread()
    {
        // This method is called from a thread with a SynchronizationContext.
        // Task.Run offloads work to the thread pool.
        string result = await Task.Run(async () =>
        {
            string data = await DownloadAsync();
            // Compute runs on the thread pool, not the original context,
            // because SynchronizationContext doesn't flow into Task.Run.
            return Compute(data);
        });

        // Back on the original context (the continuation is posted back).
        Console.WriteLine(result);
    }

    private static async Task<string> DownloadAsync()
    {
        await Task.Delay(100);
        return "downloaded data";
    }

    private static string Compute(string data) =>
        $"Computed: {data.Length} chars";
}
Class TaskRunExampleClass
    Public Shared Async Function ProcessOnUIThread() As Task
        ' If a SynchronizationContext is present when this method starts,
        ' the outer await captures it. Task.Run still offloads work to the thread pool.
        Dim result As String = Await Task.Run(
            Async Function()
                Dim data As String = Await DownloadAsync()
                ' Compute runs on the thread pool, not the caller's context,
                ' because SynchronizationContext doesn't flow into Task.Run.
                Return Compute(data)
            End Function)

        ' Resume on the captured context, if one was available.
        Console.WriteLine(result)
    End Function

    Private Shared Async Function DownloadAsync() As Task(Of String)
        Await Task.Delay(100)
        Return "downloaded data"
    End Function

    Private Shared Function Compute(data As String) As String
        Return $"Computed: {data.Length} chars"
    End Function
End Class

In einer Konsolenanwendung ist SynchronizationContext.Current in der Regel null, so dass das Snippet nicht auf einem echten UI-Thread fortgesetzt wird. Stattdessen veranschaulicht der Codeausschnitt das Prinzip konzeptionell: Wenn ein UI-SynchronizationContext über await hinweg weitergegeben würde, würde der await innerhalb des an Task.Run übergebenen Delegaten diesen UI-Kontext als Current sehen. Die Fortsetzung danach await DownloadAsync() würde dann wieder in den UI-Thread zurückgesendet, sodass Compute(data) im UI-Thread statt im Threadpool ausgeführt wird. Dieses Verhalten verfehlt den Zweck des Task.Run Aufrufs.

Da die Laufzeit die SynchronizationContext-Weitergabe in ExecutionContext unterdrückt, erbt die await innerhalb von Task.Run keinen äußeren UI-Kontext, und die Fortsetzung läuft wie vorgesehen weiterhin auf dem Threadpool.

Zusammenfassung

Aspekt Ausführungskontext SynchronizationContext
Purpose Trägt den Umgebungszustand über asynchrone Grenzen hinweg Stellt einen Ziel-Scheduler dar (in dem Code laufen soll)
Erfasst von Asynchrone Methoden-Ersteller (Infrastruktur) Task-Awaiter (await task)
Wird über Await hinweg weitergegeben? Ja, immer Nein – wird erfasst und dorthin gepostet, nicht weitergegeben.
Supprimierungs-API ExecutionContext.SuppressFlow (erweitert; selten erforderlich) ConfigureAwait(false)
Bereich Alle Awaitables Task und Task<TResult> (benutzerdefinierte Awaiters können eine ähnliche Logik hinzufügen)

Siehe auch