Calling async code from sync code in C#: deadlocks, the thread pool and the right approach
How blocking on a Task deadlocks UI apps and starves the thread pool in ASP.NET Core, what to do when blocking is unavoidable, where ConfigureAwait(false) and .NET 8's ConfigureAwaitOptions matter, and how hidden async void crashes processes.
Sooner or later, synchronous code needs to call an async method: a sync interface you don't own, a constructor, a legacy Main. The reflex is .Result or .Wait(). In a WinForms or WPF event handler, that can freeze the app with a deadlock. In ASP.NET Core it won't deadlock, but under load it can quietly exhaust the thread pool until the service stops responding.
The two failures look nothing alike, and the platform has changed underneath them. Classic ASP.NET and the desktop UI frameworks behave one way, ASP.NET Core another. The conclusion is the same in both: go async all the way, and treat blocking as a last resort. This article walks through how the deadlock actually happens, why ASP.NET Core changes the symptoms but not the conclusion, what to do in the rare cases where blocking is unavoidable, and two related traps: ConfigureAwait and async void.
How the deadlock happens
When you await something, the compiler captures the current SynchronizationContext and later posts the rest of the method back to it. A UI thread's context has exactly one thread. That's the whole problem:
private static readonly HttpClient Http = new();
private void LoadButton_Click(object sender, EventArgs e)
{
// The UI thread blocks here, waiting for the task...
string html = GetPageAsync().Result;
outputBox.Text = html;
}
private async Task<string> GetPageAsync()
{
string html = await Http.GetStringAsync("https://example.com");
// ...but this continuation needs the UI thread to run. Deadlock.
return html.Trim();
}
The UI thread is waiting on the task, and the task is waiting on the UI thread. Neither can move. Legacy ASP.NET (System.Web) has a request context that behaves the same way, which is why this deadlock is so closely associated with older ASP.NET code.
The fix in UI code is simple. Event handlers are the one place where async void is legitimate:
private async void LoadButton_Click(object sender, EventArgs e)
{
outputBox.Text = await GetPageAsync();
}
ASP.NET Core and thread pool starvation
ASP.NET Core doesn't install a SynchronizationContext. Continuations run on any thread pool thread, so the snippet above wouldn't deadlock inside a controller. It's tempting to conclude that .Result is "fine on the server". It isn't.
Every blocking call holds a thread pool thread hostage while it waits for I/O. When a burst of traffic arrives, all threads end up blocked, and the work that would unblock them (the continuations) is queued behind them. The thread pool adds threads gradually, not instantly, so latency climbs. The service looks hung even though CPU usage is low. That's thread pool starvation, and it usually only shows up in production, under real load. If you suspect it, dotnet-counters lets you watch thread pool thread count and queue length while you load test. A thread count that keeps rising while the queue stays long is the classic signature.
So the guidance is the same everywhere: async all the way.
Async all the way: the modern toolbox
For a long time, sync-over-async was hard to avoid. Entry points, Dispose methods and many framework extension points were synchronous, so the usual workarounds were "AsyncHelper" methods built on TaskFactory, nested message loops, or a bare .Result. None of them works everywhere, and each has trade-offs. Today most of those pressure points have async counterparts:
static async Task<int> Main(string[] args)(C# 7.1+) instead of blocking inMain. Top-level statements canawaitdirectly.IAsyncDisposableandawait usinginstead of blocking insideDispose().- Async filters, middleware, hosted services and Razor Page handlers in ASP.NET Core.
IAsyncEnumerable<T>andawait foreachfor streams.
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
If you write a type that does async cleanup, such as flushing a buffer, implement IAsyncDisposable. Don't hide FlushAsync().Wait() inside Dispose().
When blocking is unavoidable
Sometimes you're implementing a synchronous interface from a third-party library, or you're stuck in a constructor. This is what I use:
public string GetSetting(string key)
{
// Synchronous interface we don't own; the store is async-only.
return Task.Run(() => _store.GetValueAsync(key))
.GetAwaiter()
.GetResult();
}
Task.Run starts GetValueAsync on a thread pool thread, where there's no SynchronizationContext to capture, so its continuations don't need the blocked thread. You still burn a thread while waiting, so this is a last resort and not a pattern to copy around.
Why GetAwaiter().GetResult() and not .Result? Both block. The difference is exception handling:
try
{
FailAsync().Wait();
}
catch (AggregateException ex)
{
// The real exception is in ex.InnerException
}
try
{
FailAsync().GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
// The original exception, as if you had awaited
}
That's the whole difference. Neither one is "safer" with respect to deadlocks.
ConfigureAwait(false) in libraries and apps
ConfigureAwait(false) tells the awaiter not to resume on the captured context. Microsoft's ConfigureAwait FAQ sums it up in one line: "if you're writing general-purpose library code, use ConfigureAwait(false)". In application code, especially UI code that touches controls after an await, don't.
In ASP.NET Core application code there's no context to capture, so ConfigureAwait(false) is mostly noise. I don't add it to controllers or app services. In a NuGet package that might be used from WPF, I do, on every await:
public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
{
using var response = await _http.GetAsync($"orders/{id}", ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return (await response.Content
.ReadFromJsonAsync<Order>(cancellationToken: ct)
.ConfigureAwait(false))!;
}
Remember that ConfigureAwait(false) in a library only reduces the risk of a caller deadlocking. It doesn't make sync-over-async correct.
ConfigureAwaitOptions (.NET 8+)
.NET 8 added a ConfigureAwait(ConfigureAwaitOptions) overload with a flags enum: None, ContinueOnCapturedContext, SuppressThrowing and ForceYielding.
// Wait for a background flush to finish; we don't care whether it failed.
await _flushTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
// Force the rest of this method to run asynchronously, even if the task is complete.
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
Two details to watch. First, None means don't continue on the captured context, so it's the same as ConfigureAwait(false). If you want the old default behaviour, include ContinueOnCapturedContext. Second, SuppressThrowing is only supported on non-generic Task. On Task<T>, it throws ArgumentOutOfRangeException, because there would be no result to return.
A related trap: async void
async void is the flip side of the same misunderstanding. An async void method can't be awaited, so its exceptions have nowhere to go. They're raised on the SynchronizationContext that was current when the method started. When there isn't one, as in ASP.NET Core, a worker service or a console app, they're raised on the thread pool and crash the process.
You rarely write async void on purpose. You get it by accident:
// List<T>.ForEach takes an Action<T>, so this lambda is async void.
orders.ForEach(async o => await _mailer.SendAsync(o));
// Same problem: Parallel.ForEach has no idea the lambda is async.
// It returns as soon as each lambda hits its first await.
Parallel.ForEach(orders, async o => await _mailer.SendAsync(o));
Both return before the emails are sent, and an exception in either can take down the app. Use Parallel.ForEachAsync (.NET 6+), or a plain foreach with await if you don't need parallelism:
await Parallel.ForEachAsync(
orders,
new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
async (order, token) => await _mailer.SendAsync(order, token));
Checklist
- Make the call chain async from top to bottom. Use async
Main,await usingand async handlers. - In UI apps, never block on a task from the UI thread. Use
async voidonly for event handlers. - In ASP.NET Core,
.Resultwon't deadlock, but it will starve the thread pool under load. Treat it as a bug. - If you must block, use
Task.Run(...).GetAwaiter().GetResult(), isolate it in one place and leave a comment explaining why. - Use
ConfigureAwait(false)in libraries. Skip it in ASP.NET Core app code. - Remember that
ConfigureAwaitOptions.Noneis the same asfalse, andSuppressThrowingonly works onTask. - Grep for
.ForEach(asyncandParallel.ForEach(with async lambdas. Replace them withforeach+awaitorParallel.ForEachAsync.
- ---
- baslik: "Calling async code from sync code in C#: deadlocks, the thread pool and the right approach"
- ozet: "How blocking on a Task deadlocks UI apps and starves the thread pool in ASP.NET Core, what to do when blocking is unavoidable, where ConfigureAwait(false) and .NET 8's ConfigureAwaitOptions matter, and how hidden async void crashes processes."
- tarih: 2026-10-13
- etiketler:
- - dotnet
- - csharp
- - async
- kategoriler:
- - dotnet
- dil: en
- ---
- Sooner or later, synchronous code needs to call an async method: a sync interface you don't own, a constructor, a legacy `Main`. The reflex is `.Result` or `.Wait()`. In a WinForms or WPF event handler, that can freeze the app with a deadlock. In ASP.NET Core it won't deadlock, but under load it can quietly exhaust the thread pool until the service stops responding.
- The two failures look nothing alike, and the platform has changed underneath them. Classic ASP.NET and the desktop UI frameworks behave one way, ASP.NET Core another. The conclusion is the same in both: go async all the way, and treat blocking as a last resort. This article walks through how the deadlock actually happens, why ASP.NET Core changes the symptoms but not the conclusion, what to do in the rare cases where blocking is unavoidable, and two related traps: `ConfigureAwait` and `async void`.
- ## How the deadlock happens
- When you `await` something, the compiler captures the current `SynchronizationContext` and later posts the rest of the method back to it. A UI thread's context has exactly one thread. That's the whole problem:
- ```csharp
- private static readonly HttpClient Http = new();
- private void LoadButton_Click(object sender, EventArgs e)
- {
- // The UI thread blocks here, waiting for the task...
- string html = GetPageAsync().Result;
- outputBox.Text = html;
- }
- private async Task<string> GetPageAsync()
- {
- string html = await Http.GetStringAsync("https://example.com");
- // ...but this continuation needs the UI thread to run. Deadlock.
- return html.Trim();
- }
- ```
- The UI thread is waiting on the task, and the task is waiting on the UI thread. Neither can move. Legacy ASP.NET (`System.Web`) has a request context that behaves the same way, which is why this deadlock is so closely associated with older ASP.NET code.
- The fix in UI code is simple. Event handlers are the one place where `async void` is legitimate:
- ```csharp
- private async void LoadButton_Click(object sender, EventArgs e)
- {
- outputBox.Text = await GetPageAsync();
- }
- ```
- ## ASP.NET Core and thread pool starvation
- ASP.NET Core doesn't install a `SynchronizationContext`. Continuations run on any thread pool thread, so the snippet above wouldn't deadlock inside a controller. It's tempting to conclude that `.Result` is "fine on the server". It isn't.
- Every blocking call holds a thread pool thread hostage while it waits for I/O. When a burst of traffic arrives, all threads end up blocked, and the work that would unblock them (the continuations) is queued behind them. The thread pool adds threads gradually, not instantly, so latency climbs. The service looks hung even though CPU usage is low. That's thread pool starvation, and it usually only shows up in production, under real load. If you suspect it, `dotnet-counters` lets you watch thread pool thread count and queue length while you load test. A thread count that keeps rising while the queue stays long is the classic signature.
- So the guidance is the same everywhere: async all the way.
- ## Async all the way: the modern toolbox
- For a long time, sync-over-async was hard to avoid. Entry points, `Dispose` methods and many framework extension points were synchronous, so the usual workarounds were "AsyncHelper" methods built on `TaskFactory`, nested message loops, or a bare `.Result`. None of them works everywhere, and each has trade-offs. Today most of those pressure points have async counterparts:
- - `static async Task<int> Main(string[] args)` (C# 7.1+) instead of blocking in `Main`. Top-level statements can `await` directly.
- - `IAsyncDisposable` and `await using` instead of blocking inside `Dispose()`.
- - Async filters, middleware, hosted services and Razor Page handlers in ASP.NET Core.
- - `IAsyncEnumerable<T>` and `await foreach` for streams.
- ```csharp
- await using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(ct);
- ```
- If you write a type that does async cleanup, such as flushing a buffer, implement `IAsyncDisposable`. Don't hide `FlushAsync().Wait()` inside `Dispose()`.
- ## When blocking is unavoidable
- Sometimes you're implementing a synchronous interface from a third-party library, or you're stuck in a constructor. This is what I use:
- ```csharp
- public string GetSetting(string key)
- {
- // Synchronous interface we don't own; the store is async-only.
- return Task.Run(() => _store.GetValueAsync(key))
- .GetAwaiter()
- .GetResult();
- }
- ```
- `Task.Run` starts `GetValueAsync` on a thread pool thread, where there's no `SynchronizationContext` to capture, so its continuations don't need the blocked thread. You still burn a thread while waiting, so this is a last resort and not a pattern to copy around.
- Why `GetAwaiter().GetResult()` and not `.Result`? Both block. The difference is exception handling:
- ```csharp
- try
- {
- FailAsync().Wait();
- }
- catch (AggregateException ex)
- {
- // The real exception is in ex.InnerException
- }
- try
- {
- FailAsync().GetAwaiter().GetResult();
- }
- catch (InvalidOperationException ex)
- {
- // The original exception, as if you had awaited
- }
- ```
- That's the whole difference. Neither one is "safer" with respect to deadlocks.
- ## ConfigureAwait(false) in libraries and apps
- `ConfigureAwait(false)` tells the awaiter not to resume on the captured context. Microsoft's [ConfigureAwait FAQ](https://devblogs.microsoft.com/dotnet/configureawait-faq/) sums it up in one line: "if you're writing general-purpose library code, use ConfigureAwait(false)". In application code, especially UI code that touches controls after an await, don't.
- In ASP.NET Core application code there's no context to capture, so `ConfigureAwait(false)` is mostly noise. I don't add it to controllers or app services. In a NuGet package that might be used from WPF, I do, on every await:
- ```csharp
- public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
- {
- using var response = await _http.GetAsync($"orders/{id}", ct).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
- return (await response.Content
- .ReadFromJsonAsync<Order>(cancellationToken: ct)
- .ConfigureAwait(false))!;
- }
- ```
- Remember that `ConfigureAwait(false)` in a library only reduces the risk of a caller deadlocking. It doesn't make sync-over-async correct.
- ### ConfigureAwaitOptions (.NET 8+)
- .NET 8 added a `ConfigureAwait(ConfigureAwaitOptions)` overload with a flags enum: `None`, `ContinueOnCapturedContext`, `SuppressThrowing` and `ForceYielding`.
- ```csharp
- // Wait for a background flush to finish; we don't care whether it failed.
- await _flushTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
- // Force the rest of this method to run asynchronously, even if the task is complete.
- await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
- ```
- Two details to watch. First, `None` means *don't* continue on the captured context, so it's the same as `ConfigureAwait(false)`. If you want the old default behaviour, include `ContinueOnCapturedContext`. Second, `SuppressThrowing` is only supported on non-generic `Task`. On `Task<T>`, it throws `ArgumentOutOfRangeException`, because there would be no result to return.
- ## A related trap: async void
- `async void` is the flip side of the same misunderstanding. An `async void` method can't be awaited, so its exceptions have nowhere to go. They're raised on the `SynchronizationContext` that was current when the method started. When there isn't one, as in ASP.NET Core, a worker service or a console app, they're raised on the thread pool and **crash the process**.
- You rarely write `async void` on purpose. You get it by accident:
- ```csharp
- // List<T>.ForEach takes an Action<T>, so this lambda is async void.
- orders.ForEach(async o => await _mailer.SendAsync(o));
- // Same problem: Parallel.ForEach has no idea the lambda is async.
- // It returns as soon as each lambda hits its first await.
- Parallel.ForEach(orders, async o => await _mailer.SendAsync(o));
- ```
- Both return before the emails are sent, and an exception in either can take down the app. Use `Parallel.ForEachAsync` (.NET 6+), or a plain `foreach` with `await` if you don't need parallelism:
- ```csharp
- await Parallel.ForEachAsync(
- orders,
- new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
- async (order, token) => await _mailer.SendAsync(order, token));
- ```
- ## Checklist
- - Make the call chain async from top to bottom. Use async `Main`, `await using` and async handlers.
- - In UI apps, never block on a task from the UI thread. Use `async void` only for event handlers.
- - In ASP.NET Core, `.Result` won't deadlock, but it will starve the thread pool under load. Treat it as a bug.
- - If you must block, use `Task.Run(...).GetAwaiter().GetResult()`, isolate it in one place and leave a comment explaining why.
- - Use `ConfigureAwait(false)` in libraries. Skip it in ASP.NET Core app code.
- - Remember that `ConfigureAwaitOptions.None` is the same as `false`, and `SuppressThrowing` only works on `Task`.
- - Grep for `.ForEach(async` and `Parallel.ForEach(` with async lambdas. Replace them with `foreach` + `await` or `Parallel.ForEachAsync`.