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

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

SynchronizationContext и ConfigureAwait

Мы говорили о SynchronizationContext ранее в контексте паттерна EAP и упомянули, что он появится снова. SynchronizationContext позволяет вызывать переиспользуемые помощники и автоматически планироваться обратно, когда и куда сочтет нужным вызывающая среда. В результате естественно ожидать, что это будет «просто работать» с async/await, что и происходит. Вернемся к нашему обработчику нажатия кнопки:

ThreadPool.QueueUserWorkItem(_ =>
{
    string message = ComputeMessage();
    button1.BeginInvoke(() =>
    {
        button1.Text = message;
    });
});

с async/await мы хотели бы иметь возможность написать это следующим образом:

button1.Text = await Task.Run(() => ComputeMessage());

Этот вызов ComputeMessage выгружается в пул потоков, и после завершения метода выполнение переходит обратно к потоку UI, связанному с кнопкой, и установка свойства Text происходит в этом потоке.

Интеграция с SynchronizationContext оставлена на усмотрение реализации ожидающего (код, генерируемый для машины состояний, ничего не знает о SynchronizationContext), поскольку именно ожидающий отвечает за фактический вызов или постановку в очередь предоставленного продолжения, когда представленная асинхронная операция завершается. Хотя пользовательский ожидающий не обязательно должен учитывать SynchronizationContext.Current, ожидающие Task, Task<TResult> , ValueTask и ValueTask<TResult> делают это. Это означает, что по умолчанию, когда вы ожидаете Task, Task<TResult>, ValueTask, ValueTask<TResult> или даже результат вызова Task.Yield(), ожидающий по умолчанию будет искать текущий SynchronizationContext и затем, если он успешно получил отличный от текущего контекст, в конечном итоге поставит продолжение в очередь к этому контексту.

Мы можем убедиться в этом, если посмотрим на код, задействованный в TaskAwaiter. Вот фрагмент соответствующего кода из Corelib:

internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
{
    if (continueOnCapturedContext)
    {
        SynchronizationContext? syncCtx = SynchronizationContext.Current;
        if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext))
        {
            var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false);
            if (!AddTaskContinuation(tc, addBeforeOthers: false))
            {
                tc.Run(this, canInlineContinuationTask: false);
            }
            return;
        }
        else
        {
            TaskScheduler? scheduler = TaskScheduler.InternalCurrent;
            if (scheduler != null && scheduler != TaskScheduler.Default)
            {
                var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false);
                if (!AddTaskContinuation(tc, addBeforeOthers: false))
                {
                    tc.Run(this, canInlineContinuationTask: false);
                }
                return;
            }
        }
    }

    ...
}

Это часть метода, который определяет, какой объект следует сохранить в Task в качестве продолжения. Ему передается stateMachineBox, который, как уже упоминалось ранее, может быть сохранен непосредственно в списке продолжений Task. Однако эта специальная логика может обернуть этот IAsyncStateMachineBox, чтобы он также включал планировщик, если он есть. Она проверяет, есть ли в данный момент SynchronizationContext, не заданный по умолчанию, и если есть, то создает SynchronizationContextAwaitTaskContinuation как фактический объект, который будет храниться в качестве продолжения; этот объект, в свою очередь, оборачивает исходный и захваченный SynchronizationContext и знает, как вызвать MoveNext в рабочем элементе, поставленном в очередь. Таким образом, вы можете использовать await как часть обработчика событий в приложении пользовательского интерфейса, и код после завершения await будет продолжен в нужном потоке. Следующий интересный момент заключается в том, что здесь обращается внимание не только на SynchronizationContext: если он не смог найти пользовательский SynchronizationContext для использования, он также смотрит, есть ли в типе TaskScheduler, который используется в Task пользовательский TaskScheduler который необходимо учитывать. Как и в случае с SynchronizationContext, если есть пользовательский, он заворачивается вместе с исходным блоком в TaskSchedulerAwaitTaskContinuation, который используется в качестве объекта продолжения.

Но, пожалуй, самое интересное здесь - это самая первая строка в теле метода: if (continueOnCapturedContext). Мы выполняем эти проверки для SynchronizationContext/TaskScheduler, только если continueOnCapturedContext равен true; если false, реализация ведет себя так, как если бы оба значения были по умолчанию, и игнорирует их. Что, скажите на милость, устанавливает значение continueOnCapturedContext в false? Вы, наверное, догадались: с помощью популярной функции ConfigureAwait(false).

