Skip to content

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 and Activity.Current follow the chain naturally.
  • Task.Run and Activity.CurrentActivity.Current propagates via ExecutionContext, so it survives Task.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 for ValueTask-like types and IAsyncEnumerable. 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]

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public class MyTask { /* ... */ }

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 lose ExecutionContext; only avoids capturing the SynchronizationContext.
  • Setting AsyncLocal<T> after awaits — change isn't visible to the originator (writes are scoped to current logical call).
  • UnsafeQueueUserWorkItem with preferLocal: 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-dump clrstack -all -i — async-aware stacks in dumps.
  • Trace.CorrelationManager.LogicalOperationStack (legacy) replaced by Activity/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

public async Task<int> Compute()
{
    return await InternalComputeAsync();
}

❌ 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

async Task Outer()
{
    _myAsyncLocal.Value = "x";
    await Task.Yield();
    // visible inside this method
}

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/await for async I/O.
  • Use [StackTraceHidden] on tiny throw-helpers and library plumbing.
  • Avoid synchronous wrappers (Task.FromResult after running real work) — costs you the async stack.
  • Avoid Task.Run to "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 of await SomeAsync() — kills async stack.
  • async void methods — 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) drops Activity.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; explicit ExecutionContext.Capture/Run needed.
  • Task.Run wrapping naturally async I/O.
  • [StackTraceHidden] on user-relevant code — debuggers can't show what they need.

Further reading