Pull to refresh

Как на самом деле Async/Await работают в C#. Часть 6. Анализ результатов компиляции асинхронных вызовов

Level of difficultyHard
Reading time24 min
Views5.5K

В этой статье мы продолжим разбирать содержание работы Stephen Toub-а: «How Async/Await Really Works in C#». В этот раз, в след за автором исходного Поста мы рассмотрим код, который генерирует C# компилятор для реализации асинхронных вызовов и множество связанных с этим сущностей-понятий-приемов, таких как: контекст исполнения, боксинг, стейт машина, стек, потоки, … Эта 6-я часть, пожалуй, основная часть всей работы, которая непосредственно отвечает на вопрос: «Как на самом деле Async/Await работают (и компилируются) в C#»

Там, где мне придется цитировать содержание исходного текста в переводе (то есть более-менее дословно переводить), оно будет выделено подчеркнутым курсивом.

Возможно вам будет интересно сравнить эту мою работу с первоначальным переводом.


Итак, автор исходного поста предлагает нам проанализировать как компилируется следующий асинхронный код:

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

Он поясняет нам что этот способ написания асинхронного кода является огромным прогрессом, «глотком свежего воздуха» выражаясь литературно, по сравнению с всеми предыдущими попытками оформления такого асинхронного кода.

Такой способ оформления мало чем отличается от синхронной версии той же самой функции:

public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

Вся разница состоит в том, что мы изменили(дополнили) сигнатуру функции ключевым словом async и поменяли возвращаемое значение с void на Task, мы вызываем асинхронные версии функций: ReadAsync, WriteAsync вместо синхронных Read, Write, и добавили к этим вызовам префикс await. И это все что нужно от программиста, компилятор и библиотеки ядра сделают все остальное, фундаментально меняя то, как выполняется этот код. Давайте заглянем внутрь.

Преобразование кода компилятором

Далее очень активно используется термин "стейт-машина". Этот термин применяется и для типа структуры в которой хранятся данные состояния этой стейт-машины и для функций-методов, которые определяют ее поведение. Иногда этот термин локально обозначает логику которая реализована в основной функции MoveNext() стейт-машины. Насколько я могу судить автор исходного Поста использует этот термин в той же манере.

Мы уже рассматривали ранее что, подобно коду итерируемых функций, код асинхронных методов компилятор переписывает кодом на основе стейт-машины. Тем не менее компилятор генерирует метод с исходной сигнатурой которую написал разработчик исходного кода (public Task CopyStreamToStreamAsync(Stream source, Stream destination)), но тело этого метода будет совершенно иным:

[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    <CopyStreamToStreamAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.source = source;
    stateMachine.destination = destination;
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public Stream source;
    public Stream destination;
    private byte[] <buffer>5__2;
    private TaskAwaiter <>u__1;
    private TaskAwaiter<int> <>u__2;

    ...
}

Обратите внимание, что единственное отличие сигнатуры функции в скомпилированном коде от исходной, которую написал разработчик, - это отсутствие самого ключевого слова async. Модификатор async на самом деле не является частью сигнатуры метода; как и unsafe. Когда вы помещаете его в сигнатуру метода, вы выражаете детали реализации метода (способ преобразования при компиляции), а не что-то, что фактически раскрывается как часть контракта. Использование async/await для реализации метода, возвращающего задачу, является одной из деталей реализации метода компилятором.

Компилятор генерирует структуру с именем <CopyStreamToStreamAsync>d__0, и он инициализирует нулями экземпляр этой структуры в стеке. Важно отметить что, если асинхронный метод завершается синхронно, эта структура никогда не покинет стек. Это означает, что со стейт-машиной не связано никакого выделения памяти на хипе (в управляемой памяти), если метод завершается синхронно. То есть, если метод завершается а-синхронно, то есть подвиснет на одном из выражений помеченных await, то есть подвиснет на чем-то что еще не завершено к моменту возврата из асинхронной функции, память придется выделять. Подробнее об этом чуть позже.

