Rediger

ExecutionContext and SynchronizationContext

When you work with async and await, two context types play important but very different roles: ExecutionContext and SynchronizationContext. You learn what each one does, how each one interacts with async/await, and why SynchronizationContext.Current doesn't flow across await points.

What is ExecutionContext?

ExecutionContext is a container for ambient state that flows with the logical control flow of your program. In a synchronous world, ambient information lives in thread-local storage (TLS), and all code running on a given thread sees that data. In an asynchronous world, a logical operation can start on one thread, suspend, and resume on a different thread. Thread-local data doesn't follow along automatically—ExecutionContext makes it follow.

How ExecutionContext flows

Capture ExecutionContext by using ExecutionContext.Capture(). Restore it during execution of a delegate by using ExecutionContext.Run:

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

All asynchronous APIs in .NET that fork work—Run, QueueUserWorkItem, BeginRead, and others—capture ExecutionContext and use the stored context when invoking your callback. This process of capturing state on one thread and restoring it on another is what "flowing ExecutionContext" means.

What is SynchronizationContext?

SynchronizationContext is an abstraction that represents a target environment where you want work to run. Different UI frameworks provide their own implementations:

  • Windows Forms provides WindowsFormsSynchronizationContext, which overrides Post to call Control.BeginInvoke.
  • WPF provides DispatcherSynchronizationContext, which overrides Post to call Dispatcher.BeginInvoke.
  • ASP.NET (on .NET Framework) provided its own context that ensured HttpContext.Current was available.

By using SynchronizationContext instead of framework-specific marshaling APIs, you can write components that work across UI frameworks:

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

Capture a SynchronizationContext

When you capture a SynchronizationContext, you read the reference from SynchronizationContext.Current and store it for later use. You then call Post on the captured reference to schedule work back to that environment.

Flowing ExecutionContext vs. using SynchronizationContext

Although both mechanisms involve capturing state from a thread, they serve different purposes:

  • Flowing ExecutionContext means capturing ambient state and making that same state current during a delegate's execution. The delegate runs wherever it ends up running—the state follows it.
  • Using SynchronizationContext means capturing a scheduling target and using it to decide where a delegate executes. The captured context controls where the delegate runs.

In short: ExecutionContext answers "what environment should be visible?" while SynchronizationContext answers "where should the code run?"

How async/await interacts with both contexts

The async/await infrastructure interacts with both contexts automatically, but in different ways.

ExecutionContext always flows

Whenever an await suspends a method (because the awaiter's IsCompleted returns false), the infrastructure captures an ExecutionContext. When the method resumes, the continuation runs within the captured context. This behavior is built into the async method builders (for example, AsyncTaskMethodBuilder) and applies regardless of what kind of awaitable you use.

SuppressFlow() exists, but it isn't an await-specific switch like ConfigureAwait(false). It suppresses ExecutionContext capture for work that you queue while suppression is active. It doesn't provide a per-await programming-model option that tells the async method builders to skip restoring the captured ExecutionContext for a continuation. That design is intentional because ExecutionContext is infrastructure-level support that simulates thread-local semantics in an asynchronous world, and most developers never need to think about it.

Task awaiters capture SynchronizationContext

The awaiters for Task and Task<TResult> include support for SynchronizationContext. The async method builders don't include this support.

When you await a task:

  1. The awaiter checks SynchronizationContext.Current.
  2. If a context exists, the awaiter captures it.
  3. When the task completes, the continuation is posted back to that captured context instead of running on the completing thread or the thread pool.

This behavior is how await "brings you back to where you were". For example, resuming on the UI thread in a desktop application.

ConfigureAwait controls SynchronizationContext capture

If you don't want the marshaling behavior, call ConfigureAwait with false:

await task.ConfigureAwait(false);

When you set continueOnCapturedContext to false, the awaiter doesn't check for a SynchronizationContext and the continuation runs wherever the task completes (typically on a thread pool thread). Library authors should use ConfigureAwait(false) on every await unless the code specifically needs to resume on the captured context.

SynchronizationContext.Current doesn't flow across awaits

This point is the most important: SynchronizationContext.Current doesn't flow across await points. The async method builders in the runtime use internal overloads that explicitly suppress SynchronizationContext from flowing as part of ExecutionContext.

Why this matters

Technically, SynchronizationContext is one of the sub-contexts that ExecutionContext can contain. If it flowed as part of ExecutionContext, code executing on a thread pool thread might see a UI SynchronizationContext as Current, not because that thread is the UI thread, but because the context "leaked" via flow. That change would alter the meaning of SynchronizationContext.Current from "the environment I'm currently in" to "the environment that historically existed somewhere in the call chain."

The Task.Run example

Consider code that offloads work to the thread pool. The UI-thread behavior described here applies only when SynchronizationContext.Current is non-null, such as in a 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 a console app, SynchronizationContext.Current is typically null, so the snippet doesn't resume on a real UI thread. Instead, the snippet illustrates the rule conceptually: if a UI SynchronizationContext flowed across await points, the await inside the delegate passed to Task.Run would see that UI context as Current. The continuation after await DownloadAsync() would then post back to the UI thread, causing Compute(data) to run on the UI thread instead of on the thread pool. That behavior defeats the purpose of the Task.Run call.

Because the runtime suppresses SynchronizationContext flow in ExecutionContext, the await inside Task.Run doesn't inherit an outer UI context, and the continuation keeps running on the thread pool as intended.

Summary

Aspect ExecutionContext SynchronizationContext
Purpose Carries ambient state across async boundaries Represents a target scheduler (where code should run)
Captured by Async method builders (infrastructure) Task awaiters (await task)
Flows across await? Yes, always No—captured and posted to, not flowed
Suppression API ExecutionContext.SuppressFlow (advanced; rarely needed) ConfigureAwait(false)
Scope All awaitables Task and Task<TResult> (custom awaiters can add similar logic)

See also