Exploring the async/await State Machine – Stack Traces and Refactoring Pitfalls

Example of an Async Stack Trace

What is the output of this Console App?

class Program
{
    static async Task Main(string[] args)
    {
        await MethodOne();
    }

    public static async Task MethodOne()
    {
        await MethodTwo();
    }

    public static async Task MethodTwo()
    {
        await Task.Yield();
        throw new Exception();
    }
}

There’s an unhandled exception, so the full stack trace is printed to the Console:

Unhandled exception. System.Exception: Exception of type 'System.Exception' was thrown.
   at StackTraceDelegation.Program.MethodTwo() in 
D:\StackTraceDelegation\Program.cs:line 18
   at StackTraceDelegation.Program.MethodOne() in 
D:\StackTraceDelegation\Program.cs:line 13
   at StackTraceDelegation.Program.Main(String[] args) in 
D:\StackTraceDelegation\Program.cs:line 10
   at StackTraceDelegation.Program.<Main>(String[] args)

If you’ve been exploring the async/await State Machine so far, this output should look suspicious.

You know that async methods don’t just directly call each other. Instead, the compiler generates a State Machine for each one with a MoveNext method that executes the method body and handles pauses and continuations.

But the stack trace above doesn’t give any clues about this. It looks as if the methods just call each other in a synchronous fashion.

Why is that? Well, it turns out the runtime tries to make our life easier. It puts in the effort to hide the async complexities and present us with this beautiful stack trace.

This hasn’t always been the case. To prove that, see the output from the same program but ran in an older .Net version (4.5.1):

Unhandled Exception: System.Exception: Exception of type 'System.Exception' was thrown.
   at StackTraceDelegation.Program.<MethodTwo>d__2.MoveNext() in 
D:\StackTraceDelegation\Program.cs:line 18
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at StackTraceDelegation.Program.<MethodOne>d__1.MoveNext() in 
D:\StackTraceDelegation\Program.cs:line 13
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at StackTraceDelegation.Program.<Main>d__0.MoveNext() in 
D:\StackTraceDelegation\Program.cs:line 10
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at StackTrace.Program.<Main>(String[] args)

It’s uglier, but it makes a lot more sense. You can clearly see the following (logical) call chain:

MainStateMachine.MoveNext -> MethodOneStateMachine.MoveNext -> MethodTwoStateMachine.MoveNext

Also, recall that async execution pauses when an async operation occurs. At this point, we would lose the call chain if we don’t capture it somewhere. You’ll see the details on this below, but for now, just remember this is one of the responsibilities of the async/await State Machine workflow.

Before digging deeper into all of this magic, I want to concentrate on some direct implications of the good-looking stack trace and how it can lead to hurting your system if you’re not careful.

The “Refactoring”

Frankly, MethodOne doesn’t do anything. It just invokes MethodTwo:

public static async Task MethodOne()
{
    await MethodTwo();   
}

This means that MethodOne doesn’t have to be marked as async. It can just directly return the Task from MethodTwo, like so:

class Program
{
    static async Task Main(string[] args)
    {
        await MethodOne();
    }

    public static Task MethodOne() => MethodTwo();

    public static async Task MethodTwo()
    {
        await Task.Yield();
        throw new Exception();
    }
}

This looks like reasonable refactoring, and you would see it as a suggestion in many code reviews.

Before getting too excited, let’s rerun the program and see the new output:

System.Exception: Exception of type 'System.Exception' was thrown.
   at StackTrace.Program.MethodTwo() in 
D:\StackTraceDelegation\Program.cs:line 18
   at StackTrace.Program.Main(String[] args) in 
D:\StackTraceDelegation\Program.cs:line 10

Did you notice the difference with the initial version? Here it is once again:

Unhandled exception. System.Exception: Exception of type 'System.Exception' was thrown.
   at StackTraceDelegation.Program.MethodTwo() in 
D:\StackTraceDelegation\Program.cs:line 18
   at StackTraceDelegation.Program.MethodOne() in 
D:\StackTraceDelegation\Program.cs:line 13
   at StackTraceDelegation.Program.Main(String[] args) in 
D:\StackTraceDelegation\Program.cs:line 10
   at StackTraceDelegation.Program.<Main>(String[] args)

That’s right – MethodOne has disappeared from the Stack Trace.

How come?

Direct Task Forwarding in Async Methods Hurts Traceability

There are two questions we need to answer now – what happened and why?

The ”what” bit is clear – we lost a part of our stack trace by dropping the async keyword and directly returning the Task from the next method in the call chain.

Why is this bad?

Well, imagine some exception occurs in your Prod environment, and you’re reviewing the error logs. You will definitely expect to see MethodOne over there, won’t you? You may even want to search by MethodOne in your logs dashboard UI, like Kibana. Especially if MethodOne is some public entry point.

I’ve experienced that myself. A method “swallowed” in such a way can raise a lot of confusion and frustration.

I hope you agree.

Now, let’s move to the fun part!

Where Did MethodOne Go?

This section requires familiarity with the async/await machinery, at least at a conceptual level.

Let’s see the stack trace with the missing MethodOne in .Net 4.5.1

Unhandled Exception: System.Exception: Exception of type 'System.Exception' was thrown.
   at StackTraceDelegation.Program.<MethodTwo>d__2.MoveNext() in 
D:\StackTraceDelegation\Program.cs:line 18
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at StackTraceDelegation.Program.<Main>d__0.MoveNext() in 
D:\StackTraceDelegation\Program.cs:line 10
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at StackTrace.Program.<Main>(String[] args)

It’s time to reach out for a decompiler to see how our program looks behind the scenes.

Here is the body of MethodOne, which is precisely the same as the original version:

No surprises here.

Now, I assume you’re familiar with the mechanics of how a method is pushed and popped from the execution stack.

Simply put, once MethodOne returns, it disappears from our program. The caller receives the Task from MethodTwo. MethodOne is popped from the execution stack and vanishes without a trace.

Contrast that with the body of MethodTwo:

Again, if you have no idea why MethodTwo has been transformed in such a way, you may want to read through the Main Workflow and State Transitions article.

MethodTwo was marked with the async keyword. This means the compiler produces a State Machine for it. This State Machine is invoked (indirectly) from the underlined line above.

Then, the MoveNext method of the State Machine will run.

The State Machine and MoveNext for MethodTwo look like so:

At the underlined line, MoveNext (indirectly) registers itself as a continuation to the task awaiter from the Task.Yield() call.

Deep down inside the AwaitUnsafeOnCompleted invocation, the current stack trace is saved within the ExecutionContext so that MethodTwo will be “remembered” as part of the call chain.

This is why we get MethodTwo (and any other async method) in the exceptions’ stack trace.

The code that deals with all of this is quite challenging to follow. Feel free to do that on your own, but here’s just a tiny fragment for some intuition:

How About the Performance Overhead?

To reiterate, marking a method as async means it will be transformed into a State Machine by the compiler.

This certainly brings with itself some time & space execution overhead.

However, except for some sporadic cases, this should be pretty much unnoticeable.

Especially compared to losing segments of your stack traces, thus making potential problems harder to troubleshoot.

Summary

In this article, you’ve seen how stack traces are handled within an async execution.

You also explored how seemingly reasonable refactoring can make methods disappear from the stack trace. This should be avoided as it’s very frustrating when troubleshooting failures.

I hope this was useful. See you next time!

For additional in-depth materials on async/await in C#, you can check this Pluralsight course.

Site Footer

Subscribe To My Newsletter

Email address