Таким образом эта структура является стейт-машиной для метода, содержащим не только всю преобразованную логику из того, что написал разработчик, но также она содержит поля для отслеживания текущей позиции в этом методе, а также все “локальные” данные состояния, которые компилятор извлек из метода, и которые должны сохраняться между вызовами MoveNext. Это логический эквивалент реализации IEnumerable<T>/IEnumerator<T>, которую мы видели в предыдущем разделе про функции-перечисления. (Обратите внимание, что код, который я показываю, взят из сборки release; в отладочных сборках компилятор C# фактически генерирует эти типы стейт-машин как классы, поскольку это может помочь в определенных случаях при отладке).

После инициализации стейт-машины мы видим вызов AsyncTaskMethodBuilder.Create(). В то время как в настоящее время мы сосредоточены на задачах-Tasks, язык C# и компилятор допускают возврат произвольных типов (типов “подобных-задаче”) из асинхронных методов, например можно написать метод

public async MyTask CopyStreamToStreamAsync

и он будет замечательно компилироваться, если мы доопределим MyTask, который мы ранее написали соответствующим образом. Это соответствие включает в себя объявление связанного типа “builder” и связывание его с типом через атрибут AsyncMethodBuilder:

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public class MyTask
{
    ...
}

public struct MyTaskMethodBuilder
{
    public static MyTaskMethodBuilder Create() { ... }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { ... }
    public void SetStateMachine(IAsyncStateMachine stateMachine) { ... }

    public void SetResult() { ... }
    public void SetException(Exception exception) { ... }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }

    public MyTask Task { get { ... } }
}

В этом контексте такой “builder” — это некоторый класс(-фабрика, смотри паттерн проектирования «фабрика классов») который знает, как создать экземпляр этого типа (свойство Task), завершить его либо успешно и с результатом, если установлен результат (SetResult), либо с исключением (SetException), и обрабатывать подключение продолжений ( смотри разъяснение для термина «продолжение» в предыдущем разделе про функции-перечисления и во всех предыдущих моих статьях посвященных «async/await в C#» и обратите внимание что “builder” — это специальный вспомогательный класс. И объект этого класса нужен компилятору чтобы правильно и до конца реализовать заданный шаблон при компиляции асинхронного метода. Большая часть описания этой скомпилированной логики далее крутится вокруг использования объекта этого класса “builder”)

к выражениям с await, которые еще не были выполнены (AwaitOnCompleted/AwaitUnsafeOnCompleted). В случае с System.Threading.Tasks.Task - типа, этот тип по умолчанию связан с AsyncTaskMethodBuilder. Обычно эта связь обеспечивается с помощью атрибута [AsyncMethodBuilder(...)], применяемого к типу, но Task является предопределенным типом в C# и поэтому не нуждается в таком атрибуте. Таким образом, компилятор будет использовать “builder”-объект для этого асинхронного метода и создает его экземпляр (который нужен чтобы создавать промежуточные объекты Task, на время переключений стейт-машины, которая заменяет этот метод при компиляции), используя статический метод Create, который является частью шаблона реализации асинхронных методов. Обратите внимание, что, как и в случае со структурой стейт-машины, AsyncTaskMethodBuilder также является структурой, поэтому здесь также нет выделения памяти на хипе (в управляемой памяти).

Затем структура стейт-машины заполняется аргументами для метода MoveNext, который после компиляции представляет исходную компилируемую функцию. MoveNext после компиляции представляет логику и все содержание исходной компилируемой асинхронной функции, и, соответственно, эти параметры должны быть доступны для метода MoveNext, поэтому эти аргументы должны храниться в стейт-машине, чтобы код мог ссылаться на них при последующих вызовах MoveNext. Стейт-машина также инициализируется значением -1, которое представляет начальное состояние. Если вызывается MoveNext и состояние равно -1, мы логически попадаем в начало метода.

Теперь самая неочевидная, но наиболее значимая строка: вызов метода Start “builder”-объекта. Это еще одна часть шаблона реализации асинхронных методов, которая должна быть предоставлена типом, который используется в момент возврата из исходного* async метода, и она используется для выполнения начального MoveNext на стейт-машине. Метод Start “builder”-объекта фактически представляет собой именно это:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    stateMachine.MoveNext();
}

*важное пояснение: наша исходная асинхронная функция (async метод)

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)

как и любая другая асинхронная функция, при компиляции заменяется компилятором на объект стейт-машину, который создается при первом и единственном вызове этой нашей исходной функции и который начинает «жить и работать» параллельно (или псевдо-параллельно) коду который вызвал нашу асинхронную функцию и будет продолжать исполняться, в свою очередь, параллельно этой стейт-машине. Работа стейт машины заключается в том, что она будет вызывать нашу исходную функцию как бы по частям, которые компилятор перечислил как состояния этой стейт-машины в функции MoveNext. Опять же, смотри разъяснения по поводу использования стейт-машины в предыдущем разделе про функции-перечисления и во всех предыдущих моих статьях посвященных «async/await в C#».

таким образом, вызов

StateMachine.<>t__builder.Start(ref state Machine);

на самом деле просто вызывает stateMachine.MoveNext(). В таком случае, почему компилятор просто не вызовет MoveNext напрямую? Зачем вообще нужен Start? Ответ заключается в том, что Start требует немного больше логики, чем я показал здесь. Но чтобы в этом разобраться нам нужно сделать краткий экскурс в понимание ExecutionContext.

