Once again about the asynchronous state machine and where exactly are the allocations

Despite the fact that about async/await Many words have already been said and many reports have been recorded, however, in my practice of teaching and mentoring, I often encounter misunderstandings of the device async/await even among Middle+ level developers.

As you know, when compiling an asynchronous method, the compiler transforms the code, breaking it into separate steps. Then, during execution, each step is interrupted by an asynchronous operation. When it ends, you need to understand exactly where to return control – to which specific step. Therefore, all steps are numbered and the compiler very strictly monitors where you can go from where to where. In computer science this solution is called state machine. Also, in Russian they call it state machine. In what follows, for brevity, I will use the abbreviation SM (state machine).

So, in this article we will take a closer look state machinegenerated by the C# compiler from an async method to understand how async works in C#.

“High-level” C#

First, let’s look at an example of simple code in regular (“high-level”) C#.

using System;
using System.Threading.Tasks;
using System.IO;

public class Program {
    private string _fileContent;
    
    public async Task Main() {
        await Task.Delay(100);
        
        int delay = int.Parse(Console.ReadLine());
        await Task.Delay(delay);
        
        _fileContent = await File.ReadAllTextAsync("file1");
        
        await Task.Delay(delay);
    }
}

The code first waits 100 ms, then reads from the console how much more to wait, waits more, reads the data from the file, and waits some more. There is no need to look for logic in the sequence of these calls; the main thing for us here is that these are simply understandable asynchronous calls.

“Low-level” C#

The following is the code that the compiler generates from “high-level” (regular) C#.
I’ll say right away that the original code generated by the compiler looks as if the developers of the code generator did everything to ensure that nothing was incomprehensible to a person. However, for those interested, the original code can be found look at sharplab.io.

In general, I asked The neural network carried out a small refactoring to make the code more readable, and also accompanied significant and non-obvious places with detailed comments. This is what happened (class contents Program):

// Машина состояний (SM)
private sealed class AsyncStateMachine : IAsyncStateMachine
{
    // Определение состояний для машины состояний
    public enum State
    {
        NotStarted,               // Машина состояний не запущена - начальное состояние
        WaitingAfterInitialDelay, // Ожидание после начальной задержки
        WaitingForFileRead,       // Ожидание чтения файла
        WaitingAfterFinalDelay,   // Ожидание после последней задержки
        Finished                  // Завершено
    }

    public State CurrentState; // Текущее состояние машины состояний

    public AsyncTaskMethodBuilder Builder; // Строитель задачи асинхронного метода

    public Program Instance; // Экземпляр программы (оригинального класса)

    private int DelayDuration; // Длительность задержки (переменная delay стала полем машины состояний)

    private string FileContentTemp; // Временное хранение содержимого файла

    private TaskAwaiter DelayAwaiter; // Ожидатель задержки

    private TaskAwaiter<string> ReadFileAwaiter; // Ожидатель чтения файла

