Pull to refresh

Как на самом деле работает Async/Await в C# (Часть 6)

Level of difficultyHard
Reading time34 min
Views17K
Original author: Stephen Toub

Так как оригинальная статья довольно объемная, я взял на себя смелость разбить ее на несколько независимых частей, более легких для перевода и восприятия.

Disclaimer: Я не являюсь профессиональным переводчиком, перевод подготовлен скорее для себя и коллег. Я буду благодарен за любые исправления и помощь в переводе, статья очень интересная давайте сделаем её доступной на русском языке.

  1. Часть 1: В самом начале…

  2. Часть 2: Асинхронная модель на основе событий (EAP)

  3. Часть 3: Появление Tasks (Асинхронная модель на основе задач (TAP)

  4. Часть 4: ...и ValueTasks

  5. Часть 5: Итераторы C# в помощь

  6. Часть 6: Async/await: Внутреннее устройство

  7. Часть 7: SynchronizationContext и ConfigureAwait и поля в State Machine

Async/await: Внутреннее устройство

Теперь, когда мы знаем, как мы сюда попали, давайте разберемся, как это работает на самом деле. Для справки, вот наш пример синхронного метода:

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/await:

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

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

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

Как мы уже видели, как и в случае с итераторами, компилятор переписывает асинхронный метод в метод, основанный на машине состояний. У нас по-прежнему есть метод с той же сигнатурой, которую написал разработчик (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 для реализации метода с возвратом Task — это деталь реализации.

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

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

После инициализации машины состояний мы видим вызов AsyncTaskMethodBuilder.Create(). Хотя мы сейчас сосредоточены на Task, язык C# и компилятор позволяют возвращать произвольные типы («task-like» типы) из async методов, например, я могу написать метод 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), а также обрабатывать подключение продолжений к ожиданию того, что еще не завершилось (AwaitOnCompleted/AwaitUnsafeOnCompleted). В случае System.Threading.Tasks.Task он по умолчанию ассоциирован с AsyncTaskMethodBuilder. Обычно эта ассоциация обеспечивается атрибутом [AsyncMethodBuilder(...)], применяемым к типу, но Task существует специально для C# и поэтому фактически не украшен этим атрибутом. В результате компилятор нашел конструктор, который можно использовать для этого асинхронного метода, и конструирует его экземпляр с помощью метода Create, который является частью паттерна. Обратите внимание, что, как и в случае с машиной состояний, AsyncTaskMethodBuilder также является структурой, поэтому здесь также нет выделения памяти.

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

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

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

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

ExecutionContext

Мы все знакомы с передачей состояния от метода к методу. Вы вызываете метод, и если в этом методе указаны параметры, вы вызываете метод с аргументами, чтобы передать эти данные вызывающей стороне. Это явная передача данных. Но есть и другие, более неявные способы. Например, вместо передачи данных в качестве аргументов, метод может быть без параметров, но может предусматривать заполнение некоторых определенных статических полей до вызова метода, а метод будет брать состояние оттуда. Ничто в сигнатуре метода не указывает на то, что он принимает аргументы, потому что это не так: существует просто неявный договор между вызывающей стороной и получателем, что вызывающая сторона может заполнить некоторые области памяти, а получатель может прочитать эти области памяти. Вызывающий и вызываемый могут даже не осознавать, что это происходит, если они являются посредниками, например, метод A может заполнить статику, а затем вызвать B, который вызывает C, который вызывает D, который в конечном итоге вызывает E, который считывает значения этой статики. Такие данные часто называют «окружающими»: они не передаются вам через параметры, а просто находятся где-то рядом и доступны для потребления при желании.

Мы можем сделать еще один шаг вперед и использовать локальное состояние потока. Локальное состояние потока, которое в .NET достигается через статические поля, приписываемые как [ThreadStatic] или через тип ThreadLocal<T>, может быть использовано таким же образом, но с данными, ограниченными только текущим потоком выполнения, причем каждый поток может иметь свою изолированную копию этих полей. Таким образом, вы можете заполнить статическое поле потока, выполнить вызов метода, а затем по завершении метода вернуть изменения в статическое поле потока, обеспечивая полностью изолированную форму таких неявно передаваемых данных.

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

Введите ExecutionContext. Тип ExecutionContext - это средство, с помощью которого окружающие данные переходят от асинхронной операции к асинхронной операции. Он живет в [ThreadStatic], но затем, когда инициируется какая-то асинхронная операция, он «захватывается» (причудливый способ сказать «прочитать копию из этого статического потока»), сохраняется, а затем, когда выполняется продолжение этой асинхронной операции, ExecutionContext сначала восстанавливается, чтобы жить в [ThreadStatic] на потоке, который собирается выполнить операцию. ExecutionContext - это механизм, с помощью которого реализуется AsyncLocal<T> (фактически, в .NET Core ExecutionContext - это полностью AsyncLocal<T>, не более того), так что если вы храните значение в AsyncLocal<T>, а затем, например, ставите в очередь рабочий элемент для выполнения на ThreadPool, это значение будет видно в AsyncLocal<T> внутри рабочего элемента, выполняющегося на пуле:

var number = new AsyncLocal<int>();

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

Console.ReadLine();

Это будет выводить 42 при каждом запуске. Не имеет значения, что в момент после постановки делегата в очередь мы сбросили значение AsyncLocal<int> обратно в 0, потому что ExecutionContext был захвачен как часть вызова QueueUserWorkItem, и этот захват включал состояние AsyncLocal<int> в тот самый момент. Мы можем увидеть это более подробно, реализовав наш собственный простой пул потоков:

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

Здесь MyThreadPool имеет BlockingCollection<(Action, ExecutionContext?)>, которая представляет его очередь рабочих элементов, причем каждый рабочий элемент является делегатом для вызываемой работы, а также ExecutionContext, связанным с этой работой. Статический конструктор для пула создает кучу потоков, каждый из которых просто сидит в бесконечном цикле, получая следующий рабочий элемент и выполняя его. Если для данного делегата не был захвачен ExecutionContext, делегат просто вызывается напрямую. Но если был захвачен ExecutionContext, то вместо прямого вызова делегата мы вызываем метод ExecutionContext.Run, который восстановит предоставленный ExecutionContext в качестве текущего контекста до запуска делегата, а затем сбросит контекст после этого. Этот пример содержит точно такой же код с AsyncLocal, как показанный ранее, только на этот раз используется MyThreadPool вместо ThreadPool, но он все равно будет выводить 42 каждый раз, потому что пул правильно перетекает ExecutionContext.

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

Вернемся к началу

Мы отклонились от обсуждения ExecutionContext, когда я писал о реализации AsyncTaskMethodBuilder.Start, которая, по моим словам, была эффективной:

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

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

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
    }
}