ExecutionContext -контекст исполнения

Тут я коротко перескажу главные идеи относительно этого термина и соответствующего кода, который представляет эту сущность в результатах генерации C# компилятора, как я их понял (а не как их изложил автор исходного Поста).

Во-первых надо вспомнить что наша структура с данными для стейт-машины и для функции, которая работает как стейт машина была создана на стеке внутри функции

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)

и значит эта структура пропадет при выходе из этой функции, но последующие вызовы кусков этой функции, преобразованных компилятором в состояния стейт машины в функции MoveNext будут нуждаться в данных этой структуры. Проблема еще и в том, что эти вызовы MoveNext могут выполняться в других потоках, а не только в том, из которого вызвана наша исходная функция и в котором изначально создана структура для стейт-машины.

Вообще говоря, эта структура пропала бы даже если бы она была создана на хипе (или создана как класс), так как после выхода из исходной функции на нее не осталось бы ссылок, и ее бы утилизировал сборщик мусора, и он вполне мог бы сделать это сразу после выхода из исходной функции.

Поэтому в этом случае нужен какой-то механизм (логика), который будет просто хранить ссылку на эту структуру (как минимум), и вызывать продолжения запущенной операции (на самом деле ВСЕХ запущенных асинхронных операций) на данный момент с теми данными, с которыми эта асинхронная операция была инициирована-запущена. Совокупность этих данных и составляет этот контекст исполнения – ExecutionContext.

Автор исходного Поста приводит сильно упрощенный и наверно даже не совсем полноценный пример того, как может выглядеть такой механизм, только чтобы проиллюстрировать идею, на сколько я понимаю:

using System.Collections.Concurrent;

var number = new AsyncLocal<int>();

number.Value = 42;
MyThreadPool.QueueUserWorkItem(() => Console.WriteLine(number.Value));
number.Value = 0;

Console.ReadLine();

class MyThreadPool
{
    private static readonly BlockingCollection<(Action, ExecutionContext?)> s_workItems = new();

    public static void QueueUserWorkItem(Action workItem)
    {
        s_workItems.Add((workItem, ExecutionContext.Capture()));
    }

    static MyThreadPool()
    {
        for (int i = 0; i < Environment.ProcessorCount; i++)
        {
            new Thread(() =>
            {
                while (true)
                {
                    (Action action, ExecutionContext? ec) = s_workItems.Take();
                    if (ec is null)
                    {
                        action();
                    }
                    else
                    {
                        ExecutionContext.Run(ec, s => ((Action)s!)(), action);
                    }
                }
            })
            { IsBackground = true }.UnsafeStart();
        }
    }
}

В конце он дает одно интересное замечание относительно этой реализации:

В качестве отступления, вы заметите, что я вызвал UnsafeStart в статическом конструкторе MyThreadPool. Запуск нового потока — это именно та асинхронная точка, в которой применяется сохраненный ExecutionContext. Но обычный метод запуска нового потока использует ExecutionContext.Capture для захвата текущего контекста, и для передачи его в запускаемый поток, то есть он использует этот захваченный контекст далее при вызове делегата ThreadStart потока. Однако как видите я не стал делать так в этом примере, поскольку я не хотел, чтобы потоки фиксировали какой-то непонятный текущий ExecutionContext, который присутствовал при запуске статического конструктора (это могло бы сделать демонстрацию ExecutionContext более запутанной), поэтому вместо этого я использовал метод UnsafeStart. Связанные с потоками методы, начинающиеся с префикса Unsafe, ведут себя точно так же, как соответствующий метод, у которого отсутствует префикс Unsafe, за исключением того, что они не захватывают ExecutionContext, например Thread.Start и Thread.UnsafeStart выполняет идентичную работу, но, в то время как Start захватывает ExecutionContext, UnsafeStart этого не делает.

Вернемся к функции Start

Я надеюсь, что теперь очень просто понять небольшое расширение логики, которое нам демонстрирует автор Поста в обновленной версии этой функции:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    ExecutionContext previous = Thread.CurrentThread._executionContext; // [ThreadStatic] field
    try
    {
        stateMachine.MoveNext();
    }
    finally
    {
        ExecutionContext.Restore(previous); // internal helper
    }
}

Суть в том, что MoveNext может подменить текущий контекст если завершится нештатно, например, поэтому этот контекст надо сначала сохранить, а в конце в любом случае восстановить. Контекст исполнения — это универсальное понятие, он существует не только для асинхронных операций, но асинхронные операции могут его подменять.

Другие пояснения мы пропустим.

Двигаемся дальше, функция MoveNext

