Async Stack Traces & Debugger Support
Key Points
- Sync stacks show the call chain in a single thread; async stacks must reconstruct the causal chain of awaiters across thread hops.
- Modern .NET (5+) stack-walks the async state machines via
IAsyncStateMachineBox— debuggers andActivity.Currentfollow the chain naturally. Task.RunandActivity.Current—Activity.Currentpropagates viaExecutionContext, so it survivesTask.Run. Sync continuations can lose context if you set it from outside.[StackTraceHidden]marks helper/wrapper frames invisible in stack traces — used for clean exception traces (e.g.,ThrowHelper.ThrowArgumentNullException).[AsyncMethodBuilder]customizes the state machine builder forValueTask-like types andIAsyncEnumerable. Rarely authored directly; senior bar is understanding the role.
Concepts (deep dive)
Why async stacks are hard
A method call is sequential — the OS thread stack records the chain. An await returns control to the caller (or none) and resumes later, possibly on a different thread. The original "stack" is gone; what remains is a graph of state-machine objects on the heap, linked by Task continuations.
Sync: Caller → A → B → C (live on stack)
Async (sync ret): Caller → A.MoveNext → ... (stack)
Async (suspend): Caller's frame returned; A's state machine on heap; awaiting Task X
X completes → continuation runs A's MoveNext on threadpool worker
The state machine sketch
public async Task<int> A()
{
var x = await B(); // suspend, schedule continuation
return x + 1;
}
// Compiler-generated (simplified):
struct AStateMachine : IAsyncStateMachine
{
int _state;
AsyncTaskMethodBuilder<int> _builder;
TaskAwaiter<int> _awaiter;
public void MoveNext()
{
switch (_state)
{
case 0:
_awaiter = B().GetAwaiter();
if (!_awaiter.IsCompleted)
{
_state = 1;
_builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return;
}
goto case 1;
case 1:
int x = _awaiter.GetResult();
_builder.SetResult(x + 1);
return;
}
}
}
The "stack frame" of A lives as fields in AStateMachine (heap-allocated when needed). When B's task completes, the threadpool calls MoveNext() again on the existing instance.
Walking async stacks (modern .NET)
The runtime stores parent-child relationships between state machines. Debuggers walk:
Current Task → originating async method's state machine
→ its awaiter's parent state machine
→ ...up to the root caller
Visual Studio's Tasks window and Parallel Stacks show this. Exception.ToString() includes "async" frames with at MyMethod() in MyFile.cs:line 42.
[StackTraceHidden]
public static class ThrowHelper
{
[DoesNotReturn, StackTraceHidden]
public static void ThrowArgumentNullException(string paramName) =>
throw new ArgumentNullException(paramName);
}
The ThrowHelper frame doesn't appear in stack traces — keeps traces focused on the user's call site. Used by BCL extensively.
[AsyncMethodBuilder]
Tells the compiler which builder type to use for async methods returning MyTask. Lets library authors create custom task-like types (ValueTask, Task, IAsyncEnumerable<T>).
You rarely write these. ValueTask exists for the "completes synchronously most of the time" case to avoid Task allocations.
ExecutionContext flow
AsyncLocal<T>, Activity.Current, CallContext (legacy) ride on ExecutionContext. Awaits capture ExecutionContext and restore it before continuing, so:
Activity.Current = new Activity("op").Start();
await SomeAsync();
// Activity.Current still references the same activity, even on a different thread
Task.Run also captures EC — so tracing context survives.
Loss scenarios
ConfigureAwait(false)— doesn't loseExecutionContext; only avoids capturing theSynchronizationContext.- Setting
AsyncLocal<T>after awaits — change isn't visible to the originator (writes are scoped to current logical call). UnsafeQueueUserWorkItemwithpreferLocal: false— explicitly skips EC capture for perf.- Manually nested threads (
new Thread(...)) — must explicitly flow EC if needed.
Task.Run capture rules
using var activity = ActivitySource.StartActivity("OuterOp");
await Task.Run(async () =>
{
Activity.Current // == "OuterOp" (captured via EC)
using var inner = ActivitySource.StartActivity("InnerOp");
await DoStuff();
});
Inner activity's parent is set to OuterOp via Activity.Current at start.
Async stack traces in exceptions
Modern .NET produces:
System.InvalidOperationException: foo
at MyApp.A() in C:\src\App.cs:line 12
at MyApp.B() in C:\src\App.cs:line 25
at MyApp.C() in C:\src\App.cs:line 40
No EndAwaitOnCompletedHandler noise — those frames are filtered. Tweak with environment variable DOTNET_AsyncStackTraceFilter=0 if you want them back for diagnosis.
Task.FromException vs throwing
async Task FailFast() => throw new InvalidOperationException();
Task FailFast2() => Task.FromException(new InvalidOperationException());
The first captures the async stack; the second has no async frames. Prefer the first for diagnosability.
Debugging tips
- VS Tasks window — see all live tasks and their await dependencies.
dotnet-dumpclrstack -all -i— async-aware stacks in dumps.Trace.CorrelationManager.LogicalOperationStack(legacy) replaced byActivity/AsyncLocal<T>.
Code: correct vs wrong
❌ Wrong: synchronous wrapper losing the async stack
public Task<int> Compute() => Task.FromResult(InternalCompute());
private int InternalCompute() { /* ... */ throw new InvalidOperationException(); }
Stack trace shows only InternalCompute; caller frame absent.
✅ Correct: async wrapper preserves chain
❌ Wrong: setting AsyncLocal after await
async Task Outer()
{
await Task.Yield();
_myAsyncLocal.Value = "x"; // visible only on this branch; original caller doesn't see it
}
✅ Correct: set before await, or use using scope
Design patterns for this topic
Pattern 1 — [StackTraceHidden] on guard helpers
public static class Guard
{
[DoesNotReturn, StackTraceHidden]
public static void NotNull(object? value, [CallerArgumentExpression("value")] string? name = null)
{ if (value is null) throw new ArgumentNullException(name); }
}
Cleaner exception traces.
Pattern 2 — Activity propagation across async
using var activity = _source.StartActivity("Op");
await DoSomethingAsync();
// activity is still the parent for any inner activities
Pattern 3 — Diagnose lost context with logging
When Activity.Current mysteriously drops, log thread-id and activity-id at strategic points to find the boundary that severed it.
Pattern 4 — ValueTask for "usually-synchronous" awaits
public ValueTask<int> ReadCachedAsync(string key)
=> _cache.TryGet(key, out var v) ? new ValueTask<int>(v) : ReadFromStoreAsync(key);
Avoids Task allocation when result is ready.
Pros & cons / trade-offs
| Mechanism | Pros | Cons |
|---|---|---|
| Default async stacks | Causal chain visible | Slight overhead vs sync |
[StackTraceHidden] | Clean traces | Hides helper info |
Custom [AsyncMethodBuilder] | New task-like types | Complex, rare |
ConfigureAwait(false) | Skip context capture | Doesn't break EC |
When to use / when to avoid
- Always:
async/awaitfor async I/O. - Use
[StackTraceHidden]on tiny throw-helpers and library plumbing. - Avoid synchronous wrappers (
Task.FromResultafter running real work) — costs you the async stack. - Avoid
Task.Runto "make sync into async" — wastes a worker thread.
Interview Q&A
Q1. Why is reconstructing async stacks hard? A. Each await returns control; the resumption may run on a different thread. The original call stack is gone; the runtime has to walk heap-resident state machines linked via task continuations.
Q2. What's IAsyncStateMachine? A. The compiler-generated struct/class implementing the awaitable's state machine. MoveNext is invoked when the task it's awaiting completes.
Q3. How does Activity.Current propagate across awaits? A. Via ExecutionContext — captured when await suspends, restored when continuation runs. AsyncLocal<T> uses the same machinery.
Q4. Why does Task.FromException(...) produce a worse stack trace than throwing inside an async method? A. The async method captures the call site as part of its state machine; Task.FromException just wraps an existing exception with no caller context.
Q5. What's [StackTraceHidden]? A. Frames marked with this attribute are skipped in stack traces (Exception.StackTrace, Environment.StackTrace). Used to keep helper plumbing out of error reports.
Q6. How does ConfigureAwait(false) affect the async stack? A. It doesn't — the async stack is still walkable. It only changes whether the SynchronizationContext is captured for the continuation. ExecutionContext (and so Activity.Current) is preserved either way.
Q7. What's [AsyncMethodBuilder] for? A. Lets a return type opt into being a valid async method's return type by specifying its builder. Task, ValueTask, IAsyncEnumerable<T> use this. Custom task-likes for niche scenarios.
Q8. How do you debug a lost AsyncLocal<T> value? A. Log thread-id and AsyncLocal.Value at strategic points; check for Task.Run, raw Threads, UnsafeQueueUserWorkItem, or premature AsyncLocal writes after awaits. Use the Microsoft.Extensions.Diagnostics.Diagnostics AsyncLocal change-tracking API for verbose logs.
Gotchas / common mistakes
- Returning
Task.FromResult(SomeSyncCall())instead ofawait SomeAsync()— kills async stack. async voidmethods — exceptions can't be observed; crashes the process.- Setting
AsyncLocal<T>inside an async method and expecting the caller to see the change. - Thinking
ConfigureAwait(false)dropsActivity.Current— it doesn't. - Logging exceptions with
ex.StackTrace.Substring(...)— you lose async frames. - Using
new Thread(...)for async work — EC doesn't flow automatically; explicitExecutionContext.Capture/Runneeded. Task.Runwrapping naturally async I/O.[StackTraceHidden]on user-relevant code — debuggers can't show what they need.