Вместо того, чтобы просто вызвать stateMachine.MoveNext(), как я предлагал ранее, мы выполняем процедуру получения текущего ExecutionContext, затем вызываем MoveNext, а после его завершения сбрасываем текущий контекст обратно в тот, который был до вызова MoveNext.

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

async Task ElevateAsAdminAndRunAsync()
{
    using (WindowsIdentity identity = LoginAdmin())
    {
        using (WindowsImpersonationContext impersonatedUser = identity.Impersonate())
        {
            await DoSensitiveWorkAsync();
        }
    }
}

«Имперсонация» - это изменение окружающей информации о текущем пользователе на информацию о ком-то другом; это позволяет коду действовать от имени другого человека, используя его привилегии и доступ. В .NET такая имперсонация происходит в асинхронных операциях, что означает, что она является частью ExecutionContext. Теперь представьте, что Start не восстановил предыдущий контекст, и рассмотрим этот код:

Task t = ElevateAsAdminAndRunAsync();
PrintUser();
await t;

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

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 выбросит исключение, которое не будет обработано, то задача будет завершена с этим исключением. Если же метод async успешно достигнет своего конца (что эквивалентно возвращению синхронного метода), то он успешно завершит возвращенную задачу. В любом из этих случаев он устанавливает состояние машины состояний, указывающее на завершение. (Иногда я слышу, как разработчики теоретизируют, что, когда речь идет об исключениях, есть разница между исключениями, брошенными до первого await и после... исходя из вышесказанного, должно быть ясно, что это не так. Любое исключение, которое остается необработанным внутри async метода, независимо от того, где оно находится в методе, и независимо от того, завершился ли метод, попадает в вышеуказанный блок catch, а пойманное исключение затем сохраняется в Task, возвращаемом из async метода).

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

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

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

