Async Performance Pitfalls
Key Points
- Sync-over-async (
.Result,.Wait(),.GetAwaiter().GetResult()) blocks a threadpool thread waiting for I/O — exhausts the pool under load. Task.Runfor CPU-bound only, not to "make async". Wrapping I/O inTask.Runadds a thread hop without benefit.ConfigureAwait(false)in libraries to avoid synchronization context capture. In ASP.NET Core (no SynchronizationContext), redundant — but harmless.ValueTaskoverTaskfor hot paths returning sync most of the time — avoidsTaskallocation.- Threadpool starvation is the #1 production .NET pathology. Diagnose with
dotnet-counters(threadpool-queue-length). async void= unhandled exceptions crash the process. Banned outside event handlers.
Concepts (deep dive)
Sync-over-async
// ❌ Block until task completes — burns a thread
var result = SomeAsync().Result;
SomeAsync().Wait();
SomeAsync().GetAwaiter().GetResult();
Each blocked call holds a threadpool thread idle. Under load, threadpool exhausts → new requests queued → latency cliff.
ASP.NET Core has no SynchronizationContext, so deadlocks are rare — but starvation absolutely happens.
Fix: full async all the way down.
Why Task.Run is not "making something async"
// ❌ Wraps blocking I/O — no benefit, adds thread hop
public async Task<string> ReadFileAsync(string path)
=> await Task.Run(() => File.ReadAllText(path));
// ✅ Use the async API
public Task<string> ReadFileAsync(string path)
=> File.ReadAllTextAsync(path);
Task.Run is for CPU-bound work that would otherwise block the calling thread (UI thread, request thread). For I/O, use the async API.
ConfigureAwait(false)
public async Task<string> LibraryMethodAsync()
{
var data = await SomeIo().ConfigureAwait(false);
return Process(data);
}
In a UI app or old ASP.NET (with SynchronizationContext), await resumes on the captured context. ConfigureAwait(false) says "don't bother — any thread is fine". In libraries, always use it (you don't know your caller's context).
ASP.NET Core has no SynchronizationContext — ConfigureAwait(false) is redundant in app code. Still good in libraries for portability.
There's no perf benefit in ASP.NET Core code — but no harm either.
ValueTask
// Task<T> always allocates a Task object
public async Task<int> GetAsync(int id) => /* often cached */;
// ValueTask<T> avoids allocation when result is sync
public ValueTask<int> GetAsync(int id)
{
if (_cache.TryGetValue(id, out var v)) return ValueTask.FromResult(v);
return new ValueTask<int>(LoadAsync(id));
}
When the cache hits, no Task is allocated. Only when the slow path runs is allocation paid.
Caveats: - Don't await ValueTask twice. - Don't store ValueTask in a field for later await. - ValueTask is bigger (struct) — passing by value has cost.
Use for high-frequency methods where results are often sync (caches, state machines).
IAsyncEnumerable<T>
For streaming results:
public async IAsyncEnumerable<Order> GetOrdersAsync(
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var row in _db.QueryAsync(ct))
yield return MapOrder(row);
}
await foreach (var order in svc.GetOrdersAsync(ct))
Process(order);
Backpressure-friendly. Each item awaited individually; consumer controls pace.
Threadpool starvation diagnosis
Look for: - threadpool-thread-count — climbing, not stabilizing. - threadpool-queue-length — high; work waiting for threads. - monitor-lock-contention-count — contention symptom.
Fix: 1. Find sync-over-async — git grep -E '\.Result|\.Wait\(\)|GetAwaiter\(\).GetResult'. 2. Make async I/O properly await. 3. Use SemaphoreSlim.WaitAsync instead of lock for awaitable critical sections.
async void is banned
// ❌ Unhandled exception → process crash
public async void DoStuffAsync() { await ...; }
// ✅
public async Task DoStuffAsync() { await ...; }
Only acceptable: event handlers (Windows Forms, WPF). Even then, wrap in try/catch.
Avoid creating Tasks unnecessarily
// ❌ Async ceremony for synchronous work
public async Task<int> Add(int a, int b) => a + b;
// ✅
public int Add(int a, int b) => a + b;
Or for cached values:
public Task<int> GetCachedAsync() => Task.FromResult(_cache); // ❌ allocates Task
// vs
public ValueTask<int> GetCachedAsync() => new(_cache); // ✅ no allocation
await using for IAsyncDisposable
Disposes asynchronously — flushes pending I/O properly.
Batching parallel work
// ❌ Sequential
foreach (var url in urls) await _http.GetAsync(url);
// ✅ Parallel with cap
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 10 },
async (url, ct) => await _http.GetAsync(url, ct));
// ✅ Or Task.WhenAll for unbounded
var tasks = urls.Select(u => _http.GetAsync(u));
await Task.WhenAll(tasks);
For I/O-bound parallelism, Parallel.ForEachAsync (with MaxDegreeOfParallelism) avoids unbounded concurrency that overwhelms downstreams.
CancellationToken propagation
public async Task<Order> GetAsync(int id, CancellationToken ct = default)
{
var data = await _db.FindAsync(id, ct);
var related = await _http.GetAsync($"/related/{id}", ct);
return Combine(data, related);
}
Always pass tokens through. Cancellation is performance — abandons doomed work.
Avoid .Result/.Wait even in tests
In test runners with their own SynchronizationContext, sync-over-async deadlocks. Make tests async.
Hot-path async overhead
The state-machine allocation costs ~50 bytes; async machinery overhead ~30ns. For nanosecond-critical code, ValueTask + struct state machines (async IAsyncEnumerable).
Fire-and-forget
// ❌ Lost exception
_ = ProcessAsync();
// ✅ Capture
_ = Task.Run(async () =>
{
try { await ProcessAsync(); }
catch (Exception ex) { _log.LogError(ex, "Background failed"); }
});
Or use IHostedService for background work.
TaskCreationOptions.LongRunning
For long-running CPU-bound work — gives a dedicated thread instead of pool:
Use sparingly — really long work probably belongs in IHostedService.
Stephen Toub's heuristics
- Avoid
async void. - Use
ConfigureAwait(false)in libraries. - Don't
Task.Runto "go async" for I/O. - Prefer
ValueTask<T>for sync-completing hot paths. - Don't allocate when result is sync —
ValueTaskoverTask.FromResult. - Always pass
CancellationToken.
Code: correct vs wrong
❌ Wrong: sync-over-async
public IActionResult Get(int id)
{
var user = _svc.GetUserAsync(id).Result; // blocks
return Ok(user);
}
✅ Correct: async all the way
public async Task<IActionResult> Get(int id)
{
var user = await _svc.GetUserAsync(id);
return Ok(user);
}
❌ Wrong: Task.Run for I/O
✅ Correct: async API
❌ Wrong: unbounded fanout
var tasks = items.Select(i => _http.GetAsync(i.Url)); // 10000 concurrent reqs
await Task.WhenAll(tasks);
✅ Correct: bounded
await Parallel.ForEachAsync(items, new() { MaxDegreeOfParallelism = 20 },
async (i, ct) => await _http.GetAsync(i.Url, ct));
Design patterns for this topic
Pattern 1 — "Async all the way"
- Intent: no
.Result/.Waitever.
Pattern 2 — "ValueTask for hot sync paths"
- Intent: zero allocation on cache hits.
Pattern 3 — "Bounded parallelism"
- Intent: avoid downstream overload.
Pattern 4 — "CancellationToken everywhere"
- Intent: abandon doomed work.
Pattern 5 — "ConfigureAwait(false) in libs"
- Intent: avoid context capture.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Async I/O | High throughput | Complexity |
| ValueTask | No alloc on sync | Constraints; bigger struct |
| Parallel.ForEachAsync | Bounded | Setup verbose |
| Task.WhenAll | Simple parallel | Unbounded |
When to use / when to avoid
- Always async for I/O.
- Use Task.Run only for CPU-bound work that would block.
- Use ValueTask in hot paths.
- Avoid sync-over-async.
- Avoid
async void.
Interview Q&A
Q1. What's threadpool starvation? Pool exhausted; new work queued; latency climbs. Common cause: sync-over-async blocking threads.
Q2. Why is Task.Run(File.Read) wrong? Wraps blocking I/O in a threadpool thread. Doesn't free a thread; adds a hop. Use the async API.
Q3. ValueTask vs Task? Task<T>: reference; allocates. ValueTask<T>: struct; avoids allocation when result is synchronous. Use ValueTask in hot paths.
Q4. ConfigureAwait(false) in ASP.NET Core? No SynchronizationContext → no benefit. But harmless. In libraries, always use it.
Q5. Why async void banned? Unhandled exceptions don't propagate to a Task → process crash.
Q6. How diagnose threadpool starvation? dotnet-counters — threadpool-queue-length, threadpool-thread-count rising.
Q7. Bounded vs unbounded parallelism? Task.WhenAll(items.Select(...)) is unbounded. Parallel.ForEachAsync with MaxDegreeOfParallelism is bounded — avoids overwhelming downstreams.
Q8. CancellationToken — why thread it through? Lets caller abandon work. Saves CPU/IO on doomed requests.
Q9. Async hot-path cost? ~50-byte state machine alloc; ~30ns machinery overhead. Use ValueTask + struct state machines for nanosecond-critical.
Q10. IAsyncEnumerable use case? Streaming results with backpressure. Each item awaited; consumer controls pace.
Q11. await using? Disposes async — flushes pending I/O. Required for IAsyncDisposable.
Q12. Fire-and-forget pattern? Wrap in Task.Run with try/catch + logging. Or use IHostedService for proper lifecycle.
Gotchas / common mistakes
- ⚠️
.Result/.Wait— starvation. - ⚠️
Task.Runfor I/O — pointless thread hop. - ⚠️
async void— process crash on exception. - ⚠️ Awaiting
ValueTasktwice — undefined behavior. - ⚠️ No CancellationToken — wasted work.
- ⚠️ Unbounded
Task.WhenAll— overwhelms downstream.
Further reading
- Stephen Toub: ConfigureAwait FAQ
- Stephen Toub: ValueTask explained
- MS Learn: async best practices
- David Fowler async guidance — https://github.com/davidfowl/AspNetCoreDiagnosticScenarios