Я подробно рассказываю о ConfigureAwait в FAQ по ConfigureAwait, поэтому советую вам прочитать его для получения дополнительной информации. Достаточно сказать, что единственное, что делает ConfigureAwait(false) как часть await, это передает свой аргумент Boolean в эту функцию (и другие подобные ей) как значение continueOnCapturedContext, чтобы пропустить проверки SynchronizationContext/TaskScheduler и вести себя так, как будто их не существует. В случае с задачами это позволяет задаче вызывать свои продолжения там, где она сочтет нужным, а не ставить их в очередь на выполнение в определенном планировщике.

Ранее я уже упоминал еще один аспект SynchronizationContext и говорил, что мы его еще увидим: OperationStarted/OperationCompleted. Сейчас самое время. Они появляются как часть функции, которую все так любят ненавидеть: async void. ConfigureAwait - в сторону, async void - это, пожалуй, одна из самых вызывающих разногласия возможностей, добавленных как часть async/await. Она была добавлена по одной и только одной причине: обработчики событий. В приложении пользовательского интерфейса вы хотите иметь возможность писать код, подобный следующему:

button1.Click += async (sender, eventArgs) =>
{
  button1.Text = await Task.Run(() => ComputeMessage());  
};

но если бы все методы async должны были иметь тип возврата типа Task, вы бы не смогли этого сделать. Событие Click имеет сигнатуру public event EventHandler? Click;, причем EventHandler определяется как public delegate void EventHandler(object? sender, EventArgs e);, и поэтому, чтобы предоставить метод, соответствующий этой сигнатуре, метод должен быть с возвратом типа void.

Существует множество причин, по которым async void считается плохим, почему в статьях рекомендуется избегать их везде, где это возможно, и почему появились анализаторы, которые отслеживают их использование. Одна из самых больших проблем связана с выводом делегатов. Рассмотрим эту программу:

using System.Diagnostics;

Time(async () =>
{
    Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    Console.WriteLine("Timing...");
    Stopwatch sw = Stopwatch.StartNew();
    action();
    Console.WriteLine($"...done timing: {sw.Elapsed}");
}

Можно было бы легко ожидать, что это выведет истекшее время, по крайней мере, 10 секунд, но если вы запустите это, то вместо этого вы получите результат, подобный этому:

Timing...
Enter
...done timing: 00:00:00.0037550

А? Конечно, исходя из всего, что мы обсудили в этом посте, должно быть понятно, в чем проблема. Лямбда async на самом деле является методом async void. Async-методы возвращаются к своему вызывающему пользователю в тот момент, когда они достигают первой точки приостановки. Если бы это был метод async Task, то в этот момент возвращалась бы Task. Но в случае с async void ничего не возвращается. Все, что знает метод Time, это то, что он вызвал action(); и вызов делегата вернулся; он понятия не имеет, что асинхронный метод на самом деле все еще «работает» и асинхронно завершится позже.

Вот здесь и приходят на помощь OperationStarted/OperationCompleted. Такие методы async void по своей природе похожи на методы EAP, рассмотренные ранее: инициация таких методов является пустой, и поэтому вам нужен какой-то другой механизм, чтобы иметь возможность отслеживать все такие операции в процессе выполнения. Таким образом, реализации EAP вызывают OperationStarted текущего SynchronizationContext, когда операция инициируется, и OperationCompleted, когда она завершается, и async void делает то же самое. Билдер, связанный с async void - это AsyncVoidMethodBuilder. Помните, как в точке входа метода async генерируемый компилятором код вызывает статический метод Create билдера для получения соответствующего экземпляра билдера? AsyncVoidMethodBuilder использует это в своих целях, чтобы перехватить создание и вызвать OperationStarted:

public static AsyncVoidMethodBuilder Create()
{
    SynchronizationContext? sc = SynchronizationContext.Current;
    sc?.OperationStarted();
    return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };
}

Аналогично, когда билдер помечается для завершения через SetResult или SetException, он вызывает соответствующий метод OperationCompleted. Именно так фреймворк для модульного тестирования, такой как xunit, может иметь методы тестирования async void и при этом использовать максимальную степень параллелизма при одновременном выполнении тестов, например, в AsyncTestSyncContext в xunit.

С этими знаниями мы можем теперь переписать наш пример синхронизации:

using System.Diagnostics;