Конструктор также может делать любые преобразования, которые он считает подходящими для типа создаваемого объекта. Например, Task фактически имеет три возможных конечных состояния: успех, неудача и отмена. Метод SetException конструктора AsyncTaskMethodBuilder специально обрабатывает OperationCanceledException, переводя задачу в конечное состояние TaskStatus.Canceled, если предоставленное исключение является OperationCanceledExceptionили производным от него; в противном случае задача завершается как TaskStatus.Faulted. Такое различие часто не заметно в исполняющем коде; поскольку исключение сохраняется в Task независимо от того, помечено оно как Canceled или Faulted, код, ожидающий Task, не сможет заметить разницу между состояниями (исходное исключение будет распространено в любом случае)... это влияет только на код, взаимодействующий с Task напрямую, например, через ContinueWith, который имеет перегрузки, позволяющие вызывать продолжение только для подмножества состояний завершения.

Теперь, когда мы понимаем аспекты жизненного цикла, вот все, что заполняется внутри блока 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();
}

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

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

Было бы ограничением, если бы единственной вещью, которую можно было бы ожидать в C#, была System.Threading.Tasks.Task. Аналогично, было бы ограничением, если бы компилятор C# должен был знать о каждом возможном типе, который может быть ожидаемым. Вместо этого C# поступает так, как обычно поступает в подобных случаях: он использует шаблон API. Код может ожидать все, что раскрывает соответствующий шаблон, шаблон «ожидающий» (точно так же, как вы можете выполнять foreach везде, где используется соответствующий шаблон «перечислимый»).

Например, мы можем дополнить тип 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();
    }
}

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

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

Означает ли это, что в awaiter есть метод, который подключает продолжение? Это было бы логично; в конце концов, сама Task поддерживает продолжения, имеет метод ContinueWith и т.д... не должен ли TaskAwaiter, возвращаемый из GetAwaiter, предоставлять метод, позволяющий нам установить продолжение? На самом деле, так и есть. Модель awaiter требует, чтобы awaiter реализовывал интерфейс INotifyCompletion, который содержит единственный метод void OnCompleted(Action continuation). Ожидающий может также опционально реализовать интерфейс ICriticalNotifyCompletion, который наследует INotifyCompletion и добавляет метод void UnsafeOnCompleted(Action continuation). Исходя из нашего предыдущего обсуждения ExecutionContext, вы можете догадаться, в чем разница между этими двумя методами: оба подключают продолжение, но если OnCompleted должен передавать ExecutionContext, то UnsafeOnCompleted не нужно. Необходимость в двух разных методах, INotifyCompletion.OnCompleted и ICriticalNotifyCompletion.UnsafeOnCompleted, во многом историческая, связанная с Code Access Security, или CAS. CAS больше не существует в .NET Core, а в .NET Framework она отключена по умолчанию, и проблемы появляются только в том случае, если вы снова используете устаревшую функцию частичного доверия. При использовании частичного доверия информация CAS передается как часть ExecutionContext, и поэтому ее отсутствие является "небезопасным", поэтому методы, которые не передают ExecutionContext, имеют префикс "Unsafe". Такие методы также были отнесены к [SecurityCritical], а частично доверенный код не может вызывать метод [SecurityCritical]. В результате было создано два варианта OnCompleted, причем компилятор предпочитает использовать UnsafeOnCompleted, если он предусмотрен, но вариант OnCompleted всегда предоставляется сам по себе на случай, если ожидающему потребуется поддержка частичного доверия. Однако, с точки зрения асинхронных методов, конструктор всегда передает ExecutionContext через точки ожидания, поэтому awaiter, который также делает это, является ненужной и дублирующей работой.

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

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

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

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

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

2.Затем он выделяет объект MoveNextRunner, чтобы обернуть как захваченный контекст, так и машину состояний бокса (которой у нас еще нет, если метод приостанавливается в первый раз, поэтому мы просто используем null как заполнитель).

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

