Fake async wrappers, why they do not scale on servers, when offloading is right (UI responsiveness), and how to integrate blocking legacy SDKs safely.
Async-over-sync means exposing or calling synchronous, blocking code through an async-looking API, typically Task.Run(() => DoBlockingWork()). On a server it does not add scalability: the blocking still happens, just on another pool thread, plus the cost of a hop. As a library API (public Task<T> GetAsync() => Task.Run(Get)) it misleads callers into thinking the operation is truly asynchronous. It is acceptable at the edge of a UI app to keep the UI thread responsive, or to move CPU-bound work off a request thread deliberately. For blocking legacy SDKs on servers, isolate them on dedicated threads with bounded concurrency until a real async API exists.
"Async-over-sync means making blocking code look asynchronous, usually
Task.Run(() => DoBlockingWork()), either inside an...Asyncmethod you publish or at a call site. On a server it doesn't buy any scalability. The blocking still happens, just on another pool thread, and you pay for the queue hop and allocations. Worse, it hides the blocking: under load you get ThreadPool starvation even though your code contains no.Result. In a library it's misleading: anAsyncsuffix promises non-blocking I/O, and hidingTask.Runtakes away the caller's choice of where work runs. So the guidance is: expose synchronous methods as synchronous, and asynchronous ones only when the implementation is really async. WhereTask.Runis right: in UI apps, to keep the UI thread responsive during CPU work, and occasionally on servers to deliberately move a heavy CPU step into a bounded background queue. For blocking legacy SDKs on servers, I isolate them on a few dedicated threads with bounded concurrency until a real async API exists."
// Library: fake async
public Task<Report> GetReportAsync() => Task.Run(() => GetReport()); // GetReport blocks on I/O
// Server call site: "making it async"
app.MapGet("/report", async () => await Task.Run(() => legacy.GetReport()));
| Code | Threads held while waiting | Extra cost |
|---|---|---|
| Synchronous call in the request | 1 pool thread | — |
await Task.Run(blockingCall) |
1 (different) pool thread | Queue hop, task allocation, context switch |
True async I/O (await client.GetAsync(...)) |
0 | — |
Task.Run doesn't create capacity; it moves the blocking. Only true async I/O releases the thread.
| Situation | Acceptable? | Why |
|---|---|---|
| UI app: CPU-heavy work after a click | Yes | The goal is freeing the UI thread |
| UI app: blocking legacy I/O | Yes, as a bridge | Keeps the UI responsive; still costs a pool thread |
| Server: wrap blocking I/O | No (temporary at most) | Still blocks a pool thread; hides starvation |
| Server: heavy CPU step | Sometimes, bounded | Doesn't add CPU; can protect other requests if moved to a limited background queue |
| Library public API | No | Let callers decide; don't lie with Async |
| Tests / console tools | Fine | Scalability doesn't matter |
| Option | Notes |
|---|---|
| 1. Real async alternative | New SDK version, or call the vendor's REST API with HttpClient |
| 2. Dedicated threads + bounded queue | A few named threads own the blocking; callers await a TaskCompletionSource; pool untouched |
| 3. Separate worker process/service | Move the SDK behind a queue (job id + status endpoint) |
| 4. Document as tech debt | With metrics on queue depth and latency |
| Method | Implementation | Name |
|---|---|---|
| Truly async I/O | await on async APIs |
GetReportAsync |
| Blocking | Synchronous | GetReport (and document it) |
| CPU-bound | Synchronous | Calculate (caller offloads if needed) |
The .NET guidance: "don't expose asynchronous wrappers for synchronous methods" and "don't expose synchronous wrappers for asynchronous methods".
Our [reporting API] rendered PDFs with a synchronous library that fetched fonts over the network on first use, and the team had wrapped it in await Task.Run(() => pdf.Render(...)) believing that made it async. At [month-end] load, latency for every endpoint rose to [about 10 s] with CPU at [35%] and a growing pool queue. Dumps showed dozens of pool threads blocked inside the PDF library's network calls, reached through the Task.Run wrapper. I moved rendering to a background job queue processed by [4] dedicated threads, changed the endpoint to return a job id with a status endpoint (202 Accepted), and cached fonts locally so rendering no longer touched the network. The rest of the API returned to normal latency, and we added a guideline banning Task.Run wrappers around I/O in server code.
Checks understanding of what async actually buys you.
Green flags
Task.Run around blocking I/O does not free threads.Red flags
Task.Run everywhere in controllers.request thread R: await Task.Run(Blocking) -> R returns to the pool (good)
pool thread W: Blocking() -> W is blocked for 1 s (bad)
net effect: one blocked pool thread per in-flight request, same as before, plus overhead
At 500 concurrent requests you still need ~500 blocked threads; the pool injects them slowly, which is exactly starvation.
ASP.NET Core requests already run on pool threads, so await Task.Run(Cpu) doesn't increase CPU available; it just hops. When it can help:
Channel<T> with N consumers);When it hurts: wrapping every CPU step in Task.Run (extra hops, no gain), or unbounded offloading (CPU contention for everyone).
public sealed class BlockingGateway : IAsyncDisposable
{
private readonly Channel<Func<Task>> _queue = Channel.CreateBounded<Func<Task>>(200);
private readonly Thread[] _threads;
public BlockingGateway(int threads)
{
_threads = Enumerable.Range(0, threads).Select(i => new Thread(Worker) { IsBackground = true, Name = $"sdk-{i}" }).ToArray();
foreach (var t in _threads) t.Start();
}
private void Worker()
{
foreach (var job in _queue.Reader.ReadAllAsync().ToBlockingEnumerable()) // blocks this dedicated thread only
job().GetAwaiter().GetResult();
}
public async Task<T> RunAsync<T>(Func<T> call, CancellationToken ct)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
await _queue.Writer.WriteAsync(() =>
{
try { tcs.TrySetResult(call()); } catch (Exception ex) { tcs.TrySetException(ex); }
return Task.CompletedTask;
}, ct); // async backpressure for callers
return await tcs.Task.WaitAsync(ct);
}
public ValueTask DisposeAsync()
{
_queue.Writer.TryComplete();
foreach (var t in _threads) t.Join(TimeSpan.FromSeconds(5));
return ValueTask.CompletedTask;
}
}
Only the gateway's threads ever block; callers await; concurrency is bounded by thread count and queue capacity.
Task.RunExceptions inside Task.Run are captured in the task. If you await it, they surface normally; if you fire and forget it, they're unobserved and silently lost. In UI apps, always await the offloaded work inside a try/catch in the handler.
| Smell | Example |
|---|---|
Task.Run returning an ...Async method's result in a library |
=> Task.Run(() => Foo()) |
Task.Run in controllers around DB/HTTP calls |
await Task.Run(() => db.Users.ToList()) |
Task.FromResult(BlockingCall()) |
Looks async, runs synchronously and blocks the caller |
| Interfaces with only async methods implemented synchronously | public Task SaveAsync() { File.WriteAllText(...); return Task.CompletedTask; } (blocks the caller) |
| Version | Change |
|---|---|
| .NET 4.5 | Task.Run; guidance on async wrappers published by the .NET team |
| .NET Core 3.0 | Channels in-box |
| .NET 6 | Parallel.ForEachAsync; managed thread pool |
| .NET 7 | ToBlockingEnumerable for IAsyncEnumerable<T> |
| .NET 10 | Same guidance |
Thread.Sleep(500); endpoint B: await Task.Run(() => Thread.Sleep(500)); endpoint C: await Task.Delay(500). Load-test each at 200 concurrency and compare queue length and threads.dotnet-stack).await Task.Run and compare UI responsiveness.Task.Run that throws and observe that nothing is reported.Task.Run( in server projects and classify each use.using System.Collections.Concurrent;
// Isolate a blocking legacy SDK on dedicated threads with bounded concurrency.
using var gateway = new BlockingSdkGateway(threadCount: 4);
var results = await Task.WhenAll(Enumerable.Range(1, 10)
.Select(i => gateway.RunAsync(() => LegacySdk.Render($"report-{i}"))));
Console.WriteLine(string.Join(", ", results));
public static class LegacySdk
{
public static string Render(string name)
{
Thread.Sleep(200); // blocking network call inside a vendor library
return name;
}
}
public sealed class BlockingSdkGateway : IDisposable
{
private readonly BlockingCollection<Action> _work = new(boundedCapacity: 100);
private readonly Thread[] _threads;
public BlockingSdkGateway(int threadCount)
{
_threads = Enumerable.Range(0, threadCount).Select(i => new Thread(() =>
{
foreach (var item in _work.GetConsumingEnumerable()) item();
}) { IsBackground = true, Name = $"legacy-sdk-{i}" }).ToArray();
foreach (var t in _threads) t.Start();
}
// Task-returning facade that never blocks a ThreadPool thread on the SDK.
public Task<T> RunAsync<T>(Func<T> call)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
_work.Add(() =>
{
try { tcs.SetResult(call()); }
catch (Exception ex) { tcs.SetException(ex); }
});
return tcs.Task;
}
public void Dispose()
{
_work.CompleteAdding();
foreach (var t in _threads) t.Join();
_work.Dispose();
}
}
Note that _work.Add blocks the caller when the queue is full; in a real server use a bounded Channel<T> and WriteAsync.
Task.Run wrappers as ...Async methodsWhat happens: public Task<T> GetAsync() => Task.Run(Get); in a library.
Why it is wrong: callers believe it's non-blocking and use it in servers.
Do instead: expose the synchronous method; implement real async I/O where possible.
What happens: await Task.Run(() => db.Orders.ToList()).
Why it is wrong: a pool thread still blocks; starvation still happens, now hidden.
Do instead: async APIs (ToListAsync), or isolate unavoidable blocking on dedicated threads.
Task.Run around every CPU step in controllersWhat happens: each computation is offloaded. Why it is wrong: extra hops and allocations, no extra CPU, higher latency. Do instead: run CPU work inline; move only rare heavy jobs to a bounded queue.
What happens: heavy computation runs in a click handler.
Why it is wrong: the UI freezes.
Do instead: await Task.Run(...) in UI code.
Task.RunWhat happens: _ = Task.Run(Work);.
Why it is wrong: exceptions are unobserved; no backpressure; lost on shutdown.
Do instead: await it, or use a background queue (see fire-and-forget question).
What happens: SaveAsync() does File.WriteAllText and returns Task.CompletedTask.
Why it is wrong: blocks the caller while promising async.
Do instead: use async APIs inside, or document the blocking.
What happens: every request starts a Task.Run for heavy work.
Why it is wrong: CPU contention and pool exhaustion under load.
Do instead: bound concurrency (queue + fixed consumers).
Task.Run as a cure for starvationWhat happens: "we added Task.Run so requests return faster". Why it is wrong: the same number of threads are blocked; the queue still grows. Do instead: remove blocking or isolate it on dedicated threads.
LongRunning for async lambdas as an "isolation" fixWhat happens: Task.Factory.StartNew(async () => ..., LongRunning).
Why it is wrong: the dedicated thread is released at the first await.
Do instead: dedicated threads for synchronous blocking only.
What happens: a gateway queue fills silently. Why it is wrong: latency grows with no visibility. Do instead: export queue depth and wait time.
The question I ask is: what resource does this protect? Task.Run protects a specific thread (the UI thread), not system capacity. On servers the scarce resource is pool threads, so blocking work must be isolated or removed, not moved around.
| Context | Right approach |
|---|---|
| UI, CPU-heavy | await Task.Run(...) |
| Server, I/O | True async APIs |
| Server, unavoidable blocking SDK | Dedicated bounded threads or worker process |
| Server, heavy CPU job | Background queue, separate service |
| Library | No hidden Task.Run |
Sync-over-async is synchronously blocking on asynchronous work: .Result, .Wait(), GetAwaiter().GetResult() or Task.WaitAll. It holds a thread for the whole operation, causes ThreadPool starvation on servers and deadlocks under a SynchronizationContext. It sneaks in through constructors, DI factory lambdas, property getters, Dispose, lock blocks, synchronous interfaces (IComparer, old filters), static initialisers and "quick fixes" in legacy code. Remove it by making the call chain async, using async factory methods or lazy async initialisation, IAsyncDisposable, hosted-service warm-up, and async-capable interfaces. Where a sync boundary is unavoidable (for example a legacy interface), keep it at the edge and document it.
ThreadPool starvation happens when pool threads are blocked faster than the pool can add new ones, so queued work (requests, continuations, timers) waits. Symptoms: latency rises sharply while CPU stays low, the pool queue length grows, thread count climbs slowly, health checks time out and Kestrel logs heartbeat warnings mentioning possible starvation. I confirm it with dotnet-counters (queue length and thread count), then take dotnet-stack snapshots or a dump to see what the threads are blocked on, usually .Result, .Wait(), synchronous I/O or locks. The fix is to make that code asynchronous; raising SetMinThreads only buys time.
A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.
How to tell it (STAR)
At [month end] our whole API slowed down. The PDF rendering looked async because it was wrapped in `Task.Run`, but the library blocked on network calls, so it still used pool threads. I moved rendering to a separate queue with a few dedicated threads and returned a job id. The rest of the API went back to normal latency.
Last updated 31 Oct 2026.