Time(async () =>
{
    Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    var oldCtx = SynchronizationContext.Current;
    try
    {
        var newCtx = new CountdownContext();
        SynchronizationContext.SetSynchronizationContext(newCtx);

        Console.WriteLine("Timing...");
        Stopwatch sw = Stopwatch.StartNew();
        
        action();
        newCtx.SignalAndWait();

        Console.WriteLine($"...done timing: {sw.Elapsed}");
    }
    finally
    {
        SynchronizationContext.SetSynchronizationContext(oldCtx);
    }
}

sealed class CountdownContext : SynchronizationContext
{
    private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false);
    private int _remaining = 1;

    public override void OperationStarted() => Interlocked.Increment(ref _remaining);

    public override void OperationCompleted()
    {
        if (Interlocked.Decrement(ref _remaining) == 0)
        {
            _mres.Set();
        }
    }

    public void SignalAndWait()
    {
        OperationCompleted();
        _mres.Wait();
    }
}

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

Timing...
Enter
Exit
...done timing: 00:00:10.0149074

Тада!

Поля в State Machine

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

Для метода CopyStreamToStream, показанного ранее:

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

вот поля, которые мы получили в итоге:

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;

    ...
}

Что представляет собой каждый из них?

<>1__state. Это «состояние» в «машине состояний». Оно определяет текущее состояние, в котором находится машина состояний, и, самое главное, что должно быть сделано при следующем вызове MoveNext. Если состояние равно -2, операция завершена. Если состояние равно -1, то либо мы собираемся вызвать MoveNext в первый раз, либо код MoveNext в данный момент выполняется в другом потоке. Если вы отлаживаете обработку метода async и видите состояние -1, это означает, что где-то есть поток, который на самом деле выполняет код, содержащийся в методе. Если состояние равно 0 или больше, метод приостановлен, и значение состояния говорит вам, на каком await он приостановлен. Хотя это не является жестким и неизменным правилом (некоторые шаблоны кода могут спутать нумерацию), в общем случае присвоенное состояние соответствует номеру await на основе 0 в порядке сверху вниз в исходном коде. Так, например, если тело метода async было целиком:

await A();
await B();
await C();
await D();

и вы обнаружили, что значение состояния равно 2, это почти наверняка означает, что метод async в настоящее время приостановлен в ожидании завершения задачи, возвращенной из C().

<>t__builder. Это билдер для машины состояний, например, AsyncTaskMethodBuilder для Task, AsyncValueTaskMethodBuilder<TResult> для ValueTask<TResult>, AsyncVoidMethodBuilder для метода async void, или любой билдер, который был объявлен для использования через [AsyncMethodBuilder(...)] либо в типе возврата async, либо переопределен через аналогичный атрибут в самом методе async. Как обсуждалось ранее, билдер отвечает за жизненный цикл метода async, включая создание задачи возврата, завершение этой задачи, а также служит посредником для приостановки, когда код в методе async просит билдер приостановить выполнение до завершения определенного ожидания.

source/destination. Это параметры метода. Это видно по тому, что их имена не изменены; компилятор назвал их точно так, как были указаны имена параметров. Как отмечалось ранее, все параметры, которые используются телом метода, должны быть сохранены в машине состояний, чтобы метод MoveNext имел к ним доступ. Обратите внимание, я сказал «используются». Если компилятор видит, что параметр не используется телом асинхронного метода, он может оптимизировать хранение поля. Например, для такого метода:

public async Task M(int someArgument)
{
    await Task.Yield();
}

компилятор будет выдавать эти поля в машину состояний:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

Обратите внимание на явное отсутствие чего-то с именем someArgument. Но если мы изменим метод async, чтобы он действительно использовал аргумент каким-либо образом:

public async Task M(int someArgument)
{
    Console.WriteLine(someArgument);
    await Task.Yield();
}

он появляется:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public int someArgument;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

<buffer>5__2; . Это «локальный» buffer, который был переведен в разряд полей, чтобы он мог сохраняться в разных точках ожидания. Компилятор очень старается, чтобы состояние не сохранялось в поле без необходимости. Обратите внимание, что в исходном тексте есть еще одна локальная переменная numRead, у которой нет соответствующего поля в машине состояний. Почему? Потому что в нем нет необходимости. Эта переменная устанавливается в результате вызова ReadAsync, а затем используется в качестве входа для вызова WriteAsync. Между ними нет ожидания, в котором нужно было бы хранить значение numRead. Подобно тому, как в синхронном методе JIT-компилятор может выбрать хранение такого значения полностью в регистре и никогда не выкладывать его в стек, компилятор C# может избежать сохранения этого локального значения в поле, поскольку ему не нужно сохранять свое значение во время ожидания. В целом, компилятор C# может отказаться от использования локальных значений в полях, если он может доказать, что их значение не нужно сохранять в ожиданиях.