Итак, исходный метод был вызван, структура для стейт-машины была проинициализирована, функция Start была вызвана и она вызывает MoveNext. Что такое MoveNext? Это метод, который содержит всю исходную логику исходного метода разработчика, но с целым рядом изменений. Давайте начнем с рассмотрения формирования результатов методом. Вот результат компиляции, который генерирует компилятор для нашего метода, но с удалением всего, что находится внутри сгенерированного блока try:

private void MoveNext()
{
    try
    {
        ... // all of the code from the CopyStreamToStreamAsync method body, but not exactly as it was written
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

Какую бы специальную работу ни выполнял метод MoveNext, он несет ответственность за завершение задачи, возвращаемой из исходного метода, помеченного как async Task, когда вся работа выполнена. Если тело блока try генерирует исключение, которое остается необработанным, то задача будет прервана с этим исключением. И если асинхронный метод успешно достигнет своего завершения (как если бы это был синхронный метод), он успешно завершит возвращенную задачу. В любом из этих случаев этот метод переводит стейт-машину в завершенное состояние. (Я иногда слышу, как разработчики теоретизируют, что, когда дело доходит до исключений, есть разница между теми исключениями, которые генерируются до первого ожидания, и теми, которые генерируются после... исходя из вышесказанного, должно быть ясно, что это не так. Любое исключение, которое остается необработанным внутри асинхронного метода, независимо от того, где оно находится в методе и независимо от того, выдал ли метод результат, попадет в приведенный выше блок перехвата, а перехваченное исключение затем будет сохранено в задаче, возвращаемой из асинхронного метода.)

Также обратите внимание, что это завершение выполняется через builder(класс-объект), используя его методы SetException и SetResult, которые являются частью шаблона для builder, котоорым пользуется компилятор. Если async метод ранее прерывался на какое-то ожидание, разработчику уже пришлось создать задачу как часть обработки этого разрыва-ожидания (скоро мы увидим, как и где), и в этом случае вызов SetException/SetResult завершит эту задачу. Однако, если асинхронный метод ранее не прерывался на ожидание, значит, мы еще не создали задачу и ничего не вернули вызывающей стороне, поэтому у builder-а больше гибкости в том, как он создает эту задачу. Если вы помните, ранее в исходном методе, самое последнее, что он делает, — это возвращает задачу вызывающей стороне, он возвращает результат из свойства Task builder-а (на языке оригинала постоянно приходится использовать слово Task, поэтому автору приходится извиняться за это):

public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    ...
    return stateMachine.<>t__builder.Task;
}

Наш builder знает, приостанавливался ли метод когда-либо, и в этом случае у него есть задача, которая уже была создана, и просто возвращает ее. Если метод никогда не приостанавливался и у builder еще нет задачи, он может создать завершенную задачу здесь. В этом случае, при успешном завершении, он может просто использовать Task.CompletedTask вместо аллокации новой задачи, избегая любоой аллокации. Для дженерик Task<TResult> builder может просто использовать Task.FromResult<TResult>(TResult result).

Обратите внимание всю логику, связанную с созданием задач (Task), разработчики компилятора C# убрали в специальный класс, и вся работа, связанная с созданием задач выполняется через объект builder этого специального класса. Это напоминает мне пример, который вы можете найти в статье с примером иллюстрирующим принципы SOLID.

Объект builder также может выполнять любые преобразования, которые он сочтет подходящими для объектов, которые он создает.

Здесь для примера, автор исходного Поста упоминает разницу между исключениями, произошедшими в обычном режиме выполнения последовательности задач составляющих асинхронную операцию, и исключениями, произошедшими на отмененной async операции или отменяющие ее. Трудно понять разницу без какого-то примера, где это надо различать, к тому же это уводит нас чуть в сторону от основной линии повествования, поэтому ограничимся этим упоминанием.

Теперь, когда мы разобрались с аспектами жизненного цикла внутри асинхронной операции, рассмотрим содержимое блока try в MoveNext:

private void MoveNext()
{
    try
    {
        int num = <>1__state;

        TaskAwaiter<int> awaiter;
        if (num != 0)
        {
            if (num != 1)
            {
                <buffer>5__2 = new byte[4096];
                goto IL_008b;
            }

            awaiter = <>u__2;
            <>u__2 = default(TaskAwaiter<int>);
            num = (<>1__state = -1);
            goto IL_00f0;
        }

        TaskAwaiter awaiter2 = <>u__1;
        <>u__1 = default(TaskAwaiter);
        num = (<>1__state = -1);
        IL_0084:
        awaiter2.GetResult();

        IL_008b:
        awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter();
        if (!awaiter.IsCompleted)
        {
            num = (<>1__state = 1);
            <>u__2 = awaiter;
            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
            return;
        }
        IL_00f0:
        int result;
        if ((result = awaiter.GetResult()) != 0)
        {
            awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter();
            if (!awaiter2.IsCompleted)
            {
                num = (<>1__state = 0);
                <>u__1 = awaiter2;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                return;
            }
            goto IL_0084;
        }
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

Автор Поста утверждает, что эта реализация похожа на ручную реализацию того же исходного метода которую он привел в самом начале статьи при обсуждении модели программирования APM, тут моя интерпретация, только теперь эту работу делает за нас компилятор, переписывая метод в форме с передачей «продолжений» из состояния в состояние!

Вспомните, начальное состояние было инициализировано значением -1 в точке входа. Затем мы входим в MoveNext, обнаруживаем, что это состояние (которое сохраняется в переменной num) не равно ни 0, ни 1, и, таким образом, выполняем код, который создает временный буфер, а затем переходит к метке IL_008b, где он выполняет вызов stream.ReadAsync. Обратите внимание, что на данный момент выполнение происходит все еще синхронно для этого вызова MoveNext и, следовательно, синхронно для вызова Start и, следовательно, синхронно для вызова реализуемой исходной функции, то есть это все еще синхронный вызов исходной функции CopyStreamToStreamAsync, и он все еще выполняется синхронно, еще не вернув обратно задачу, представляющую возможное завершение этого метода. Но это скоро изменится…

Мы вызываем Stream.ReadAsync и получаем от него обратно Task<int>. Чтение могло завершиться синхронно, или оно могло завершиться асинхронно, но так быстро, что теперь оно уже завершено, или, возможно, оно еще не завершилось. Независимо от этого, у нас есть Task<int>, которая представляет свое возможное завершение, и компилятор выдает код, который проверяет эту Task<int>, чтобы определить, как действовать дальше: действительно ли Task<int> уже завершена (не имеет значения, была ли она завершена синхронно или асинхронно к тому времени, когда мы это проверяем), тогда код для этого метода может просто продолжать выполняться синхронно… нет смысла добавлять ненужные накладные расходы, если мы можем просто продолжать работать здесь и сейчас. Но чтобы справиться со случаем, когда Task<int> не завершена, компилятору необходимо выдать код, чтобы подключить продолжение к Task<int>. Таким образом, ему необходимо выдать код, который спрашивает Task<int> “вы закончили?” Обращается ли он напрямую к задаче, чтобы задать этот вопрос?

Это было бы ограничением, если бы единственное, для чего вы могли бы применить await в C#, была бы System.Threading.Tasks.Task. Аналогичным образом, было бы ограничением, если бы компилятор C# должен был знать о каждом возможном типе, к которому он может применить await. Вместо этого C# делает то, что он обычно делает в подобных случаях: он использует шаблон API (шаблон с интерфейсами). Код может ожидать все, что предоставляет подходящий интерфейс, интерфейс “awaiter” (точно так же, как вы можете foreach все, что предоставляет правильный интерфейс “enumerable”). Например, мы можем дополнить тип MyTask, который мы написали ранее, чтобы реализовать шаблон ожидания:

class MyTask
{
    ...
    public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this };

    public struct MyTaskAwaiter : ICriticalNotifyCompletion
    {
        internal MyTask _task;

        public bool IsCompleted => _task._completed;
        public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void GetResult() => _task.Wait();
    }
}

Обратите внимание, автор Поста напоминает нам про использование интерфейсов (a pattern of APIs) как необходимое условие использования, по крайней мере таких ключевых слов из C# как: await, foreach, я могу еще вспомнить using, например, все они требуют, чтобы помеченное выражение приводилось к типу с определенным интерфейсом.

Далее, это конечно неблагодарное дело пересказывать код словами на человеческом языке, хотя это действительно помогает понять логику кода до конца, сравнивая логику кода и то, что про нее написано. И автор Поста, по моему, написал очень полезный сопровождающий-поясняющий текст здесь, мы попробуем воспроизвести это за ним, хотя английский язык все-таки намного ближе к машинному (я где-то слышал), поэтому лучше бы конечно разбирать это по исходному тексту.

Тип может быть awaited, если он предоставляет метод GetAwaiter(), который есть у Task. Этот метод должен возвращать что-то, что, в свою очередь, предоставляет доступ к нескольким элементам, включая свойство IsCompleted, которое используется для проверки в момент вызова IsCompleted, была ли операция уже завершена. И вы можете видеть, как это использует компилятор: в IL_008b для задачи, возвращенной из ReadAsync, вызывается GetAwaiter, а затем IsCompleted запрашивается у этого экземпляра struct awaiter. Если IsCompleted возвращает значение true, то в конечном итоге мы перейдем к IL_00f0, где код вызывает другой член awaiter-а: GetResult(). Если операция завершилась неудачно, функция GetResult() отвечает за создание исключения, чтобы распространить его из await в асинхронном методе; в противном случае функция GetResult() отвечает за возврат результата операции, если таковой имеется. В случае с ReadAsync, если этот результат равен 0, то мы выходим из нашего цикла чтения/записи, переходим к концу метода, где он вызывает SetResult, и все готово.

Еще раз взглянем на эту логику. Обратите внимание, самое интересное случится, когда эта проверка IsCompleted на самом деле вернет значение false. Если она возвращает true, мы просто продолжаем обрабатывать цикл, аналогично шаблону APM, в котором когда CompletedSynchronously возвращает true тогда тот, кто вызвал метод Begin, а не callback, отвечает за продолжение выполнения. Но если IsCompleted возвращает значение false, нам нужно приостановить выполнение асинхронного метода до завершения операции под ожиданием await. А это означает что мы должны организовать возврат из MoveNext, и поскольку при первом вызове это было частью Start, и мы все еще находимся в вызове исходного метода, это означает возврат задачи вызывающей стороне. Но прежде, чем что-либо из этого произойдет, нам нужно подключить продолжение к ожидаемой задаче (отмечая, что во избежание погружений в стек(рекурсии), как в случае APM, в случае APM если асинхронная операция завершается после того, как IsCompleted возвращает значение false, но прежде чем мы подключим продолжение, продолжение все равно должно быть вызвано асинхронно из вызывающего потока, и, таким образом, он будет поставлен в очередь). Поскольку мы можем ожидать чего угодно, мы не можем просто напрямую связаться с экземпляром задачи; вместо этого нам нужно использовать какой-нибудь, определенный шаблоном реализации метод, для выполнения этого действия.

Означает ли это, что awaiter предоставляет метод, который подключит продолжение? Это имело бы смысл; в конце концов, сама задача поддерживает продолжения, имеет метод ContinueWith и т.д. ... Разве не должен GetAwaiter возвращать TaskAwaiter, который предоставляет метод, позволяющий нам подключать продолжение? И это действительно сделано таким образом. Шаблон awaiter требует, чтобы awaiter реализовал интерфейс INotifyCompletion, который содержит единственный метод void OnCompleted(Action continuation). Ожидающий также может опционально реализовать интерфейс ICriticalNotifyCompletion, который наследует INotifyCompletion и добавляет метод void UnsafeOnCompleted(Action continuation). Согласно нашему предыдущему обсуждению ExecutionContext, вы можете догадаться, в чем разница между этими двумя методами: оба подключают продолжение, но, в то время как OnCompleted должен передавать ExecutionContext, для UnsafeOnCompleted в этом нет необходимости. Необходимость в двух различных методах здесь, INotifyCompletion.OnCompleted и ICriticalNotifyCompletion.UnsafeOnCompleted, в значительной степени историческая, связанная с безопасностью доступа к коду, или CAS. CAS больше не существует в .NET Core, и по умолчанию он отключен в .NET Framework, и будет доступен только в том случае, если вы вернетесь к устаревшей модели ограниченной безопасности. Когда используется модель ограниченной безопасности, информация CAS передается как часть ExecutionContext, и, следовательно, ее отсутствие является “небезопасным”, вот почему методам, которые не передают ExecutionContext, был присвоен префикс “Небезопасный”. Такие методы также были отнесены к категории [SecurityCritical], и код с ограниченной безопасностью не может вызвать метод [SecurityCritical]. В результате были созданы два варианта OnCompleted, причем компилятор предпочитал использовать UnsafeOnCompleted, если он предоставлен, но вариант OnCompleted всегда предоставлялся сам по себе на случай, если awaiter должен поддерживать модель ограниченной безопасности. Однако с точки зрения асинхронного метода конструктор всегда передает ExecutionContext через точки с await-ожиданием, поэтому если бы awaiter также выполнял это, это стало бы ненужной и дублирующей работой.

Хорошо, итак, awaiter предоставляет метод для подключения продолжения. Компилятор мог бы использовать его напрямую, за исключением очень важной части головоломки: каким именно должно быть продолжение? И более того, с каким объектом оно должно быть связано? Помните, что структура стейт-машины находится в стеке, и вызов MoveNext, в котором мы сейчас выполняемся, является вызовом метода в этом экземпляре. Нам нужно сохранить структуру стейт-машины, чтобы при возобновлении у нас опять было правильное состояние, что означает, что эта структура не может просто продолжать жить в стеке; ее необходимо скопировать куда-нибудь в хип, поскольку стек в конечном итоге будет использоваться для другой последующей, несвязанной работы, выполняемой этим потоком. И затем продолжение должно вызвать метод MoveNext для этой копии структуры в хипе.

Более того, ExecutionContext также имеет значение здесь. Стейт-машина должена гарантировать, что любые внешние данные, хранящиеся в ExecutionContext, будут захвачены в момент приостановки, а затем применены в момент возобновления, что означает, что продолжение также должно включать этот ExecutionContext. Таким образом, простого создания делегата, указывающего на MoveNext в структуре стейт-машины, недостаточно. Это также нежелательные накладные расходы. Если при приостановке мы создаем делегат, который указывает на MoveNext стейт-машины, каждый раз, когда мы это делаем, мы будем помещать структуру стейт-машины (даже если она уже находится в куче как часть какого-либо другого объекта) и выделять дополнительный делегат (ссылка для делегата на этот объект будет к новой копии структуры). Таким образом, нам нужно выполнить сложный танец, посредством которого мы гарантируем, что мы перемещаем структуру из стека в хип только в первый раз, когда метод приостанавливает выполнение, но во всех остальных случаях использует тот же объект хипа в качестве неявного параметра MoveNext, и в момент перехода к ожиданию гарантирует, что мы захватили правильный контекст, и при возобновлении гарантирует, что мы используем этот захваченный контекст для вызова операции.

В этом гораздо больше логики, чем мы хотели бы, чтобы генерировал компилятор… было бы гораздо лучше, если бы эта логика была инкапсулирована во вспомогательный модуль по нескольким причинам. Во-первых, без этого в сборку каждого пользователя нужно было бы вводить много сложного кода. Во-вторых, мы хотим разрешить настройку этой логики как части реализации шаблона builder (мы увидим пример того, почему, позже, когда будем говорить о пулинге). И в-третьих, мы хотим иметь возможность развивать и улучшать эту логику таким образом, что даже существующие ранее скомпилированные двоичные файлы просто в каком-то смысле станут лучше работать. Это не гипотетическое пожелание! Библиотечный код для поддержки этой логики был полностью переработан в .NET Core 2.1, так что работа стала намного эффективнее, чем в .NET Framework. Мы начнем с изучения того, как именно это работало в .NET Framework, а затем посмотрим, что происходит сейчас в .NET Core.

Вы можете видеть, в коде, сгенерированном компилятором C#, как реализуется переход к ожиданию:

if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
    <>1__state = 1;
    <>u__2 = awaiter;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}

Мы сохраняем в поле state идентификатор состояния, который указывает местоположение, в которое мы должны перейти при возобновлении работы метода. Затем мы сохраняем ссылку на awaiter в поле структуры, чтобы его можно было использовать для вызова GetResult после возобновления. И затем, непосредственно перед возвращением из вызова MoveNext, самое последнее, что мы делаем, это вызываем <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this), запрашивающий у builder-а подключение продолжения к awaiter-у для этой стейт-машины. (Обратите внимание, что он вызывает AwaitUnsafeOnCompleted builder-а, а не AwaitOnCompleted builder-а, потому что awaiter реализует ICriticalNotifyCompletion; стейт-машина обрабатывает текущий ExecutionContext, поэтому нам не нужно требовать, чтобы awaiter тоже обрабатывал... как упоминалось ранее, это было бы просто дублированием и ненужными накладными расходами.)

Реализация этого метода AwaitUnsafeOnCompleted слишком сложна, чтобы копировать ее здесь, поэтому я кратко опишу, что он делает в .NET Framework:

  1. Он использует ExecutionContext.Capture() для захвата текущего контекста.

  2. Затем он выделяет объект MoveNextRunner для переноса как захваченного контекста, так и упакованную(boxed) структуру стейт-машины (которой у нас еще нет, если это первый раз, когда метод уходит в ожидание, поэтому мы просто используем null в качестве значения). «Упакованный(boxed)» фактически означает аллоцированный на хипе, скопированный в управляемую память.

  3. Затем он создает Action делегат для метода Run в этом MoveNextRunner; это тот делегат, который будет вызывать MoveNext стейт-машины в контексте захваченного ExecutionContext.

  4. Если это первый раз, когда метод приостанавливается, у нас еще нет boxed структуры стейт-машины, поэтому в этот раз ее копируют на хип, то есть сохраняют уже как объект управляемой памяти, который предоставляет интерфейс IAsyncStateMachine. Затем этот объект сохраняется в поле MoveNextRunner-объекта, который тоже уже аллоцирован.

  5. Теперь следует несколько ошеломляющий шаг. Если вы посмотрите на определение структуры стейт-машины, оно содержит builder-объект: public AsyncTaskMethodBuilder <>t__builder;, и если вы посмотрите на определение builder-а, оно содержит внутренний IAsyncStateMachine m_stateMachine;. Объекту-builder необходимо ссылаться на структуру стейт-машины на хипе, чтобы при последующих прерываниях для ожидания он мог видеть, что он уже скопировал структуру стейт-машины в хип, и ему не нужно делать это снова. Но мы только что скопировали структуру стейт-машины в хип, и эта структура содержит поле builder у которого есть поле m_state Machine, равное null. Нам нужно видоизменить m_stateMachine-поле builder-а из структуры стейт-машины, чтобы оно указывало этот новый объект стейт-машины на хипе (для хранения на хипе создается врапер-объект как оболочка для структуры). Чтобы выполнить эту замысловатую, но достаточно простую операцию интерфейс IAsyncStateMachine, который реализует сгенерированная компилятором структура стейт-машины, имеет метод void SetStateMachine(IAsyncStateMachine StateMachine);, и эта структура стейт-машины включает реализацию этого метода интерфейса:

