diff --git a/docs/navigate/advanced-programming/toc.yml b/docs/navigate/advanced-programming/toc.yml index 5872397045d69..62cc30de123d6 100644 --- a/docs/navigate/advanced-programming/toc.yml +++ b/docs/navigate/advanced-programming/toc.yml @@ -20,6 +20,10 @@ items: href: ../../standard/asynchronous-programming-patterns/async-wrappers-for-synchronous-methods.md - name: Synchronous wrappers for asynchronous methods href: ../../standard/asynchronous-programming-patterns/synchronous-wrappers-for-asynchronous-methods.md + - name: ExecutionContext and SynchronizationContext + href: ../../standard/asynchronous-programming-patterns/executioncontext-synchronizationcontext.md + - name: SynchronizationContext and console apps + href: ../../standard/asynchronous-programming-patterns/synchronizationcontext-console-apps.md - name: Event-based asynchronous pattern (EAP) items: - name: Documentation overview diff --git a/docs/standard/asynchronous-programming-patterns/executioncontext-synchronizationcontext.md b/docs/standard/asynchronous-programming-patterns/executioncontext-synchronizationcontext.md new file mode 100644 index 0000000000000..63ea6e03413da --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/executioncontext-synchronizationcontext.md @@ -0,0 +1,125 @@ +--- +title: "ExecutionContext and SynchronizationContext" +description: Learn about the difference between ExecutionContext and SynchronizationContext in .NET, how each one is used with async/await, and why SynchronizationContext.Current doesn't flow across awaits. +ms.date: 04/08/2026 +ai-usage: ai-assisted +dev_langs: + - "csharp" + - "vb" +helpviewer_keywords: + - "ExecutionContext" + - "SynchronizationContext" + - "async await context" + - "ConfigureAwait" + - "context flow" + - "asynchronous programming, context" +--- +# ExecutionContext and SynchronizationContext + +When you work with `async` and `await`, two context types play important but very different roles: and . You learn what each one does, how each one interacts with `async`/`await`, and why doesn't flow across await points. + +## What is 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— makes it follow. + +### How ExecutionContext flows + +Capture by using . Restore it during execution of a delegate by using : + +:::code language="csharp" source="./snippets/executioncontext-synchronizationcontext/csharp/Program.cs" id="ExecutionContextCapture"::: +:::code language="vb" source="./snippets/executioncontext-synchronizationcontext/vb/Program.vb" id="ExecutionContextCapture"::: + +All asynchronous APIs in .NET that fork work—, , , and others—capture 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? + + 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 to call `Control.BeginInvoke`. +- WPF provides `DispatcherSynchronizationContext`, which overrides to call `Dispatcher.BeginInvoke`. +- ASP.NET (on .NET Framework) provided its own context that ensured `HttpContext.Current` was available. + +By using instead of framework-specific marshaling APIs, you can write components that work across UI frameworks: + +:::code language="csharp" source="./snippets/executioncontext-synchronizationcontext/csharp/Program.cs" id="SyncContextUsage"::: +:::code language="vb" source="./snippets/executioncontext-synchronizationcontext/vb/Program.vb" id="SyncContextUsage"::: + +### Capturing a SynchronizationContext + +When you capture a , you read the reference from and store it for later use. You then call 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: answers "what environment should be visible?" while 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 . When the method resumes, the continuation runs within the captured context. This behavior is built into the async method builders (for example, ) and applies regardless of what kind of awaitable you use. + + exists, but it isn't an await-specific switch like `ConfigureAwait(false)`. It suppresses 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 for a continuation. That design is intentional because 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 and include support for . The async method builders don't include this support. + +When you `await` a task: + +1. The awaiter checks . +1. If a context exists, the awaiter captures it. +1. 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 with `false`: + +```csharp +await task.ConfigureAwait(false); +``` + +When you set `continueOnCapturedContext` to `false`, the awaiter doesn't check for a 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: **doesn't flow** across await points. The async method builders in the runtime use internal overloads that explicitly suppress from flowing as part of . + +### Why this matters + +Technically, is one of the sub-contexts that can contain. If it flowed as part of , 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 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 is non-null, such as in a UI app: + +:::code language="csharp" source="./snippets/executioncontext-synchronizationcontext/csharp/Program.cs" id="TaskRunExample"::: +:::code language="vb" source="./snippets/executioncontext-synchronizationcontext/vb/Program.vb" id="TaskRunExample"::: + +In a console app, is typically `null`, so the snippet doesn't resume on a real UI thread. Instead, the snippet illustrates the rule conceptually: if a UI flowed across `await` points, the `await` inside the delegate passed to 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 flow in , 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` (custom awaiters can add similar logic) | + +## See also + +- [Task-based asynchronous pattern (TAP)](task-based-asynchronous-pattern-tap.md) +- [Consume the task-based asynchronous pattern](consuming-the-task-based-asynchronous-pattern.md) +- [SynchronizationContext and console apps](synchronizationcontext-console-apps.md) diff --git a/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/csharp/ExecutionContextAndSyncContext.csproj b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/csharp/ExecutionContextAndSyncContext.csproj new file mode 100644 index 0000000000000..dfb40caafcf9a --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/csharp/ExecutionContextAndSyncContext.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/csharp/Program.cs b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/csharp/Program.cs new file mode 100644 index 0000000000000..155b83f4f5df2 --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/csharp/Program.cs @@ -0,0 +1,155 @@ +using System.Collections.Concurrent; + +// Verification entry point +ExecutionContextCaptureDemo(); +await TaskRunExample.ProcessOnUIThread(); + +// Install a custom SynchronizationContext for the demo +var demoContext = new DemoSynchronizationContext(); +SynchronizationContext.SetSynchronizationContext(demoContext); +SyncContextExample.DoWork(); +demoContext.ProcessQueue(); +SynchronizationContext.SetSynchronizationContext(null); + +await Task.Delay(200); +Console.WriteLine("Done."); + +// +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); + } +} +// + +static class SingleThreadSynchronizationContext +{ + public static Task Run(Func asyncAction) + { + var previousContext = SynchronizationContext.Current; + var context = new SingleThreadContext(); + SynchronizationContext.SetSynchronizationContext(context); + + Task task; + try + { + task = asyncAction(); + task.ContinueWith(_ => context.Complete(), TaskScheduler.Default); + context.RunOnCurrentThread(); + return task; + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + } + + private sealed class SingleThreadContext : SynchronizationContext + { + private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new(); + + public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state)); + + public void RunOnCurrentThread() + { + foreach (var workItem in _queue.GetConsumingEnumerable()) + { + workItem.Callback(workItem.State); + } + } + + public void Complete() => _queue.CompleteAdding(); + } +} + +// +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); + } + }); + } +} +// + +// Minimal SynchronizationContext for demo purposes +sealed class DemoSynchronizationContext : SynchronizationContext +{ + private readonly Queue<(SendOrPostCallback, object?)> _queue = new(); + + public override void Post(SendOrPostCallback d, object? state) + { + lock (_queue) + { + _queue.Enqueue((d, state)); + } + } + + public void ProcessQueue() + { + Thread.Sleep(150); // Allow time for ThreadPool work to complete + lock (_queue) + { + while (_queue.Count > 0) + { + var (callback, state) = _queue.Dequeue(); + callback(state); + } + } + } +} + +// +static class TaskRunExample +{ + public static async Task ProcessOnUIThread() + { + // Assume this method is called from a UI thread. + // 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 UI thread, + // because SynchronizationContext doesn't flow into Task.Run. + return Compute(data); + }); + + // Back on the UI thread (captured by the outer await). + Console.WriteLine(result); + } + + private static async Task DownloadAsync() + { + await Task.Delay(100); + return "downloaded data"; + } + + private static string Compute(string data) => + $"Computed: {data.Length} chars"; +} +// diff --git a/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/vb/ExecutionContextAndSyncContext.vbproj b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/vb/ExecutionContextAndSyncContext.vbproj new file mode 100644 index 0000000000000..9c90288f3e11a --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/vb/ExecutionContextAndSyncContext.vbproj @@ -0,0 +1,9 @@ + + + + Exe + net10.0 + ExecutionContextAndSyncContext + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/vb/Program.vb b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/vb/Program.vb new file mode 100644 index 0000000000000..173b9cc13edcf --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/executioncontext-synchronizationcontext/vb/Program.vb @@ -0,0 +1,101 @@ +Imports System.Threading + +Module Program + Sub Main() + ExecutionContextCaptureExample() + + SyncContextExample.DoWork() + + Task.Run(Async Function() + Await TaskRunExampleClass.ProcessOnUIThread() + End Function).Wait() + + Thread.Sleep(200) + Console.WriteLine("Done.") + End Sub + + ' + 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 + ' +End Module + +' +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 +' + +' +Class TaskRunExampleClass + Public Shared Async Function ProcessOnUIThread() As Task + ' Assume this method is called from a UI thread. + ' Task.Run 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 UI thread, + ' because SynchronizationContext doesn't flow into Task.Run. + Return Compute(data) + End Function) + + ' Back on the UI thread (captured by the outer await). + 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 +' diff --git a/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/csharp/Program.cs b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/csharp/Program.cs new file mode 100644 index 0000000000000..99344b56ba148 --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/csharp/Program.cs @@ -0,0 +1,181 @@ +using System.Collections.Concurrent; + +// Top-level entry point for verification +DefaultBehaviorDemo(); +AsyncPumpDemo(); +Console.WriteLine("All demos complete."); + +// +static void DefaultBehaviorDemo() +{ + DemoAsync().GetAwaiter().GetResult(); +} + +static async Task DemoAsync() +{ + var d = new Dictionary(); + 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); +} +// + +// +static void AsyncPumpDemo() +{ + AsyncPump.Run(async () => + { + var d = new Dictionary(); + 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); + }); +} +// + +// +sealed class SingleThreadSynchronizationContext : SynchronizationContext +{ + private readonly + BlockingCollection> _queue = new(); + + public override void Post(SendOrPostCallback d, object? state) + { + _queue.Add(new KeyValuePair(d, state)); + } + + public void RunOnCurrentThread() + { + while (_queue.TryTake(out KeyValuePair workItem, + Timeout.Infinite)) + { + workItem.Key(workItem.Value); + } + } + + public void Complete() => _queue.CompleteAdding(); +} +// + +// +static class AsyncPump +{ + public static void Run(Func 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); + } + } + // + + // + 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> _queue = new(); + private int _operationCount; + + public override void Post(SendOrPostCallback d, object? state) + { + _queue.Add(new KeyValuePair(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 workItem, + Timeout.Infinite)) + { + workItem.Key(workItem.Value); + } + } + + public void Complete() => _queue.CompleteAdding(); +} +// diff --git a/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/csharp/SyncContextConsoleApps.csproj b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/csharp/SyncContextConsoleApps.csproj new file mode 100644 index 0000000000000..dfb40caafcf9a --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/csharp/SyncContextConsoleApps.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/vb/Program.vb b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/vb/Program.vb new file mode 100644 index 0000000000000..6d1ad4708f6db --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/vb/Program.vb @@ -0,0 +1,169 @@ +Imports System.Collections.Concurrent +Imports System.Threading + +Module Program + Sub Main() + DefaultBehaviorDemo() + AsyncPumpDemo() + Console.WriteLine("All demos complete.") + End Sub + + ' + Sub DefaultBehaviorDemo() + DemoAsync().GetAwaiter().GetResult() + End Sub + + Async Function DemoAsync() 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 + ' + + ' + 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 + ' +End Module + +' +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 +' + +' +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 + ' + + ' + 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 +' diff --git a/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/vb/SyncContextConsoleApps.vbproj b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/vb/SyncContextConsoleApps.vbproj new file mode 100644 index 0000000000000..cce8e81df1b8a --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/snippets/synchronizationcontext-console-apps/vb/SyncContextConsoleApps.vbproj @@ -0,0 +1,9 @@ + + + + Exe + net10.0 + SyncContextConsoleApps + + + diff --git a/docs/standard/asynchronous-programming-patterns/synchronizationcontext-console-apps.md b/docs/standard/asynchronous-programming-patterns/synchronizationcontext-console-apps.md new file mode 100644 index 0000000000000..0f823bd4a2e4c --- /dev/null +++ b/docs/standard/asynchronous-programming-patterns/synchronizationcontext-console-apps.md @@ -0,0 +1,121 @@ +--- +title: "SynchronizationContext and console apps" +description: Learn how to use a custom SynchronizationContext to control where async continuations run in .NET console applications, including a complete AsyncPump implementation. +ms.date: 04/08/2026 +ai-usage: ai-assisted +dev_langs: + - "csharp" + - "vb" +helpviewer_keywords: + - "SynchronizationContext" + - "console app async" + - "async Main" + - "AsyncPump" + - "message pump" + - "asynchronous programming, console" +--- +# SynchronizationContext and console apps + +UI frameworks like Windows Forms, WPF, and .NET MAUI install a on their UI thread. When you `await` a task in those environments, the continuation automatically posts back to the UI thread. Console apps don't install a , which means `await` continuations run on the thread pool. This article explains the consequences and shows how to build a single-threaded message pump when you need one. + +## Default behavior in a console app + +In a console app, returns `null`. When a method yields at an `await`, the continuation runs on whatever thread pool thread is available: + +:::code language="csharp" source="./snippets/synchronizationcontext-console-apps/csharp/Program.cs" id="DefaultBehavior"::: + +Representative output from running this program: + +```output +[1, 1] +[3, 2687] +[4, 2399] +[5, 2397] +[6, 2516] +``` + +Thread 1 (the main thread) appears only once, during the first synchronous iteration before `await Task.Yield()` suspends the method. All subsequent iterations run on thread pool threads. + +## Modern async entry points + +Starting with C# 7.1, you can declare `Main` as `async Task` or `async Task`. In C# 9 and later, you can use top-level statements with `await` directly: + +```csharp +// Top-level statements (C# 9+) +await DemoAsync(); +``` + +```csharp +// async Task Main (C# 7.1+) +static async Task Main() +{ + await DemoAsync(); +} +``` + +These entry points don't install a . The runtime generates a bootstrap that calls your async method and blocks on the returned , similar to calling `.GetAwaiter().GetResult()`. Continuations still run on the thread pool. + +## When you need thread affinity + +For many console apps, running continuations on the thread pool is fine. However, some scenarios require that all continuations run on a single thread: + +- **Serialized execution**: Multiple concurrent async operations share state without locks by running their continuations on the same thread. +- **Library requirements**: Some libraries or COM objects require affinity to a particular thread. +- **Unit testing**: Test frameworks might need deterministic, single-threaded execution of async code. + +## Build a single-threaded SynchronizationContext + +To run all continuations on one thread, you need two things: + +1. A whose method queues work to a thread-safe collection. +1. A message pump loop that processes that queue on the target thread. + +### The custom context + +The context uses a to coordinate producers (the async continuations) and a consumer (the pumping loop): + +:::code language="csharp" source="./snippets/synchronizationcontext-console-apps/csharp/Program.cs" id="SingleThreadContext"::: +:::code language="vb" source="./snippets/synchronizationcontext-console-apps/vb/Program.vb" id="SingleThreadContext"::: + +### The AsyncPump.Run method + +`AsyncPump.Run` installs the custom context, invokes the async method, and pumps continuations on the calling thread until the method completes: + +:::code language="csharp" source="./snippets/synchronizationcontext-console-apps/csharp/Program.cs" id="AsyncPumpRun"::: +:::code language="vb" source="./snippets/synchronizationcontext-console-apps/vb/Program.vb" id="AsyncPumpRun"::: + +### See it in action + +Replace the default call with `AsyncPump.Run`: + +:::code language="csharp" source="./snippets/synchronizationcontext-console-apps/csharp/Program.cs" id="AsyncPumpDemo"::: +:::code language="vb" source="./snippets/synchronizationcontext-console-apps/vb/Program.vb" id="AsyncPumpDemo"::: + +Output: + +```output +[1, 10000] +``` + +The specific thread ID might differ depending on the runtime and platform, but the key result is that all 10,000 iterations run on a single thread: the main thread. + +## Handle async void methods + +The `Func` overload tracks completion through the returned . Async `void` methods don't return a task; instead, they notify the current through and . To support async `void` methods, extend the context to track outstanding operations: + +:::code language="csharp" source="./snippets/synchronizationcontext-console-apps/csharp/Program.cs" id="AsyncVoidSupport"::: +:::code language="vb" source="./snippets/synchronizationcontext-console-apps/vb/Program.vb" id="AsyncVoidSupport"::: + +With operation tracking enabled, the pump exits only when all outstanding async `void` methods complete, not just the top-level task. + +## Practical considerations + +- **Deadlock risk**: If code running inside `AsyncPump.Run` blocks synchronously (for example, by calling `.Result` or `.Wait()` on a task whose continuation must post back to the pump), the pump thread can't process that continuation. The result is a deadlock. The same problem described in [Synchronous wrappers for asynchronous methods](synchronous-wrappers-for-asynchronous-methods.md). +- **Performance**: A single-threaded pump limits throughput to one thread. Use this approach only when thread affinity matters. +- **Cross-platform**: The `AsyncPump` implementation shown here uses only types from the `System.Collections.Concurrent` and `System.Threading` namespaces. It works on all platforms that .NET supports. + +## See also + +- [ExecutionContext and SynchronizationContext](executioncontext-synchronizationcontext.md) +- [Task-based asynchronous pattern (TAP)](task-based-asynchronous-pattern-tap.md) +- [Consume the task-based asynchronous pattern](consuming-the-task-based-asynchronous-pattern.md)