<>u__1 и <>u__2. В асинхронном методе есть два await: одно для Task, возвращаемого ReadAsync, и одно для Task, возвращаемого WriteAsync. Task.GetAwaiter() возвращает TaskAwaiter, а Task<TResult>.GetAwaiter() возвращает TaskAwaiter<TResult>, оба из которых являются различными типами структур. Поскольку компилятору необходимо получить эти ожидания до выполнения await (IsCompleted, UnsafeOnCompleted) и затем получить к ним доступ после выполнения await (GetResult), ожидания необходимо хранить. А поскольку это разные типы структур, компилятору необходимо поддерживать два отдельных поля для этого (альтернативой было бы объединить их и иметь одно object поле для этих ожиданий, но это привело бы к дополнительным затратам на аллокацию). Однако компилятор будет стараться повторно использовать поля, когда это возможно. Если у меня есть:

public async Task M()
{
    await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2);
    await Task.FromResult(false);
    await Task.FromResult(3);
}

имеется пять ожиданий, но задействованы только два различных типа ожиданий: три - TaskAwaiter<int> и два - TaskAwaiter<bool>. Таким образом, в конечном итоге в машине состояний есть только два поля ожиданий:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    ...
}

Теперь, если я изменю свой пример:

public async Task M()
{
    await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2).ConfigureAwait(false);
    await Task.FromResult(false).ConfigureAwait(false);
    await Task.FromResult(3);
}

здесь по-прежнему задействованы только Task<int> и Task<bool>, но я фактически использую четыре разных типа ожидающих структур, потому что тип ожидающего, возвращаемого вызовом GetAwaiter() на объекте, возвращаемом ConfigureAwait, отличается от типа, возвращаемого Task.GetAwaiter()... это снова видно из полей ожидающего, созданных компилятором:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3;
    private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4;
    ...
}

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

Существуют и другие типы полей, которые вы можете увидеть определенными в машине состояний. В частности, вы можете увидеть некоторые поля, содержащие слово «wrap». Рассмотрим этот глупый пример:

public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;

В результате получается машина состояний со следующими полями:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private TaskAwaiter<int> <>u__1;
    ...
}

Пока ничего особенного. Теперь поменяйте порядок добавления выражений:

public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);

При этом вы получаете эти поля:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private int <>7__wrap1;
    private TaskAwaiter<int> <>u__1;
    ...
}

Теперь у нас есть еще один: <>7__wrap1 . Почему? Потому что мы вычислили значение DateTime.Now.Second, и только после его вычисления нам нужно было что-то ожидать, а значение первого выражения должно быть сохранено, чтобы добавить его к результату второго. Таким образом, компилятор должен убедиться, что временный результат первого выражения доступен для добавления к результату await, что означает, что он должен поместить результат выражения во временное поле, что он и делает с этим полем <>7__wrap1. Если вы когда-нибудь столкнетесь с гипер-оптимизацией реализации асинхронных методов, чтобы уменьшить объем выделяемой памяти, вы можете поискать такие поля и посмотреть, могут ли небольшие изменения в исходном тексте избежать необходимости сохранения временного результата и, таким образом, избежать необходимости в таких временных полях.

Подведение итогов

Я надеюсь, что эта статья помогла пролить свет на то, что именно происходит под покровом, когда вы используете async/await, но, к счастью, вам обычно не нужно знать или заботиться об этом. Здесь есть много движущихся частей, все они объединяются, чтобы создать эффективное решение для написания масштабируемого асинхронного кода без необходимости иметь дело со спагетти кодом из обратных вызовов. И все же в конечном итоге эти части на самом деле относительно просты: универсальное представление для любой асинхронной операции, язык и компилятор, способные переписать обычный поток управления в реализацию машины состояний корутин, и паттерны, связывающие все это вместе. Все остальное - это оптимизационная подливка.

Счастливого кодинга!

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


  1. Stawros
    22.05.2023 19:22
    +1

    Можно было бы легко ожидать, что это выведет истекшее время, по крайней мере, 10 секунд, но если вы запустите это, то вместо этого вы получите результат, подобный этому:

    Не знаю, я бы и так трактовал передачу асинхронной лямбды как вызов async void, а это просто fire and forget. Меня сначала смутило отсутствие "Exit" в логе в самом конце, но видимо это из-за того, что это запуск Main выполнен без ожидания подтверждения выхода - просто программа отработала до того, как внутренний таск с вейтом завершился. А вот как захендлить ожидание через SynchronizationContext я не знал, да. В любом случае цикл статей интересен, за что поклон и уважение.