private void SetStateMachine(IAsyncStateMachine stateMachine) =>
    <>t__builder.SetStateMachine(stateMachine);

Если коротко: builder сохраняет стейт-машину на хипе создавая ссылку на упакованный объект, а затем передает эту ссылку методу SetStateMachine, который вызывает метод SetStateMachine builder, который только таким образом получает ссылку на скопированную на хип структуру стейт-машины. Вот такая вот, блин, пидерсия.

Тут надо пояснить что это сделано не просто так, то есть сделано совершенно оправдано, так как именно в таком виде эта, казалось бы очень запутанная логика на самом деле легко и непринужденно решает все возможные проблемы любых возможных сценариев использования стейт-машины, к тому же как видите эта запутанность существует только на человеческом языке, потому что мы видим что нужно всего 2 (две!) строчки кода чтобы реализовать это на компьютерном языке!

  1. Наконец, у нас есть метод-Action, представляющий продолжение, и он передается методу UnsafeOnCompleted awaiter-а. В случае TaskAwaiter задача сохранит этот Action в списке продолжений задачи, так что, когда задача завершится, она вызовет Action, через MoveNextRunner.Run, через ExecutionContext.Run и, наконец, будет вызван метод MoveNext стейт-машины, чтобы повторно войти в стейт-машину и продолжить выполнение с того места, где оно остановилось ранее.