    private void MoveNext()
    {
        try
        {
            switch (CurrentState)
            {
                case State.NotStarted:
                    // Запуск начальной задержки
                    DelayAwaiter = Task.Delay(100).GetAwaiter();
                    if (DelayAwaiter.IsCompleted)
                    {
                        /* 
                            В случае если таска сразу после запуска завершилась, произойдет переход к выполнению следующего этапа машины состояний (WaitingAfterInitialDelay)
                            Такое бывает, например, когда в методе с модификатором async нет асинхронных вызовов, либо если мы эвэйтим уже завершенную таску.
                        */
                        goto case State.WaitingAfterInitialDelay;
                    }
                    // Конкретно в этом кейсе, исполнение не зайдет в if, который выше, а выполнит две нижние строки
                    CurrentState = State.WaitingAfterInitialDelay;
                    Builder.AwaitUnsafeOnCompleted(ref DelayAwaiter, ref this);
                    /* 
                        AwaitUnsafeOnCompleted запланирует, что указанная машина состояний (ref this) будет продвинута вперед после завершения работы указанного awaiter'а (будет вызван метод MoveNext).
                        По смыслу это похоже на ContinueWith.
                        [ссылка на исходник под кодом] *
                    */
                    break;

                case State.WaitingAfterInitialDelay:
                    DelayAwaiter.GetResult();
                    /*
                        В случае если в асинхронном методе случился эксепшн, тогда он будет выброшен при вызове GetResult и мы сразу попадем в блок catch.
                    */

                    DelayDuration = int.Parse(Console.ReadLine());
                    DelayAwaiter = Task.Delay(DelayDuration).GetAwaiter();
                    if (DelayAwaiter.IsCompleted)
                    {
                        goto case State.WaitingForFileRead;
                    }
                    CurrentState = State.WaitingForFileRead;
                    Builder.AwaitUnsafeOnCompleted(ref DelayAwaiter, ref this);
                    break;

                case State.WaitingForFileRead:
                    /*
                        Важно, что если выполнение идет по реально асинхронному сценарию (т. е. мы попадаем сюда не из goto case), и используется контекст синхронизации по умолчанию, либо он не задан (что по умолчанию в запросах ASP.NET Core, например), то метод MoveNext() будет вызван из какого-то потока пула потоков. То есть, разные состояния SM могут быть запущены разными потоками.
                        Обычно, нам, программистам, эта особенность не мешает. Но есть редкие кейсы, где это может быть важно - как, например, кейс в одной из задачек на самопроверку ниже в статье.
                    */
                    DelayAwaiter.GetResult();
                    ReadFileAwaiter = File.ReadAllTextAsync("file1").GetAwaiter();
                    if (ReadFileAwaiter.IsCompleted)
                    {
                        goto case State.WaitingAfterFinalDelay;
                    }
                    CurrentState = State.WaitingAfterFinalDelay;
                    Builder.AwaitUnsafeOnCompleted(ref ReadFileAwaiter, ref this);
                    break;

                case State.WaitingAfterFinalDelay:
                    // Завершение чтения файла и установка результата
                    FileContentTemp = ReadFileAwaiter.GetResult();
                    Instance._fileContent = FileContentTemp;
                    FileContentTemp = null;
                    DelayAwaiter = Task.Delay(DelayDuration).GetAwaiter();
                    if (DelayAwaiter.IsCompleted)
                    {
                        CurrentState = State.Finished;
                        return;
                    }
                    CurrentState = State.Finished;
                    Builder.AwaitUnsafeOnCompleted(ref DelayAwaiter, ref this);
                    break;
            }
        }
        catch (Exception exception)
        {
            CurrentState = State.Finished;
            Builder.SetException(exception);
        }
    }
}

private string _fileContent; // Содержимое файла

[AsyncStateMachine(typeof(AsyncStateMachine))]
public Task Main()
{
    AsyncStateMachine stateMachine = new AsyncStateMachine();
    stateMachine.Builder = AsyncTaskMethodBuilder.Create();
    stateMachine.Instance = this;
    stateMachine.CurrentState = AsyncStateMachine.State.NotStarted;
    stateMachine.Builder.Start(ref stateMachine);
    /* 
        Первый вызов MoveNext происходит прямо в stateMachine.Builder.Start. Т. е. первое состояние нашей SM фактически выполняется синхронно (и далее до первого реального асинхронного вызова).
        Исходник **
    */
    return stateMachine.Builder.Task;
}

*Method source code AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted available link
** Source code for this method AsyncStateMachine.Builder.Start available link.

From the code above you can see that, in fact, for every use of the keyword await the compiler generates additional state for the state machine (SM). In addition, it is important to note that the compiler will generate the state machine itself if the modifier is used in the method type definition async.

By the way, this code won’t run because it doesn’t have some helper methods that the compiler generates. But it does help you understand how asynchrony works in C#.

Dealing with allocations

Interestingly, in Debug mode AsyncStateMachine for tasks it is presented as a class, and in Release – as a structure (struct). But even though this is a structure, if execution really follows an asynchronous scenario, under the hood in Runtime allocation will still occur for AsyncStateMachine.

When execution is asynchronous (calling DelayAwaiter.IsCompleted returns false), the CLR needs to move the state machine from the stack to the managed heap; to do this, it is boxed into AsyncStateMachineBox runtime.
For Task it happens inside AsyncTaskMethodBuilder.GetStateMachineBox.
For ValueTask this happens inside the chain (AsyncValueTaskMethodBuilder.AwaitUnsafeOnCompleted -> AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted -> AsyncTaskMethodBuilder.GetStateMachineBox).

Pooling

Another interesting thing is that the CLR provides the ability pooling* AsyncStateMachineBox to minimize allocations (method StateMachineBox RentFromCache()).

