.NET Разработчик
День 2819. #ЗаметкиНаПолях #Async
Ещё Раз Про Async/Await в C#. Часть 4
Паттерн awaitable: создание объектов, поддерживающих ожидание
Если спросить большинство C#-разработчиков, что можно использовать с оператором await, они ответят: «Task». Это распространённый, но далеко не единственный случай. Механизм await работает так же, как и foreach, на основе структуры. Компилятор ищет определенную «форму» (набор элементов), и Task как раз ей соответствует.
Понимание этой структуры поможет разобраться в конечном автомате, который генерируется компилятором (о нём позже). Кроме того, это позволит создавать собственные «ожидаемые» объекты.
Структура
Чтобы выражение await expr успешно скомпилировалось, у объекта expr должен быть метод GetAwaiter(). Возвращаемый им тип — так называемый «awaiter» (объект ожидания) — должен содержать три элемента и реализовывать один интерфейс:
public readonly struct MyAwaiter : INotifyCompletion
{
// завершилась ли уже операция?
public bool IsCompleted { get; }
// значение, либо повторный выброс исключения
public TResult GetResult();
// вызывается при завершении
public void OnCompleted(Action continuation);
}
Вот и весь контракт. Компилятор выполняет привязку этих членов по их именам, что имеет два важных следствия:
1. Метод GetAwaiter может быть методом расширения. Это позволяет сделать тип «ожидаемым», даже если вы не являетесь его владельцем:
public static class
TimeSpanAwaitableExtensions
{
public static TaskAwaiter
GetAwaiter(this TimeSpan timeSpan) =>
Task.Delay(timeSpan).GetAwaiter();
}
…
await TimeSpan.FromMilliseconds(200);
2. Объект ожидания может быть структурой. TaskAwaiter, ValueTaskAwaiter и YieldAwaitable.YieldAwaiter являются структурами; поэтому при ожидании уже завершённой задачи не происходит выделения памяти под объект ожидания.
Как используются эти три члена
Для каждого вызова await компилятор генерирует примерно следующий код:
var awaiter = expr.GetAwaiter();
if (!awaiter.IsCompleted)
{
// сохранение состояния
// регистрация продолжения
// возврат к вызывающему методу
awaiter.OnCompleted(MoveNext);
return;
}
var result = awaiter.GetResult();
- Свойство IsCompleted определяет, будет ли выполнение метода приостановлено. Если оно возвращает true, выполнение продолжается без прерываний, в том же потоке, без планирования задачи и выделения дополнительной памяти. Это «быстрый путь», ради которого и существует ValueTask.
- Метод OnCompleted вызывается только в том случае, если IsCompleted вернуло false. Он получает продолжение и отвечает за его последующий запуск. Где именно будет выполнено это продолжение — решать вам.
- Метод GetResult вызывается в обоих случаях. Он возвращает результат операции и, что важно, именно здесь повторно выбрасываются исключения. Если операция завершилась неудачей, ожидающий объект сообщает об этом, выбрасывая исключение из GetResult, а не через какой-то отдельный канал для передачи ошибок.
То, что GetResult может возвращать void, нормально: именно так ведёт себя await при работе с непараметризованным Task.
ICriticalNotifyCompletion
Существует еще один интерфейс — ICriticalNotifyCompletion, который добавляет метод UnsafeOnCompleted. Практически все реальные реализации ожидающих объектов поддерживают его. Разница заключается в работе с ExecutionContext. Ожидается, что OnCompleted захватит текущий ExecutionContext и восстановит его перед выполнением продолжения. UnsafeOnCompleted этого делать не обязан.
Слово «Unsafe» звучит тревожно, но на деле всё не так страшно: создаваемый компилятором конечный автомат уже захватывает ExecutionContext при запуске метода и восстанавливает его при каждом вызове MoveNext. Повторный захват контекста для каждого await был бы излишней работой. Поэтому конечный автомат вызывает UnsafeOnCompleted, если ожидающий объект его предоставляет, а в противном случае использует «безопасную» версию (OnCompleted).
Реализуйте оба интерфейса. В UnsafeOnCompleted должна выполняться основная логика, а OnCompleted может просто вызывать UnsafeOnCompleted — при условии, что у вас нет собственного контекстного состояния, которое нужно передавать дальше.
Полезный объект, поддерживающий ожидание
Task.Yield() возвращает управление в текущий SynchronizationContext (например, в UI-приложении это означает возврат в UI-поток). Иногда же требуется обратное: безусловно покинуть текущий поток и перейти в поток из пула потоков.
public readonly struct
ThreadPoolAwaitable : ICriticalNotifyCompletion
{
public ThreadPoolAwaitable GetAwaiter() => this;
public bool IsCompleted => false;
public void GetResult()
{
}
public void OnCompleted(Action continuation)
=> UnsafeOnCompleted(continuation);
public void UnsafeOnCompleted(Action continuation)
=> ThreadPool.UnsafeQueueUserWorkItem(
static state => ((Action)state!)(), continuation);
}
…
await default(ThreadPoolAwaitable);
// дальнейший код выполняется в потоке из пула
Особенности:
- Тип сам является своим собственным «ожидателем». GetAwaiter() возвращает this. Это распространённый прием, который позволяет избежать лишнего выделения памяти и дополнительного уровня косвенности; YieldAwaitable использует такой же подход.
- Свойство IsCompleted всегда возвращает false. Это сделано намеренно: если бы оно возвращало true, продолжение выполнялось бы синхронно в текущем потоке, а именно этого мы и стараемся избежать.
- Это readonly-структура. Для операции ожидания не требуется выделение памяти в куче.
Примечание: Тот же механизм, только наоборот, позволяет в UI-фреймворках использовать конструкцию await someControl.Dispatcher; для возврата в UI-поток. Метод OnCompleted объекта ожидания в данном случае помещает продолжение в диспетчер, а не в пул потоков.
Когда стоит создавать свой объект ожидания
Довольно редко. В большинстве случаев правильным выбором будут Task, ValueTask или TaskCompletionSource. Создание собственного объекта ожидания оправдано в следующих ситуациях:
1. Вы хотите, чтобы await означал «смену контекста» (как в случае с Task.Yield() или ThreadPoolAwaitable). При этом не нужно передавать какой-либо результат — важен только факт планирования выполнения.
2. Вы работаете с механизмом завершения операций, который существенно отличается от стандартного, и хотите избежать выделения памяти под объект Task для каждой операции. Для этого существует интерфейс IValueTaskSource (про него позже в этой серии).
3. Вы хотите сделать существующий тип «ожидаемым», не меняя его исходный код — здесь поможет прием с методами расширения.
Важно помнить: собственный объект ожидания не поддерживает композицию. Он не будет работать с Task.WhenAll, WaitAsync или любыми другими механизмами, ожидающими объект Task. Если вам нужны такие возможности, просто возвращайте Task.
Источник: https://www.meziantou.net/the-awaitable-pattern-writing-your-own-awaitable.htm
👍6
23 · 572 ·