Введение в проблему с аллокациями

Далее автор Поста знакомит нас с результатами анализа количества аллокаций для некоторой асинхронной функции, взятой в качестве примера.

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

class Program
{
    static async Task Main()
    {
        var al = new AsyncLocal<int>() { Value = 42 };
        for (int i = 0; i < 1000; i++)
        {
            await SomeMethodAsync();
        }
    }

    static async Task SomeMethodAsync()
    {
        for (int i = 0; i < 1000; i++)
        {
            await Task.Yield();
        }
    }
}

Анализ демонстрирует насколько эффективнее работают библиотеки (и/или код ядра) в .NET Core по сравнению с .NET Framework.

Для версии кода скомпилированной в .NET Framework выполняются миллионы(!) аллокаций для данного примера кода, тогда как тот же код скомпилированный в .NET Core производит всего тысячу аллокаций, соответственно, наблюдаемое использование памяти отличается как ~145MB против примерно 109КБ, разница более чем в тысячу раз.

Пожалуй мы оставим разбор этих результатов и причин которые привели к такой огромной разнице для следующей статьи. Заранее можно сказать что одной из причин которая позволила так кардинально улучшить ситуацию с количеством аллокаций является то, что решение основанное на комбинации задач (Tasks), стейт-машины, генератора задач (AsyncMethodBuilder), аwaiter-а (TaskAwaiter) и других вспомогательных классов и интерфейсов, получилось достаточно гибким, чтобы позволить такие масштабные улучшения эффективности без того чтобы предъявлять новые требования к разработчикам исходного C# кода. Я где-то читал что разработчик языка C# славится своей способностью обеспечить обратную совместимость, и тут мы видим еще один впечатляющий пример демонстрирующий эту способность.

 Выражаю искреннее восхищение тем кто дочитал до конца и смог понять роли которые отводятся вспомогательным классам:

генератора задач-builder-а (AsyncMethodBuilder);

аwaiter-а (TaskAwaiter);

во всем этом перформансе.

С наилучшими пожеланиями,

Сёргий

Tags:
Hubs:
Total votes 5: ↑2 and ↓3+1
Comments17

Articles