4.Если метод приостанавливается в первый раз, у нас еще нет боксированной машины состояний, поэтому в этот момент он боксирует ее, создавая копию на куче, сохраняя экземпляр в локальном, типизированном как интерфейс IAsyncStateMachine. Этот ящик затем сохраняется в MoveNextRunner, который был выделен.

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

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

Поэтому билдер помещает машину состояния в бокс, а затем передает этот бокс в метод SetStateMachine бокса, который вызывает метод SetStateMachine билдера, который сохраняет бокс в поле. Фух.

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

Именно это происходит в .NET Framework, и вы можете наблюдать результат этого в профайлере, например, запустив профайлер выделения, чтобы увидеть, что выделяется на каждом ожидании. Возьмем эту глупую программу, которую я написал только для того, чтобы продемонстрировать затраты на выделение памяти:

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

Эта программа создает AsyncLocal для передачи значения 42 через все последующие асинхронные операции. Затем она вызывает SomeMethodAsync 1000 раз, каждая из которых приостанавливается/возобновляется 1000 раз. В Visual Studio я запускаю это с помощью профайлера .NET Object Allocation Tracking, который дает следующие результаты:

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

  • ExecutionContext. Их выделяется более миллиона. Почему? Потому что в .NET Framework ExecutionContext - это изменяемая структура данных. Поскольку мы хотим передавать данные, которые были на момент форка асинхронной операции, и не хотим, чтобы они потом видели мутации, выполненные после форка, нам нужно скопировать ExecutionContext. Каждая форкированная операция требует такой копии, поэтому при 1000 вызовов SomeMethodAsync, каждый из которых приостанавливается/возобновляется 1000 раз, мы имеем миллион экземпляров ExecutionContext. Ой.

  • Action. Аналогично, каждый раз, когда мы ожидаем чего-то, что еще не завершено (как в случае с нашим миллионом await Task.Yield()), мы выделяем новый делегат Action для передачи в метод UnsafeOnCompleted этого ожидающего.

  • MoveNextRunner. То же самое; их миллион, так как в описании предыдущих шагов каждый раз, когда мы приостанавливаем выполнение, мы выделяем новый MoveNextRunner для хранения Action и ExecutionContext, чтобы выполнить первый с помощью второго.

  • LogicalCallContext. Еще миллион. Это детали реализации AsyncLocal<T> в .NET Framework; AsyncLocal<T> хранит свои данные в "логическом контексте вызова" ExecutionContext, что является причудливым способом сказать общее состояние.

  • QueueUserWorkItemCallback. Каждый Task.Yield() ставит в очередь рабочий элемент в пул потоков, что приводит к выделению миллиона объектов рабочих элементов, используемых для представления этих миллионов операций.

  • Task. Их тысячи, так что, по крайней мере, мы вышли из клуба "миллионов". Каждый async Task, который завершается асинхронно, должен выделить новый экземпляр Task, чтобы представить окончательное завершение этого вызова.

  • <SomeMethodAsync>d__1. Это бокс сгенерированнsq компилятором структуры машины состояний. 1000 методов приостанавливаются, возникает 1000 боксов.

  • QueueSegment/IThreadPoolWorkItem[]. Их несколько тысяч, и технически они связаны не столько с асинхронными методами, сколько с работой, которая ставится в очередь в пул потоков в целом. В .NET Framework очередь пула потоков представляет собой связанный список некольцевых сегментов. Эти сегменты не используются повторно; для сегмента длиной N, после того как N рабочих элементов были поставлены в очередь и сняты с нее, сегмент отбрасывается и отправляется в сборку мусора.

Это был .NET Framework. Это .NET Core:

