Why is the call stack of asynchronous code sometimes reversed in Visual Studio?

Together with my colleague Eugene, we spent a lot of time. The application handles thousands of requests in an asynchronous pipeline full of async / await. During our research, we received strange calls, they seemed to be “upside down”. The purpose of this post is to explain why calls can be reversed even in Visual Studio.


Let’s see the result of profiling in Visual Studio

I wrote a simple .NET Core application that simulates multiple async / await calls:

static async Task Main(string[] args)
{
    Console.WriteLine($"pid = {Process.GetCurrentProcess().Id}");
    Console.WriteLine("press ENTER to start...");
    Console.ReadLine();
    await ComputeAsync();

    Console.WriteLine("press ENTER to exit...");
    Console.ReadLine();
}

private static async Task ComputeAsync()
{
    await Task.WhenAll(
        Compute1(),
        ...
        Compute1()
        ); 
}

ComputeAsync starts many tasks that other async methods will wait for:

private static async Task Compute1()
{
    ConsumeCPU();
    await Compute2();
    ConsumeCPUAfterCompute2();
}


private static async Task Compute2()
{
    ConsumeCPU();
    await Compute3();
    ConsumeCPUAfterCompute3();
}

private static async Task Compute3()
{
    await Task.Delay(1000);
    ConsumeCPUinCompute3();
    Console.WriteLine("DONE");
}

Unlike the Compute1 and Compute2 methods, the latter, Compute3, waits one second before using any CPU resources and calculating the square root in the CompusumeCPUXXX helpers:

[MethodImpl(MethodImplOptions.NoInlining)]
private static void ConsumeCPUinCompute3()
{
    ConsumeCPU();
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void ConsumeCPUAfterCompute3()
{
    ConsumeCPU();
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void ConsumeCPUAfterCompute2()
{
    ConsumeCPU();
}

private static void ConsumeCPU()
{
    for (int i = 0; i < 1000; i++)
        for (int j = 0; j < 1000000; j++)
        {
            Math.Sqrt((double)j);
        }
}

In Visual Studio, the CPU utilization of this test program is profiled through the Debug | Performance Profiler ….

In the Summary result pane, click the Open Details … link.

And select the call stack tree view.

You should see two execution paths:

If you open the last one, you will see the expected chain of calls:

… if the methods were synchronous, which is not true. So, to present a nice call stack, Visual Studio has done an excellent job with the implementation details of async / await. However, if you open the first node, you get something more disturbing:

… if you don’t know how async / await are implemented. My Compute3 code definitely doesn’t call Compute2, which doesn’t call Compute1! This is where the Visual Studio smart frame / call stack reconstruction comes in the most confusion. What’s going on?

Understanding the async / await implementation

Visual Studio hides the actual calls, but with dotnet-dump and the pstacks command, you can see which methods are actually being called:

Following the arrows from bottom to top, you should see asynchronous calls like this

  1. The timer callback calls d__4.MoveNext (), which corresponds to the end of Task.Delay in the Compute3 method.

  2. d__3.MoveNext () is called after await Compute3 to continue executing the code.

  3. d __. MoveNext () is called after await Compute2.

  4. ConsumeCPUAfterCompute2 () is called as expected.

  5. ComputeCPU () or ConsumeCPUInCompute3 () are also called as expected.

All of the fancy method names are associated with the types of “state machines” generated by the C # compiler when you define asynchronous methods, which then wait for other asynchronous methods or any “expected” object to execute. The role of these methods is to control the “state machine” to execute code synchronously until await is called, then until the next await is called — over and over again until the method returns.

All of these d __ * types contain fields corresponding to each asynchronous method of local variables and parameters, if any. For example, this is what is generated for the ComputeAsync and Compute1 / 2/3 async methods without local variables or parameters:

The integer <> 1__state field keeps track of the “execution status” of the machine. For example, after creating a state machine in Compute1, this field is assigned the value -1:

I don’t want to go into the details of the constructor, but let’s just say that the MoveNext method of the state machine d__2 is being executed by the same thread. Before looking at the MoveNext implementation that computes Compute1 (without exception handling), keep in mind that it must:

  1. Execute all code prior to calling await.

  2. Change the “execution state” (more on that later).

  3. Recharge with magic to execute this code on a different thread (more on that later when needed).

  4. Return to continue executing your code after the await call.

  5. And do it again and again before the next await call.

<> 1__state is -1, so the first “synchronous” piece of code is executed, that is, the ComsumeCPU method is called).

The Compute2 method (here Task) is then called to get the corresponding expected object. If the task is executed immediately (ie there is no await call, such as a simple task. FromResult () in the async method), IsCompleted () will return true, and the code after the await call will be executed by the same thread. Yes, this means that async / await calls can be executed synchronously by the same thread: why create a thread when you don’t need one?

If a task is submitted to the thread pool for execution by a worker thread, <> 1__state is set to 0 (so the next call to MoveNext will execute the next “synchronous” part (ie after await).

The code now calls awaitUnsafeOnCompleted to do the magic: add a continuation to the Compute2 task (the first parameter is awaiter) so that MoveNext is called on the same state machine (the second parameter is this) when the task completes. Then the current thread is quietly returned.

So when the Compute2 task finishes, its continuation is executed to call MoveNext, this time with <> 1__state set to 0, so the last two lines are executed: awaiter.GetResult () returns immediately because the returned Compute2 Task has already completed and is now called the last method is CinsumeCPUAfterCompute2. Here’s a quick summary of what’s going on:

  • Whenever you see an asynchronous method, the C # compiler uses the MoveNext method to generate a dedicated state machine type that is responsible for executing code synchronously between await calls.

  • Every time you see a call to await, it means that a continuation will be added to the Task that wraps the async method being executed. This continuation code will call the caller’s state machine’s MoveNext method to execute the next piece of code, before the next await call.

This is why Visual Studio, when trying to accurately map each state frame of the MoveNext async method based on the method itself, shows upside-down call stacks: the frames correspond to the continuation after the await calls (in green in the previous figure).

Please note that I have described in more detail how async / await works, as well as the AwaitUnsageOnCompleted action during a session at a conference DotNext from Kevinso feel free to watch the recording from now onif you want to dig deeper into the topic.

And if you want to dive into C # – take a look at our course on development in this language… A versatile stack of your skills will seriously strengthen your position in the job market and increase your income.

find outhow to level up in other specialties or master them from scratch:

Other professions and courses

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *