В предыдущих сериях

yield return #dotnet #il-code

Пародия на замыкания #dotnet #methods #gc

ThreadPool.Intro #dotnet #threadpool

Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview

Сказка про Method as Parameter #dotnet #methods #gc

Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization

А вы никогда не задумывались, что async и await выглядят как-то инородно среди прочего C# кода? Больше нигде не встречается такого странного синтаксиса и таких модификаторов, кроме как в методах, работающих с Task и Task<T>.

А ещё интересно, сколько вообще стоит пользоваться async/await? И когда можно (нужно?) обходиться без них?

Давайте выясним!

Статья является продолжением предыдущей из серии статей про ThreadPool. Читать предыдущую статью для того, чтобы понять эту, необязательно. Но в дальнейшем это может быть не так.

Художественное отступление, шутка на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом совершенно не обязательно.


Следуй за мной. Я покажу тебе все тайны этого мира.

Ты скажешь, что мы проходили это уже сто раз? Что мы уже были здесь? О нет, мы не ходим по кругу. Этот путь может быть бесконечно похож на тысячи других, которые мы уже проходили. Но он другой. И будут ещё тысячи и тысячи других.

Что, кажется, что мы попали в прошлое? Что такое уже было? Нет, ни в коем случае. Присмотрись внимательно. Присмотрись к мелочам.

Это ещё не волшебство. Это так, дешевые фокусы. Чтобы овладеть настоящей магией, нужно научиться чувствовать каждую мельчайшую частичку этого мира.

Каждая крошка, каждый символ, каждый малейший кусочек этого мира имеет особенный смысл. Бесконечно важный и глубокий. Каждое сформированное слово не просто так. Возникая, они фундаментально меняют всё вокруг.

Нельзя разбрасываться словами просто так. Также, как нельзя разбрасываться смыслом. Если что-то использовать бессмысленно, получится бессмыслица.

Каждое слово, которое использовано, должно быть не просто так. Если ты не знаешь, что это слово значит, не смей использовать его. Иначе придётся отвечать за последствия.

Сложно представить себе современное приложение, которое не является многопоточным и не работает с Task. А в современном C#, наверное, каждый хотя бы раз пользовался async методами и вызывал их с ключевым словом await.

Пользоваться методами, возвращающими Task, очень просто. И нужно. И современный C# очень хорош, позволяет достаточно эффективно и просто пользоваться такими методами, иногда даже не осознавая почти ничего об их устройстве. А современный .Net очень хорошо реализует эту механику, чтобы в большинстве случаев это работало достаточно эффективно.

Сегодня мы совсем легонечко пощупаем таски со стороны их использования в цепочках вызовов методов.

Let's play

Task — очень многогранная штука. За ней может прятаться как закешированный и готовый результат, так и простая idle-пауза. Как сетевой вызов к соседнему сервису, так и нагромождение других Task и их Continuation'ов, выстроенных в сложную цепочку и переиспользующих результаты друг друга, способных работать целыми часами. Поэтому сегодня нам будет важен не «контент» или «полезная нагрузка» Task'а, а то, как с этим Task обращаются в коде.

Task — это вполне обычный .NET объект — класс. Кстати, весьма тяжеловесный. Среди полей этого класса есть, например, «флажок IsCompleted». Или поле «посчитанный ответ», чтобы раздавать его всем налево и направо, если его попросят много раз (естественно после того, как ответ посчитается).

Поэтому метод, который возвращает Task, её не исполняет и даже не дожидается результата её исполнения. Он просто возвращает объект. Он возвращает не результат выполнения Task'и, а саму Task. Так, например, вот такой метод не выполняет никакого ожидания и выполняется мгновенно:

private Task ReturnTaskDelay()
{
    return Task.Delay(100500);
}

Ждать 100500 миллисекунд будет тот, кто попытается дождаться выполнения работы таски. Или попытается извлечь результат выполнения этой таски (у Task.Delay(), правда, нет никакого результата, кроме факта завершения работы). И если к моменту «заглядывания за результатом» этой таски 100500 миллисекунд уже прошло, то результат будет «мгновенным», а иначе, придётся ждать столько, сколько осталось подождать.

Как бы мог выглядеть код, который дожидается результата выполнения этой таски? Как-нибудь так:

public void WaitTaskDelay()
{
    var taskDelayTask = ReturnTaskDelay();
    taskDelayTask.Wait();
}
 
private Task ReturnTaskDelay()
{
    return Task.Delay(100500);
}

Вот метод WaitTaskDelay() уже будет ожидать исполнения Task.Delay(100500), которую метод ReturnTaskDelay() только лишь стартанул и мгновенно вернул. И всё время ожидания метод WaitTaskDelay будет занимать целый поток у .NET'а, не давая никому другому им воспользоваться.

А как ещё мы могли бы написать этот код? Например, используя ключевые слова async и await в методе ReturnTaskDelay (который в такой ситуации принято называть уже с суффиксом Async, т.е. ReturnTaskDelayAsync):

public void WaitTaskDelay()
{
    var taskDelayTask = ReturnTaskDelayAsync();
    taskDelayTask.Wait();
}
 
private async Task ReturnTaskDelayAsync()
{
    await Task.Delay(100500);
}

На первый взгляд эти два куска кода очень похожи. Более того, кажется, что эффект от вызова метода WaitTaskDelay в обоих случаях будет абсолютно одинаковый. Так ли это? Давайте проверим.

Проверяем

Task.Delay() — слишком сложная Task'а. Она чего-то ждёт, там внутри очередь Timer'ов, там ух какие дебри. Давайте возьмём чего-нибудь попроще.

Как на счет «Таски с готовым ответом»?

private static readonly Task<int> CompletedTask = Task.FromResult(42);

Task.FromResult(42) создаёт Task<int> сразу «завершенную» и с «посчитанным ответом 42». Если попросить у такого Task ответ, он «мгновенно» вернётся из закешированного состояния. Как если бы это была Task, которая скачивает ответ с другого сервера, но к моменту вашего обращения за результатом ответ она уже получила и ничего ждать не нужно.

Перепишем наш код с использованием не Task.Delay(), а нашей Task с ответом на главный вопрос жизни, вселенной, и всего такого:

private static readonly Task<int> CompletedTask = Task.FromResult(42);
 
public int CallMethod()
{
    return GetAnswerToTheUltimateQuestionOfLife().Result;
}
 
public int CallAsyncMethod()
{
    return GetAnswerToTheUltimateQuestionOfLifeAsync().Result;
}
 
private Task<int> GetAnswerToTheUltimateQuestionOfLife()
{
    return CompletedTask;
}
 
private async Task<int> GetAnswerToTheUltimateQuestionOfLifeAsync()
{
    return await CompletedTask;
}

Давайте вызовем методы CallMethod и CallAsyncMethod. Сравним с помощью бенчмарка, как они работают?

|          Method |      Mean |  Gen 0 | Allocated |
|---------------- |----------:|-------:|----------:|
|      CallMethod |  1.027 ns |      - |         - |
| CallAsyncMethod | 19.165 ns | 0.0172 |      72 B |

Разница колоссальная. Async метод оказался в ~20 раз медленнее и нааллоцировал каких-то объектов! А мы в коде никаких объектов не создаём.

Изучаем, что это вообще за async await такие

Видимо, ключевые слова async и await таят в себе много интересностей. Ну, ничего страшного, мы такое уже проходили. Заглянем внутрь с помощью ildasm (ILSpy, или иными инструментами изучения IL-кода):

Мы не просто так изучали yield return'ы. Это было просто необходимым шагом для сегодняшнего мероприятия. В этом мире всё не просто так.

А значит, нам сразу должно броситься в глаза то, что внутри нашего класса ThreadPoolAwaitsCost (в этом классе я написал наш код) есть автосгенерированный тип <GetAnswerToTheUltimateQuestionOfLifeAsync>d__3. Кстати, мы не можем сразу предположить, что именно этот тип является тем, которым намусорили при использовании метода CallAsyncMethod. Потому что этот автосгенерированный тип — ValueType, или, проще говоря, struct.

Этот тип реализует некий интерфейс IAsyncStateMachine:

Оставим разбирательство, что нам нагенерировал компилятор, и что это вообще за тип такой. Давайте сравним, чем отличаются методы, которые мы вызывали и бенчмаркали.

CallAsyncMethod и CallMethod выглядят совершенно одинаково. Они просто вызывают следующую функцию (GetAnswerToTheUltimateQuestionOfLifeAsync и GetAnswerToTheUltimateQuestionOfLife соответственно), как и написано у нас в коде, и получают объект Task<int>. А затем вызывают у него Getter свойства Result.

Что ж, заглянем тогда в следующие по цепочке вызовов функции.

Ага. А вот тут уже большое различие.

Не-async метод очень простой. Он берет значение поля CompletedTask и сразу возвращает его. Что, в целом, от него и требовалось. А на async метод давайте посмотрим поближе и покрупнее. Цветом выделены участки кода, на которые следует обратить внимание, мы их сейчас разберём подробнее.

Красненькое. В строке 0002 вызывают метод Create у AsyncTaskMethodBuilder<int>. Потом как-то работают с этим билдером и нашим сгенерированный типом <GetAnswerToTheUltimateQuestionOfLifeAsync>d__3.

Синенькое. В строке 001d всё у того же AsyncTaskMethodBuilder<int> вызывают метод Start. Потом снова как-то манипулируют с билдером и нашим <GetAnswerToTheUltimateQuestionOfLifeAsync>d__3.

Зелёненькое. В строке 0029 наконец-то у AsyncTaskMethodBuilder<int> вызывают Getter у свойства Task. Получают ту самую таску, которую мы должны вернуть из метода. И в конце-концов возвращают.

А мы можем повторить такой же код сами? Написать аналог async метода с await'ом тасок, но без async и await? Да легко:

public int CallBuiltMethod()
{
    return GetAnswerToTheUltimateQuestionOfLifeWithBuilder().Result;
}
 
private Task<int> GetAnswerToTheUltimateQuestionOfLifeWithBuilder()
{
    var machine = default(Machine);
    machine.State = 0;
    machine.Builder = AsyncTaskMethodBuilder<int>.Create();
    machine.Builder.Start(ref machine);
    return machine.Builder.Task;
}
 
private struct Machine : IAsyncStateMachine
{
    public AsyncTaskMethodBuilder<int> Builder;
    public int State;
 
    public void MoveNext()
    {
        if (State == 1)
            return;
 
        State = 1;
        Builder.SetResult(CompletedTask.Result);
    }
 
    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        Builder.SetStateMachine(stateMachine);
    }
}

Можете проверить сами, этот метод тоже возвращает 42. Забенчмаркаем его вместе с остальными.

|          Method |       Mean |  Gen 0 | Allocated |
|---------------- |-----------:|-------:|----------:|
|      CallMethod |  0.7450 ns |      - |         - |
| CallAsyncMethod | 19.3970 ns | 0.0172 |      72 B |
| CallBuiltMethod | 17.8863 ns | 0.0172 |      72 B |

Получилось чуть быстрее. Наверное, просто потому, что компилятор очень громоздко реализовал метод MoveNext() в своём автосгенерированном классе. Он не подозревал, насколько простой у нас случай, и сгенерировал настоящий автомат с кучей состояний, с описанием их переходов. Чтобы работало в общем случае.

Да, тот, кто реализует IAsyncStateMachine, на самом деле реализует просто автомат. Будь наш метод GetAnswerToTheUltimateQuestionOfLifeAsync сложный, с кучей await'ов и условиями переходов между ними, то в автомате сгенерированного кода мы бы встретили по состоянию на, условно, каждый await. И описание всех возможных переходов между ними. А руками собрать такой автомат в собственной реализации IAsyncStateMachine (как мы сделали в GetAnswerToTheUltimateQuestionOfLifeWithBuilder для автомата с одним состоянием) было бы слишком занудным занятием.

Отлично. Теперь мы умеем писать код, который в итоге получается при использовании async и await, но без async и await, с помощью обычных языковых конструкций. И вот, снова получается, что никакого async и await не существует. Это просто такой сахар, чтобы инженерам не приходилось самим писать автоматы (кто прогуливал теорию автоматов в институте?).

Подведём промежуточные итоги

Что мы выяснили? Мы выяснили, что async и await это такие ключевые слова, которые на самом деле не являются частью .NET'а. Это просто сахар в языке C#, который исчезает при компиляции и превращается в обычные .NET объекты и методы, по пути переписывая наш код в другой.

Что нам осталось выяснить?

  1. Посмотреть на то, как бы выглядел автомат в настоящем сложном случае с использованием async и await. Проникнуться логикой того, как действует компилятор и как работает async/await.

  2. Выяснить, а чем же всё-таки намусорил код с async и await и с нашей собственной реализацией IAsyncStateMachine.

Попробуем построить нетривиальный автомат.

Совсем переусложнять циклами, кучей условий и сложными переходами не будем. Возьмём просто несколько await вызовов подряд:

public class AsyncStateMachineDemonstration
{
    private static readonly Task<int> GetTheAnswerToTheUltimateQuestion = Task.FromResult(42);
 
    public async Task<int> Sample()
    {
        Console.WriteLine("Before first await");
 
        var result = await GetTheAnswerToTheUltimateQuestion;
 
        Console.WriteLine($"Between first and second awaits the result is {result}");
 
        await Task.Delay(result);
 
        Console.WriteLine("After second await");
 
        return result + 10;
    }
}

Пора написать автомат, описывающий переходы между await'ами. Почему мы концентрируемся именно на точках с await? Потому что, если нам придётся долго ожидать результата выполнения той Task, которой мы делаем await, мы не хотим блокировать поток .NET'а и сидеть ждать, бессмысленно его занимая, пока та таска завершится.

Мы хотим сказать дотнету: «Дотнет-дотнет, мы запомнили, что остановились на ожидании вон той Task (записали себе в состояние). Мы не знаем, когда она закончит свою работу и не хотим продолжать занимать (собой) твой поток в пустую. Поэтому мы заканчиваем работать. Вызови нас снова (дёрни наш метод MoveNext()), когда эта таска завершится, а мы продолжим с этого момента дальше (ведь мы запомнили себе в состоянии, где мы остановились)».

Разговаривать с Дотнетом очень увлекательно, знаете ли. Давайте ровно это и напишем, только на языке C#. Я оставлю наш Sample, а рядом напишу его альтернативу без async и await с использованием автомата в методе WithStateMachine():

public class AsyncStateMachineDemonstration
{
    private static readonly Task<int> GetTheAnswerToTheUltimateQuestion = Task.FromResult(42);
 
    public async Task<int> Sample()
    {
        Console.WriteLine("Before first await");
 
        var result = await GetTheAnswerToTheUltimateQuestion;
 
        Console.WriteLine($"Between first and second awaits the result is {result}");
 
        await Task.Delay(result);
 
        Console.WriteLine("After second await");
 
        return result + 10;
    }
 
    public Task<int> WithStateMachine()
    {
        var machine = default(Machine);
        machine.State = 0;
        machine.Builder = AsyncTaskMethodBuilder<int>.Create();
        machine.Builder.Start(ref machine);
        return machine.Builder.Task;
    }
 
    private struct Machine : IAsyncStateMachine
    {
        /// <summary>
        /// We don't have conditions in the source code so automaton is so simple.
        /// All transitions are linear: 0 -> 1 -> 2 -> 3 -> 4 -> 5.
        /// 
        /// States from start to first await:
        /// 0 - Initial state.
        /// 1 - First "await" has not been completed and we are going to rest until it is completed.
        /// 
        /// States since the first await to the second:
        /// 2 - First "await" is complete and we have stored the result. 
        /// 3 - Second "await" has not been completed and we are going to rest until it is completed.
        ///
        /// States since to the second await done to the end:
        /// 4 - Second "await" is complete and we have finished waiting delay.
        /// 5 - Terminal state.
        /// </summary>
        public int State;
        public AsyncTaskMethodBuilder<int> Builder;
        private TaskAwaiter<int> awaiter1;
        private TaskAwaiter awaiter2;
        private int result;
 
        public void MoveNext()
        {
            //Do work from start to first await 
            if (State == 0 || State == 1)
            {
                var ultimateQuestionAwaiter = awaiter1;
 
                if (State == 0)
                {
                    //From the original code
                    Console.WriteLine("Before first await");
 
                    //From the original code ("await GetTheAnswerToTheUltimateQuestion;")
                    ultimateQuestionAwaiter = GetTheAnswerToTheUltimateQuestion.GetAwaiter();
                }
 
                if (!ultimateQuestionAwaiter.IsCompleted)
                {
                    awaiter1 = ultimateQuestionAwaiter;
                    State = 1;
 
                    //When ultimateQuestionAwaiter completes his part of the work and becomes Completed, the ThreadPool calls our MoveNext() again!
                    Builder.AwaitUnsafeOnCompleted(ref ultimateQuestionAwaiter, ref this);
                    return;
                }
 
                result = ultimateQuestionAwaiter.GetResult();
                State = 2;
            }
 
            //Do work since first await to the second
            if (State == 2 || State == 3)
            {
                var taskDelayAwaiter = awaiter2;
 
                if (State == 2)
                {
                    //From the original code
                    Console.WriteLine($"Between first and second awaits the result is {result}");
 
                    //From the original code ("await Task.Delay(result);")
                    taskDelayAwaiter = Task.Delay(result).GetAwaiter();
                }
 
                if (!taskDelayAwaiter.IsCompleted)
                {
                    awaiter2 = taskDelayAwaiter;
                    State = 3;
 
                    //When taskDelayAwaiter completes his part of work and becomes Completed, the ThreadPool calls our MoveNext() again!
                    Builder.AwaitUnsafeOnCompleted(ref taskDelayAwaiter, ref this);
                    return;
                }
 
                State = 4;
            }
 
            //Do work since second await done to the end
            if (State == 4)
            {
                //From the original code
                Console.WriteLine("After second await");
 
                //From the original code ("return result + 10;)"
                var resultToSet = result + 10;
 
                State = 5;
                Builder.SetResult(resultToSet);
            }
 
            if (State == 5)
                return;
        }
 