Так намного красивее! Для этого примера на .NET Framework было более 5 миллионов выделений на общую сумму ~145 МБ выделенной памяти. Для того же примера на .NET Core вместо этого было всего ~1000 выделений общим объемом ~109 КБ. Почему так мало?

  • ExecutionContext. В .NET Core ExecutionContext теперь неизменяемый. Недостатком этого является то, что каждое изменение контекста, например, установка значения в AsyncLocal<T>, требует выделения нового ExecutionContext. Однако плюсом является то, что передача контекста происходит гораздо, гораздо, гораздо чаще, чем его изменение, а поскольку ExecutionContext теперь неизменяем, нам больше не нужно клонировать его в процессе передачи. «Захват» контекста - это буквально просто чтение его из поля, а не чтение и клонирование его содержимого. Таким образом, передача не только намного, намного, намного чаще, чем изменение, но и намного, намного, намного дешевле.

  • LogicalCallContext. В .NET Core этого больше не существует. В .NET Core единственное, для чего существует ExecutionContext, - это хранилище для AsyncLocal. Другие вещи, которые имели свое особое место в ExecutionContext, моделируются в терминах AsyncLocal. Например, имперсонализация в .NET Framework будет проходить как часть SecurityContext, который является частью ExecutionContext; в .NET Core имперсонализация проходит через AsyncLocal, который использует valueChangedHandler для внесения соответствующих изменений в текущий поток.

  • QueueSegment/IThreadPoolWorkItem[]. В .NET Core глобальная очередь ThreadPool теперь реализована как ConcurrentQueue<T>, а ConcurrentQueue<T> был переписан как связный список циклических сегментов нефиксированного размера. Как только размер сегмента становится достаточно большим, чтобы он никогда не заполнялся, поскольку удаления из очереди не отстают от добавлений в очередь, нет необходимости выделять дополнительные сегменты, и один и тот же достаточно большой сегмент просто используется бесконечно.

А как насчет остальных распределений, таких как Action , MoveNextRunner и <SomeMethodAsync>d__1 ? Понимание того, как были удалены оставшиеся выделения, требует погружения в то, как это теперь работает в .NET Core.

Давайте отмотаем нашу дискуссию назад, когда мы обсуждали, что происходит во время приостановки:

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

Код, который здесь выдается, одинаков независимо от того, на какую платформу нацелен, поэтому независимо от .NET Framework и .NET Core, сгенерированный IL для этой приостановки идентичен. Однако, что меняется, так это реализация метода AwaitUnsafeOnCompleted, которая в .NET Core значительно отличается:

1.Все начинается одинаково: метод вызывает ExecutionContext.Capture() для получения текущего контекста выполнения.

2.Затем все расходится с .NET Framework. Builder в .NET Core имеет только одно поле:

public struct AsyncTaskMethodBuilder
{
    private Task<VoidTaskResult>? m_task;
    ...
}

После захвата ExecutionContext он проверяет, содержит ли поле m_task экземпляр AsyncStateMachineBox<TStateMachine> , где TStateMachine - это тип сгенерированной компилятором структуры машины состояния. Этот тип AsyncStateMachineBox<TStateMachine> и есть "магия". Он определяется следующим образом:

private class AsyncStateMachineBox<TStateMachine> :
    Task<TResult>, IAsyncStateMachineBox
    where TStateMachine : IAsyncStateMachine
{
    private Action? _moveNextAction;
    public TStateMachine? StateMachine;
    public ExecutionContext? Context;
    ...
}

Вместо того чтобы иметь отдельную задачу Task, это задача (обратите внимание на ее базовый тип). Вместо того, чтобы упаковывать машину состояний, структура просто живет как сильно типизированное поле в этой задаче. И вместо того, чтобы иметь отдельный MoveNextRunner для хранения Action и ExecutionContext, они просто поля этого типа, и поскольку именно этот экземпляр хранится в поле m_task конструктора, у нас есть прямой доступ к нему и нам не нужно перераспределять вещи при каждом приостановлении. Если ExecutionContext изменится, мы можем просто перезаписать поле новым контекстом, и нам больше не нужно будет ничего выделять; любой Action, который у нас есть, по-прежнему указывает на нужное место. Итак, после захвата ExecutionContext, если у нас уже есть экземпляр этого AsyncStateMachineBox<TStateMachine> , метод приостанавливается не в первый раз, и мы можем просто сохранить в нем только что захваченный ExecutionContext. Если у нас еще нет экземпляра AsyncStateMachineBox<TStateMachine> , то нам нужно его выделить:

var box = new AsyncStateMachineBox<TStateMachine>();
taskField = box; // important: this must be done before storing stateMachine into box.StateMachine!
box.StateMachine = stateMachine;
box.Context = currentContext;

