Почему в Visual Studio стек вызовов асинхронного кода иногда перевёрнут?

Вместе с моим коллегой Евгением мы потратили много времени. Приложение обрабатывает тысячи запросов в асинхронном конвейере, полном async/await. Во время нашего исследования мы получили странные вызовы, они выглядели как бы “перевернутыми”. Цель этого поста — рассказать, почему вызовы могут оказаться перевёрнутыми даже в Visual Studio.


Давайте посмотрим результат профилирования в Visual Studio

Я написал простое приложение .NET Core, которое имитирует несколько вызовов async/await:

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 запускает много задач, выполнения которых будут ожидать другие методы async:

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");
}

В отличие от методов Compute1 и Compute2, последний — Compute3 ждёт одну секунду, прежде чем задействовать какие-то ресурсы CPU и вычислить квадратный корень в хелперах CompusumeCPUXXX:

[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);
        }
}

В Visual Studio использование ЦП этой тестовой программой профилируется через меню Debug | Performance Profiler....

На панели итоговых результатов (Summary result) нажмите ссылку Open Details....

И выберите древовидное представление стека вызовов.

Вы должны увидеть два пути выполнения:

Если открыть последний из них — вы увидите ожидаемую цепочку вызовов:

...если методы были синхронными, что не соответствует действительности. Таким образом, чтобы представить красивый стек вызовов, Visual Studio проделала отличную работу с деталями реализации async/await. Однако если вы откроете первый узел, то получите нечто более тревожное:

... если вы не знаете, как реализованы async/await. Мой код Compute3 определённо не вызывает Compute2, который не вызывает Compute1! Именно здесь реконструкция интеллектуального фрейма/стека вызовов Visual Studio вносит самую большую путаницу. Что же происходит?

Разбираемся с реализацией async/await

Visual Studio скрывает реальные вызовы, но с помощью dotnet-dump и команды pstacks вы сможете увидеть, какие методы на самом деле вызываются:

Пройдя по стрелкам снизу вверх, вы должны увидеть такие асинхронные вызовы

  1. Обратный вызов таймера вызывает <Compute3>d__4.MoveNext(), что соответствует концу Task.Delay в методе Compute3.

  2. <Compute2>d__3.MoveNext() вызывается после await Compute3, чтобы продолжить выполнение кода.

  3. <Compute1>d__.MoveNext() вызывается после await Compute2.

  4. ConsumeCPUAfterCompute2() вызывается как ожидалось.

  5. ComputeCPU() или ConsumeCPUInCompute3() также вызываются как ожидалось.

Все причудливые имена методов связаны с типами “машины состояний”, генерируемых компилятором C#, когда вы определяете асинхронные методы, которые затем ожидают выполнения других асинхронных методов или любой “ожидаемый” объект. Роль этих методов заключается в управлении “машиной состояний”, чтобы синхронно выполнять код до вызова await, затем до следующего вызова await — снова и снова, пока метод возвращается.

Все эти типы <имя метода>d__* содержат поля, соответствующие каждому асинхронному методу локальных переменных и параметров, если таковые имеются. Например, вот что генерируется для методов ComputeAsync и Compute1/2/3 async без локальных переменных или параметров:

Поле integer <>1__state отслеживает “состояние выполнения” машины. К примеру, после создания конечного автомата в Compute1 этому полю присваивается значение -1:

Я не хочу углубляться в детали конструктора, но давайте просто скажем, что метод MoveNext машины состояний <Compute1>d__2 выполняется тем же потоком. Прежде чем рассматривать соответствующую методу Compute1 реализацию MoveNext (без обработки исключений), имейте в виду, что она должна:

  1. Выполнить весь код до вызова await.

  2. Изменить «состояние исполнения» (об этом позже).

  3. Зарядиться магией, чтобы выполнить этот код в другом потоке (об этом позже, когда понадобится).

  4. Вернуться, чтобы продолжить выполнение кода после вызова await.

  5. И делать это до следующего вызова await, снова и снова.

<>1__state равно -1, поэтому выполняется первая "синхронная" часть кода, то есть вызывается метод ComsumeCPU).

Затем для получения соответствующего ожидаемого объекта вызывается метод Compute2 (здесь Task). Если задача выполняется немедленно (т.е. нет вызова await, такого как простая задача . FromResult() в методе async), IsCompleted() вернёт true, и код после вызова await будет выполняться тем же потоком. Да, это означает, что вызовы async/await могут выполняться синхронно одним и тем же потоком: зачем создавать поток, когда он не нужен?

Если задача передаётся в пул потоков для выполнения потоком-воркером, значение <>1__state устанавливается в 0 (поэтому при следующем вызове MoveNext будет выполнена следующая “синхронная” часть (т.е. после вызова await).

Теперь код вызывает awaitUnsafeOnCompleted, чтобы сотворить магию: добавить продолжение к задаче Compute2 (первый параметр — awaiter), чтобы MoveNext был вызван на той же машине состояния (второй параметр — this), когда задача завершится. Затем тихо возвращается текущий поток.

Поэтому, когда заканчивается задача Compute2, её продолжение выполняется для вызова MoveNext, на этот раз с <>1__state в значении 0, поэтому выполняются последние две строки: awaiter.GetResult() возвращается немедленно, потому что возвращённая Compute2 Task уже завершена, и теперь вызывается последний метод — CinsumeCPUAfterCompute2. Вот краткое описание происходящего:

  • Каждый раз, когда вы видите асинхронный метод, с помощью метода MoveNext компилятор C# генерирует выделенный тип конечного автомата, отвечающий за синхронное выполнение кода между вызовами await.

  • Каждый раз, когда вы видите вызов await, это означает, что продолжение добавится к задаче Task, обёртывающей выполняемый метод async. Этот код продолжения вызовет метод MoveNext конечного автомата вызывающего метода, чтобы выполнить следующий фрагмент кода, до следующего вызова await.

Вот почему Visual Studio, пытаясь точно сопоставить каждый фрейм состояния асинхронного метода MoveNext исходя из самого метода, показывает перевёрнутые стеки вызовов: фреймы соответствуют продолжениям после вызовов await (зелёным цветом на предыдущем рисунке).

Обратите внимание, что я более подробно описал, как работает async/await, а также действие AwaitUnsageOnCompleted во время сессии на конференции DotNext с Кевином, поэтому не стесняйтесь смотреть запись с этого момента, если вы хотите углубиться в тему.

А если вы хотите погрузиться в C# — обратите внимание на наш курс по разработке на этом языке. Универсальный стек среди ваших навыков серьёзно укрепит ваши позиции на рынке труда и увеличит доход.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
52 0