Missing Stack trace when eliding the await keyword

25/06/2023
Async.NETC#

You may have heard that when you elide the await keyword in a method that returns a Task or Task<T>, you lose the stack trace. Buy why does that happen? Let's find out!

An example

Have a look at the following code:

using System;
using System.Threading.Tasks;

try
{
    await DoWorkWithoutAwaitAsync();
}
catch (Exception e)
{
    Console.WriteLine(e);
}

static Task DoWorkWithoutAwaitAsync()
{
    return ThrowExceptionAsync();
}

static async Task ThrowExceptionAsync()
{
    await Task.Yield();
    throw new Exception("Hey");
}

The result of running this code is:

System.Exception: Hey  
 at Program.<<Main>$>g__ThrowExceptionAsync|0_1()  
 at Program.<Main>$(String[] args)  

There is no trace of DoWorkWithoutAwaitAsync in the stack trace even though it is called. Let's discuss what happens here.

The state machine

The only difference between ThrowExceptionAsync and DoWorkWithoutAwaitAsync is that the former does not use await. We can have a look at the translated code to see what happens:

[System.Runtime.CompilerServices.NullableContext(1)]
[CompilerGenerated]
internal static Task <<Main>$>g__DoWorkWithoutAwaitAsync|0_0()
{
    return <<Main>$>g__ThrowExceptionAsync|0_1();
}

[System.Runtime.CompilerServices.NullableContext(1)]
[AsyncStateMachine(typeof(<<<Main>$>g__ThrowExceptionAsync|0_1>d))]
[CompilerGenerated]
internal static Task <<Main>$>g__ThrowExceptionAsync|0_1()
{
    <<<Main>$>g__ThrowExceptionAsync|0_1>d stateMachine = default(<<<Main>$>g__ThrowExceptionAsync|0_1>d);
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

The compiler has generated a state machine for ThrowExceptionAsync and not for the DoWorkWithoutAwaitAsync method. Let's have a look inside the state machines internal:

try
{
    YieldAwaitable.YieldAwaiter awaiter;
    // Here is stuff
    awaiter.GetResult();
    throw new Exception("Hey");
}
catch (Exception exception)
{
    <>1__state = -2;
    <>t__builder.SetException(exception);
}

We are setting the task exception onto the Task object and don't throw anything up the stack (like an uncatched throw). The Task object is then awaited in the Main method, and the exception is thrown there.

Here is a small part about stack traces. Stack traces are not a picture where your code is coming from, but more where your code is going to. Now that seems weird. Well, for synchronous code, things are literally the same. Where you are coming from is where you will go back to. But with asynchronous code, that is different. And that is simple to show: If we have something like this:

async Task DoAsyncWork()
{
    // Before
    await DoAsync();
    // After
}

We have a part after the DoAsync - the so called continuation. So once the DoAsync method is done, we will continue with the code after the await. While we are "waiting" for DoAsync we go back to the caller. That is why the stack trace is the same whether or not we look into the past or into the future! But now it gets interesting. If we have a method up the chain that does not await the method, it will just return and is basically done. So the stack trace will not contain the method that is not awaited. So once our DoAsync is done, the continuation continues and will point to the first caller that awaits DoAsyncWork. So everything in between that elided the await keyword is not part of the stack trace.

And I will quote here Eric Lippert:

No. A stack trace does not tell you where you came from in the first place. A stack trace tells you where you are going next. This is useful because there is often a strong correlation between where you came from and where you're going next; usually you're going back to where you came from.

This is not always true though. The CLR can sometimes figure out where to go next without knowing where you came from, in which case the stack trace doesn't contain the information you need.

For example, tail call optimizations can remove frames from the stack. Inlining optimizations can make a call to a method look like part of the calling method. Asynchronous workflows in C# 5 completely divorce "where you came from" and "where you're going next"; the stack trace of an asynchronous method resumed after an await tells you where you are going after the next await, not how you got into the method before the first await.

We can see this if we follow the trail of our async function above. In the first iteration we have the following code:

try
{
    await DoWorkWithoutAwaitAsync();  // -------------------
}                                    //                     |
catch (Exception e)                  //                     |
{                                    //                     |
    Console.WriteLine(e);            //                     |
}                                    //                     |
                                     //                     |
static Task DoWorkWithoutAwaitAsync()  // <----------------
    => ThrowExceptionAsync();

static async Task ThrowExceptionAsync()
{
    await Task.Yield();
    throw new Exception("Hey");
}

Nothing out of the ordinary. The next step looks like this:

try
{
    await DoWorkWithoutAwaitAsync();
}
catch (Exception e)
{
    Console.WriteLine(e);
}

static Task DoWorkWithoutAwaitAsync() 
    => ThrowExceptionAsync();  // ------------------------
                               //                         |
static async Task ThrowExceptionAsync()  // <------------
{
    await Task.Yield();
    throw new Exception("Hey");
}

Now it gets interesting as we are approaching the await. What happens next is, that we are hitting the await boundary and give back control to the caller.

try
{
    await DoWorkWithoutAwaitAsync();  // <-----------------
}                                    //                   |
catch (Exception e)                  //                   |
{                                    //                   |
    Console.WriteLine(e);            //                   |
}                                    //                   |
                               //                         |
static Task DoWorkWithoutAwaitAsync()  // ---------------
    => ThrowExceptionAsync();  // <-----------------------
                               //                         |
static async Task ThrowExceptionAsync()
{
    await Task.Yield();        // ------------------------
    throw new Exception("Hey");
}

We could see that we are passed DoWorkWithoutAwaitAsync as it just returns the ThrowExceptionAsync Task. As it returned entirely, it is gone from the stack trace. But wait, we are still awaiting Task.Yield();. So when Task.Yield is finally done, we are continuing with throw new Exception("Hey");. And guess where the continuation now is pointing to: Exactly, towards our Main method with the try-catch block. So even though in the past we did come from DoWorkWithoutAwaitAsync our stack trace doesn't have this information anymore!

Conclusion

To recap: Stack traces are a look into the future, not into the past. In 99% of cases, this might be the same, except if you elide the await keyword for example.

5
An error has occurred. This application may no longer respond until reloaded. Reload x