Обратите внимание на строку, которую источник комментирует как "важную". Она заменяет сложный танец SetStateMachine в .NET Framework, так что SetStateMachine фактически не используется в .NET Core. Поле taskField, которое вы видите там, является ссылкой на поле m_task в AsyncTaskMethodBuilder. Мы выделяем AsyncStateMachineBox<TStateMachine>, затем через taskField сохраняем этот объект в m_task билдера (это билдер, который находится в структуре машины состояний на стеке), а затем копируем эту машину состояний на стеке (которая теперь уже содержит ссылку на ящик) в AsyncStateMachineBox<TStateMachine> на куче, так что AsyncStateMachineBox<TStateMachine> соответствующим образом и рекурсивно ссылается на себя. Это все еще головоломно, но гораздо более эффективно.

3.Затем мы можем передать Action методу этого экземпляра, который вызовет его метод MoveNext, который выполнит соответствующее восстановление ExecutionContext перед вызовом MoveNext машины StateMachine. Это действие можно кэшировать в поле _moveNextAction, чтобы при последующем использовании можно было просто повторно использовать то же действие. Затем это действие передается в UnsafeOnCompleted ожидающего, чтобы подключить продолжение.

Это объяснение показывает, почему большинство остальных распределений исчезло: <SomeMethodAsync>d__1 не боксируется, а просто живет как поле в самой задаче, а MoveNextRunner больше не нужен, поскольку он существовал только для хранения Action и ExecutionContext. Но, исходя из этого объяснения, мы все равно должны были бы увидеть 1000 выделений Action, по одному на каждый вызов метода, а мы этого не сделали. Почему? А как насчет объектов QueueUserWorkItemCallback... мы все еще ставим их в очередь как часть Task.Yield(), так почему они не отображаются?

Как я уже отмечал, одним из преимуществ переноса деталей реализации в основную библиотеку является то, что она может развиваться со временем, и мы уже видели, как она развивалась от .NET Framework к .NET Core. Она также получила дальнейшее развитие после первоначального переписывания для .NET Core, с дополнительными оптимизациями, которые выигрывают от наличия внутреннего доступа к ключевым компонентам системы. В частности, асинхронная инфраструктура знает о таких основных типах, как Task и TaskAwaiter. И поскольку она знает о них и имеет внутренний доступ, ей не нужно играть по общепринятым правилам. Шаблон awaiter, которому следует язык C#, требует, чтобы awaiter имел метод AwaitOnCompleted или AwaitUnsafeOnCompleted, оба из которых принимают продолжение в качестве Action, а это означает, что инфраструктура должна уметь создавать Action для представления продолжения, чтобы работать с произвольными awaiter, о которых инфраструктура ничего не знает. Но если инфраструктура столкнется с awaiter, о котором она знает, она не обязана использовать тот же путь кода. Для всех основных ожидающих устройств, определенных в System.Private.CoreLib, инфраструктура может пойти более легким путем, который вообще не требует Action. Все эти awaiter'ы знают о IAsyncStateMachineBoxes и могут рассматривать сам объект box как продолжение. Так, например, YieldAwaitable, возвращаемый Task.Yield, способен поставить сам IAsyncStateMachineBox в очередь непосредственно в ThreadPool в качестве рабочего элемента, а TaskAwaiter, используемый при ожидании Task, способен сохранить сам IAsyncStateMachineBox непосредственно в списке продолжений Task. Не нужно никаких Action, не нужен QueueUserWorkItemCallback.

Таким образом, в очень распространенном случае, где асинхронный метод ожидает только вещи из System.Private. CoreLib (Task, Task<TResult>, ValueTask, ValueTask<TResult>, YieldAwaitable и их варианты ConfigureAwait), в худшем случае происходит только одно распределение накладных расходов, связанных со всем жизненным циклом асинхронного метода: если метод когда-либо приостанавливается, он выделяет единственный производный тип Task, который хранит все остальное необходимое состояние, а если метод никогда не приостанавливается, то дополнительного распределения не происходит.