        public void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            Builder.SetStateMachine(stateMachine);
        }
    }
}

Я постарался сделать код максимально читаемым и простым (от чего сам автомат и код стали далеко не самыми оптимальными) и снабдил его комментариями. Надеюсь, что всё было понятно. Код получился довольно простой, но объёмный. Понятно, зачем нужен сахар с async и await. Писать код без них слишком утомительно.

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

Попробуем выяснить, чем же всё-таки мусорит код с автоматом

А тут всё очень просто. Если чуть-чуть покопаться в методах AsyncTaskMethodBuilder, то можно заметить, что когда мы делаем return machine.Builder.Task;, мы собственно и создаём ту самую таску, которую в итоге вернём наружу. И это всегда будет новый объект Task.

Кстати, ещё Builder.SetResult (который нам необходимо сделать в конце работы с автоматом, чтобы вернуть результат) тоже кое-когда может создать новую Task, в которой просто лежит готовый ответ.

Выводы

Есть Task'и. И это обычные объекты. Ими можно обмениваться и пользоваться, как обычным object.

Task'у можно «дождаться, пока работа внутри неё закончится», или «попросить у неё ответ, но перед этим сначала придётся дождаться, пока работа внутри закончится, если ещё не».

Task'у можно «дождаться», используя ключевое слово await, но только в методах, помеченных как async. Если ждать выполнения работы в Task'е таким образом, то дотнет будет «отдыхать» и не занимать поток, пока работа в Task'е не закончится. А потом «разбудит» ваш метод (на самом деле уже не ваш метод, он его переделает в автомат) для продолжения работы с кодом, написанным после await.

Вот только никаких async и await не существует. При компиляции они исчезают, превращаясь в вполне понятный и обычный C# код, описывающий автомат. Работающий с методами ThreadPool'а и Task'ами.

По умолчанию, самым безопасным поведением наверное можно считать использование async-методов с await'ами. Так сложнее ошибиться, нужно меньше думать, у вас всегда будут читабельные стектрейсы. Они придуманы для того, чтобы было комфортно выстраивать сложные цепочки взаимодействия тасок друг с другом, чтобы переиспользовать результаты выполнения Task'ов друг у друга, и при этом не блокироваться на синхронном ожидании выполнения каждой из этих Task (.Result.Wait()GetAwaiter().GetResult()).

Но в случаях, когда вам критична производительность, пользоваться await в async методах нужно осознанно и аккуратно. И иногда можно и вовсе не пользоваться. Например, если у вас есть готовая Task, которую нужно просто вернуть наружу, нет никакого смысла await'ить её. Просто сразу возвращайте, чтобы дотнету не пришлось делать невероятно много сложной работы.

Нужно ли когда-нибудь самостоятельно реализовывать такой автомат вместо использования await'ов? Сомневаюсь. Но в теории могут быть ситуации, когда такое пригодится. Моя реализация WithStateMachine получилась на ~10% эффективнее, чем автосгенерированная по методу Sample ;)

Зачем мы так подробно разбирали, казалось бы, интуитивные в использовании вещи? Зачем узнавали, как оно устроено внутри? Так надо. Так полезно. Эти знания нам ещё пригодятся. А понимание устройства работы тредпула может быть очень полезно на практике.

Комментарии (3)


  1. Hixon10
    15.05.2023 22:17

    А вот в ваших рукописных реализациях async/await ничего нет про барьеры памяти. Но они же иногда должны быть, иначе бы мы иногда не видели изменений, сделанных из асинхронного метода в методе, где мы ждем результат. Интересно, что и в официальных доках я тоже ничего про это не вижу, только в ответах на stackoverflow.


    1. deniaa Автор
      15.05.2023 22:17

      Ваш комментарий интересен и за 5 минут я не придумал лаконичного ответа. Как минимум, будет полезно сначала посмотреть на примеры таких ответов на stackoverflow.

      Но статья всё-таки не о том, как правильно писать автоматы для стейтмашины работы с Task. Поэтому я не буду обещать, что обязательно подробно отвечу на этот вопрос.


      1. Hixon10
        15.05.2023 22:17

        Примеры вот — https://stackoverflow.com/a/54413147/1756750, или https://stackoverflow.com/a/55139219/1756750


        В целом, можно пробовать смотреть JIT ASM в условном https://sharplab.io/, но это не выглядит как надежный способ изучения вопроса, при условии что в sharplab даже Арма нет, где более слабые гарантии памяти. Еще можно в сорцы dotnet смотреть, но это про текущее состояние, а не спецификацию поведения.