* pooling in this context is a technique for reusing data to save space in the application heap and resources G.C.. If the state machine is pooled, the CLR will be able to reuse it for ValueTaskto save on heap allocations. Although, even Stephen Taub doubts in the real effectiveness of this approach.

Separately, I note that the use ValueTask often does not cancel allocations in the case of an asynchronous scenario.

Well, a final note – if you look closely at what happened to the variable delaythen we will see that it was captured and transferred to the field of the state machine (DelayDuration in clean code and <delay>5__2 in the code from the compiler). Accordingly, it is important to understand that if execution proceeds according to an asynchronous scenario (which is quite often), value type variables will be locked together with the state machine and will be moved to a managed heap – therefore Spans are prohibited from being used in methods with a modifier async.

What else to read on the topic

For example, a whole guide from Stephen Taub “How Async/Await Really Works in C#” (“How Async/Await Really Works in C#”). Translations are also available on Habré.

Or an article Prefer ValueTask to Task, always; and don’t await twicewhich describes not only the features ValueTaskbut also how to implement even asynchronous logic through IValueTaskSource or ManualResetValueTaskSourceCoreminimizing the number of memory allocations on the heap.

It’s worth mentioning that the approach from this article is similar to what Sergey Teplyakov @SergeyT did back in 2017 in his article. The difference is that in my example, SM is preserved more accurately in its original form, the code is immediately accompanied by comments inside SM, the issue with allocations is discussed, and various cases are given.

Self-test exercises

Self-test exercises under spoiler
  1. What will the program output?

void Main()
{
    RunAsync(); //"fire-and-forget"
    Console.WriteLine("Main");
    Thread.Sleep(1500);
}

async Task RunAsync()
{
    Console.WriteLine("RunAsync 1");
    await Task.Delay(1000);
    Console.WriteLine("RunAsync 2");
}
Answer

Since the first state will definitely be executed synchronously (it is executed by the calling thread), then immediately upon calling RunAsync “RunAsync 1” will be output to the console, then after running Task.Delay(1000) the calling thread will immediately continue execution and proceed to execution Console.WriteLine("Main")after which it will switch to state WaitSleepJoin (Wait:ExecutionDelay) and falls asleep, then about a second later another thread (thread pool worker) writes “RunAsync 2” to the console. As a result, we get the following conclusion:

RunAsync 1
Main
RunAsync 2
  1. How many SM states will be generated for such code?

async Task Delay1()
{
    await Task.Delay(1);
}
Answer
  • the first state is the stage at which the task is started Task.Delay(1)

  • the second state is continuation with a call GetResultwhich will be executed after the previously launched task is completed.

  • Total: 2 states.

By the way, technically, the state field there can take one more value -2which is set after all operations are completed, but in fact it is equivalent to the initial state.

  1. And for this?

async Task MultiDelay()
{
    var task = Task.Delay(1);
    await task;
    await task;
    await task;
}
Answer
  • 1 to all launch events Task.Delay

  • 1 continiation with call GetResult after the first await

  • then 2 additional states that actually synchronously consume an already completed task (they are executed along the chain through goto)

  • Total: 4 states.

  1. And the final task. How many SM states will be generated for such code?

Task Delay1()
{
    return Task.Delay(1);
}
Answer

The state machine for this code will not be generated at all, because there is no modifier async in the method declaration Delay1.

  1. Bonus: A question that is sometimes asked in interviews about why it is prohibited to use await inside lock. To answer it, it is enough conceptually (simplified) recreate the code in which unfolds keyword lock:

object _syncObj = new();

async Task DelayLocked()
{
    Monitor.Enter(_syncObj);
    await Task.Delay(1);
    Monitor.Exit(_syncObj);
}
Answer

If you have not yet encountered this problem and do not know the answer to it, for better understanding, I would recommend trying to solve it yourself – run the code from the above example, see what it produces, then look at the generated state machine and try to understand what is happening. And you can write about the results of your research in the comments to this article. Spoiler: the result of executing this code depends on the synchronization context.

The material is relevant for version .NET 8.0.1. I will be happy to answer your questions in the comments.

In conclusion, I would like to thank Mark Shevchenko @markshevchenko and Evgeniy Peshkov @epeshk for reviewing this article. Also, in future versions of .NET async/await Maybe be greatly transformed.

Similar Posts

Leave a Reply

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