При желании мы можем избавиться и от последнего выделения, по крайней мере, амортизированным способом. Как уже было показано, существует стандартный конструктор, связанный с Task (AsyncTaskMethodBuilder), и точно так же есть стандартный конструктор, связанный с Task<TResult> (AsyncTaskMethodBuilder<TResult>) и с ValueTask и ValueTask<TResult> (AsyncValueTaskMethodBuilder и AsyncValueTaskMethodBuilder<TResult>, соответственно). Для ValueTask/ValueTask<TResult> билдеры на самом деле довольно просты, поскольку они сами обрабатывают только синхронно и успешно завершающийся случай, в этом случае асинхронный метод завершается без приостановки, а билдеры могут просто вернуть ValueTask.Completed или ValueTask<TResult>, обернув значение результата. Для всего остального они просто делегируют AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>, поскольку возвращаемая ValueTask/ValueTask<TResult> просто обертывает Task и может использовать всю ту же логику. Но в .NET 6 и C# 10 появилась возможность переопределения используемого конструктора для каждого метода, а также пара специализированных конструкторов для ValueTask/ValueTask<TResult>, которые могут объединять объекты IValueTaskSource/IValueTaskSource<TResult>, представляющие конечное завершение, вместо использования Task.

Мы можем увидеть влияние этого в нашем примере. Давайте немного изменим наш SomeMethodAsync, который мы профилировали, чтобы он возвращал ValueTask вместо Task:

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

Это приведет к созданию этой сгенерированной точки входа:

[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
private static ValueTask SomeMethodAsync()
{
    <SomeMethodAsync>d__1 stateMachine = default;
    stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

Теперь мы добавляем [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] к объявлению SomeMethodAsync:

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
static async ValueTask SomeMethodAsync()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Yield();
    }
}

а компилятор вместо этого выводит следующее:

[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
private static ValueTask SomeMethodAsync()
{
    <SomeMethodAsync>d__1 stateMachine = default;
    stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

Фактический код C# для всей реализации, включая всю машину состояний (не показана), практически идентичен; единственное различие заключается в типе создаваемого и сохраняемого билдера, который используется везде, где мы ранее видели ссылки на билдер. Если вы посмотрите на код PoolingAsyncValueTaskMethodBuilder, то увидите, что его структура практически идентична структуре AsyncTaskMethodBuilder, включая использование некоторых точно таких же общих процедур для выполнения таких вещей, как специальная выборка известных типов ожидающих. Ключевое отличие заключается в том, что вместо того, чтобы делать new AsyncStateMachineBox<TStateMachine>(), когда метод впервые приостанавливается, он вместо этого делает StateMachineBox<TStateMachine >.RentFromCache(), и после завершения асинхронного метода (SomeMethodAsync) и завершения await на возвращаемом ValueTask, арендованный ящик возвращается в кэш. Это означает (амортизированное) нулевое выделение:

Этот кэш сам по себе немного интересен. Объединение объектов в пул может быть хорошей идеей и может быть плохой идеей. Чем дороже создание объекта, тем ценнее его объединение в пул; так, например, объединение действительно больших массивов гораздо ценнее, чем объединение действительно маленьких массивов, потому что большие массивы не только требуют больше циклов процессора и обращений к памяти для обнуления, но и оказывают большее давление на сборщик мусора, заставляя его чаще собирать данные. Однако для очень маленьких объектов объединение в пулы может быть чистым негативом. Пулы - это просто распределители памяти, как и GC, поэтому при объединении вы обмениваете затраты, связанные с одним распределителем, на затраты, связанные с другим, а GC очень эффективен при обработке множества крошечных, недолговечных объектов. Если вы выполняете много работы в конструкторе объекта, избежание этой работы может превысить затраты на сам распределитель, что делает объединение в пул ценным. Но если вы практически не делаете никакой работы в конструкторе объекта и объединяете его в пул, вы делаете ставку на то, что ваш распределитель (ваш пул) более эффективен для используемых схем доступа, чем GC, а это часто бывает плохой ставкой. Существуют и другие издержки, и в некоторых случаях вы можете оказаться в эффективной борьбе с эвристикой GC; например, оптимизация GC основана на предпосылке, что ссылки от объектов более высокого поколения (например, gen2) к объектам более низкого поколения (например, gen0) относительно редки, но объединение объектов в пул может аннулировать эти предпосылки.

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

Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments7

Articles