Сам себе awaiter
Здравствуйте. Меня зовут Валерий и я — кодоголиклюблю писать программы. И иногда в процессе написания программ сами собой возникают интересные истории. Об одной такой история я и хочу рассказать в этой статье.
За десять с лишним лет, прошедших с момента изобретения конструкции async/await, она стала привычной и широко используемой. В наше время мало у кого вызывает затруднение написать самому или же понять смысл написанного кем-то другим выражения типа await stream.ReadAsync(buffer,async, count)
— ясно, что это — чтение из потока в некий буфер, и что программа тут отдает управление на то время, пока это чтение выполняется, чтобы по завершении чтения получить управление продолжить свое выполнение дальше.
Но что вы скажете, увидев в коде вот такое выражение: await this
в одном из методов класса, совершенно не похожего на Task/ValueTask или ещё что-то, что привычно видеть после await? Не правда ли это вас смутит и уж, тем более, вы вряд ли напишете такое сами? А я однажды такое написал в здравом уме и трезвой памяти. И если вам интересно, зачем это было так написано и что за магия тут творитсякак это работает, и как вообще определить свой класс, чтобы ссылку на него можно было указать после await — читайте статью.
Предупреждение
Если вам, прежде всего, интересно пополнить свою коллекцию практических приемов решения задач, не особо глубоко разбираясь, как эти приемы работают, а тем более — если вы сейчас ищете решение конкретной задачи, то эта статья вряд ли принесет вам существенную пользу. Но если вам интересны вопросы типа "как это работает" или "как можно сделать то же самое другим способом", то эта статья — для вас.
О чем, собственно, речь
Упомянутый оператор await this
сам собой написался я написал в одном методов одного из классов, который входит в мою новую библиотеку ActiveSession.
Об этой библиотеке я написал пару статей на Хабре: основную и дополнительную к ней. Класс, о котором идет речь — это один из классов стандартных исполнителей (термин "исполнитель" объяснен в этих статьях). Оригинал кода, о котором идет речь, можно посмотреть в репозитории библиотеки на GitHub.
Класс, про который идет речь — обобщенный, называется EnumAdapterRunner<TItem>
и является наследником в иерархии из нескольких классов. Впрочем, все, что связано с наследованием и классами-предками, я постарался из рассказа убрать, как несущественные для этой статьи детали.
Но, на самом деле, это не важно, что это за класс — достаточно в нескольких словах рассказать, что делает этот класс и этот метод, чтобы понимать контекст.
Класс этот (он — обобщенный, с параметром-типом TItem) перечисляет в фоновом режиме последовательность записей типа TItem(IEnumerable<TItem>
), свою для каждого экземпляра класса. При этом предполагается, что процесс перечисления занимает некоторое заметное время (например, записи получаются из базы данных или по сети). Полученные записи класс запоминает во внутреннем буфере. А затем, как раз при вызове рассматриваемого метода из внешней программы, возвращает указанное в параметрах вызова число этих записей. При этом полная совокупность вызовов этого метода возвращает все записи, без пропусков и дублей. Этот метод — асинхронный: если нужное число записей ещё не попало во внутренний буфер, то метод отдает управление до того момента, когда нужное число записей будет помещено фоновым процессом в буфер. Важной для этой статьи особенностью этого метода является то, что для одного экземпляра класса может выполняться одновременно только один вызов метода — потому что иначе порядок возвращенных записей и отсутствие пропусков или дублей сложно гарантировать.
То, что вызовы метода для экземпляра класса не могут осуществляться параллельно, позволяет для выражения, стоящего в конструкции await (далее в статье я называю его словом "ожидаемое") использовать нечто, уникальное только в рамках экземпляра. Это могло бы быть (и так нередко делают) поле экземпляра типа Task, содержащее задачу, которая завершается при добавлении записи в буфер. Но я подумал, а почему бы этим уникальным ожидаемым не мог бы быть сам экземпляр этого класса. Именно так и родилась идея использовать оператор await this
.
Как это работает
Взгляд на метод, использующий await this
Этот метод (его название FetchRequiredAsync) выполняет выборку записей из буфера, которые помещает туда фоновый метод перечисления последовательности, о котором будет написано дальше.
Код обсуждаемого метода схематично выглядит так:
async Task FetchRequiredAsync(Int32 MaxAdvance, List<TItem> Result, CancellationToken Token)
{
using(Token.Register(EnqueueAwaitContinuationForRunning)) {
while(Result.Count < MaxAdvance && Status.IsRunning() ){
CheckDisposed();
Token.ThrowIfCancellationRequested();
TItem? item;
if(_queue.TryTake(out item)) Result.Add(item!);
else if(_queue.IsAddingCompleted) break;
else await this;
}
}
}
Некоторые пояснения. Параметры метода имеют следующий смысл: Result — список, куда помещается результат вызова, MaxAdvance — сколько записей должен максимально содержать результат, Token — маркер отмены, который позволяет отменить выполнение метода. Поле экземпляра _queue
— это промежуточный буфер, он имеет тип ConcurentQueue<TItem>
. Метод CheckDisposed() выбрасывает исключение ObjectDisposedException если ранее началась очистка этого экземпляра (был вызван метод Dispose() или DisposeAsync() — обсуждаемый класс поддерживает оба этих варианта очистки): в отличие от многих классов в ASP.NET Core, обсуждаемый класс допускает вызов методов очистки параллельно, во время работы других методов.
В реальности обсуждаемый класс является частью иерархии наследования классов. А потому перечисленные выше поля и методы находятся в классах, от которых он унаследован. Более того, поле _queue
из базового класса для методов этого класса, на самом деле, напрямую недоступно, и весь доступ к нему осуществляется посредством защищенных (protected) методов-оболочек вида QueueSomeOperation(...) вместо _queue.SomeOperation(...)
: эти методы-оболочки кроме вызовов соответствующих методов _queue
могут дополнительно делать некие операции. Но эти операции в контексте статьи не существенны, потому я и упростил код (и во вставке выше, и в дальнейших вставках), вызывая методы для _queue
напрямую.
Кроме того, и сам обсуждаемый метод FetchRequiredAsync не является публичным, доступным снаружи — этот метод является защищенным виртуальным и он вызывается из другого, уже публичного метода в базовом классе, который помимо вызова обсуждаемого метода делает дополнительные проверки параметров, преобразует результат в нужную форму и т.п. (для тех, кто умеет в паттерны, поясню: в библиотеке используется прием проектирования "Шаблонный метод"). Но обсуждение всей этой обвязки ничего не дает для описания приема, про который я рассказываю тут в статье. Поэтому обсуждается только внутренний метод FetchRequiredAsync.
И, наконец, во всем вставленном коде убрано все, касающееся записи трассировки выполнения методов посредством ILogger. Ради ясности убрана также оптимизация: делегаты для методов класса, которые используются в обсуждаемом в статье коде, не создаются (как в рабочем коде) однократно в конструкторе и не сохраняются во внутренних полях, чтобы их не приходилось создавать при каждом вызове, а создаются по необходимости при каждом вызове метода, которому требуется передать в качестве аргумента делегат для метода: так получается понятнее показать, делегат какого именно метода передается.
То есть, в целом, логика работы обсуждаемого метода типична для решаемой им задачи: он в цикле выбирает записи из промежуточного буфера, помещаемые фоновым процессом, пока они есть, а если их нет — асинхронно (т.е. не блокируя поток) ожидает с помощью await this появления новых записей или возникновения других событий: завершения фонового перечисления, отмены выполнения через маркер отмены, начала очистки всего экземпляра объекта. По завершении ожидания проверяет, по какой причине ожидание было прекращено, и либо продолжает выборку записей из буфера, либо завершает свое выполнение соответствующим образом — нормальным возвратом или выбросом исключения.
Фоновый метод, перечисляющий последовательность.
Для дальнейшего обсуждения нужно иметь представление и о том методе, который выполняет перечисление последовательности в фоне. Здесь всё просто: в нем выполняется цикл перечисления последовательности до тех пор пока либо она не закончится, либо не придет команда на прекращение выполнения экземпляра, либо при перечислении не возникнет исключение. После каждого извлеченного члена последовательности, а также перед своим завершением метод фонового перечисления пытается запустить продолжение после await, если ожидание на await действительно происходит.
Он выглядит (если упрощенно, без записи событий в журнал и кода, связанного с инициализацией и освобождением ресурсов) следующим образом:
void EnumerateSource()
{
try {
foreach(TItem item in _source!) {
if(CompletionToken.IsCancellationRequested) {
//No need to proceed.
break;
}
if (_queue.TryAdd(item))
{
EnqueueAwaitContinuationForRunning();
}
else if (CompletionToken.IsCancellationRequested) {
//Apparently somewhat excessive check. It's intended to sped up a cancellation
//by avoiding one more (possibly long) enumeration at the start of the cycle
break;
}
}
}
catch (Exception e)
{
Exception = e;
}
finally
{
_queue.CompleteAdding();
EnqueueAwaitContinuationForRunning();
}
}
Пояснения: CompletionToken — это свойство класса, которое содержит маркер отмены (CancellationToken), отменяемый при завершении работы класса, а Exception — свойство, которое хранит исключение, возникшее в фоновом процессе.
Вызов метода EnqueueAwaitContinuationForRunning (о нем будет написано далее) пытается запустить делегат, продолжающий выполнение после await если таковой был сохранен, т.е. если await действительно ожидает.
Как реализован await this
Операция await, как известно, не требует, чтобы ожидаемое наследовалось от определенного класса или реализовывало какой-то определенный интерфейс, а опирается на поведенческую типизацию (она же — "утиная", по-английски — "duck typing"): если у класса есть метод GetAwaiter(), который возвращает объект-awaiter, удовлетворяющий определенным соглашениям, то этот класс может служить ожидаемым.
Слово awaitable, которое используется в документации, я предпочел перевести как "ожидаемое".
Можно было бы перевести и "awaiter": по-русски наверное, это будет "ожидатель" (а сама Microsoft в русском переводе документации использует словосочетание "средство ожидания"), но и тот, и другой перевод кажется мне убогим, поэтому в статье я использую оригинальное английское название awaiter. В конце концов, если даже сам Пушкин так делал — "Du comme il faut (Шишков, прости: не знаю как перевести)", — то и мне такое тоже, думаю, позволительно.
И чтобы не плодить сущности, я сделал ровно то, что написано в заголовке статьи: метод GetAwaiter() класса возвращает this, то есть класс — действительно сам себе awaiter.
Awaiter обязан иметь нужный набор методов и свойств. Для него тоже используется поведенческая типизация, но требований здесь побольше.
Прежде всего, awaiter должен реализовывать свойство bool IsCompleted
на случай если нам вдруг повезло и реально ждать уже не надо. Продолжить мы можем, если за то время, пока поток, выполняющий метод, добирался до await, фоновый поток успел добавить в буфер следующую запись. Это реализуется вот таким, очевидным, кодом:
bool IsCompleted { get { return _queue.Count > 0; } }
Следующий метод, который надо реализовать в awaiter- это GetResult(), он должен ожидать (синхронно) конца операции, которую мы ожидаем и, если надо, вернуть результат. Результат нам возвращать не надо, а ожидание реализует событие с ручным сбросом, которое сбрасывается или устанавливается кодом, планирующим выполнение продолжения (о нем ниже). В нашем случае, когда внутри класса производится только асинхронное ожидание в единственном методе, а ожидание на экземпляре класса невозможно, потому что все связанные с реализацией awaiter методы — закрытые (private), синхронное ожидание возникать не должно. Но, на всякий случай, оно реализовано.
readonly ManualResetEventSlim _complete_event = new ManualResetEventSlim(true);
void GetResult() { _complete_event.Wait(); }
И, наконец, awaiter обязан реализовывать интерфейс INotifyCompletion или унаследованный от него ICriticalNotifyCompletion. В настоящее время практически никакой разницы между использованием этих интерфейсов нет. Я выбрал второй вариант — реализовать ICriticalNotifyCompletion. В нем нужно реализовать два метода — OnCompleted (он унаследован от INotifyCompletion) и UnsafeOnCompleted, которые, по факту, делают одно и то же: они планируют запуск переданного им делегата продолжения по завершении операции, ожидание которой происходит.
Реализация обоих методов идентична: они вызывают один и тот же внутренний метод который сохраняет переданный ему делегат продолжения для последующего вызова.
void ICriticalNotifyCompletion.UnsafeOnCompleted(Action Continuation) { Schedule(Continuation);}
void INotifyCompletion.OnCompleted(Action Continuation) {Schedule(Continuation);}
void Schedule(Action continuation)
{
_complete_event.Reset();
try
{
if (Interlocked.CompareExchange(ref _continuation, continuation, null) != null)
{
throw new InvalidOperationException("The schedule operation failed for unknown reason.");
}
}
catch(Exception e)
{
_complete_event.Set();
throw;
}
if (_queue.IsAddingCompleted)
{
EnqueueAwaitContinuationForRunning();
}
}
Поскольку ожидать завершения операции — получения в фоновом режиме следующей записи последовательности — может только один поток управления, то делегат из этого потока достаточно сохранить просто в поле нужного типа в экземпляре. Сохранение производится потоковобезопасной операцией из класса Interlocked и, на всякий случай, проверяется, что другого ожидания операции нет. В случае успешного сохранения делегата для последующего вызова сбрасывается уже упомянутое событие с ручным сбросом, которое потенциально может ожидать метод GetResult(). Запуск делегата на выполнение обычно производится фоновой операцией перечисления (процесс будет рассмотрен далее). Но если фоновое перечисление уже завершилось, признаком чего является то, что _queue.IsAddingCompleted
возвращает true, то запустить делегат продолжения, скорее всего, будет некому, поэтому, если он есть, придется запустить его здесь: ждать-то всё равно больше нечего.
Возобновление выполнения обсуждаемого метода после завершения ожидания
Возобновление выполнения метода FetchRequiredAsync производится с помощью метода EnqueueAwaitContinuationForRunning:
void EnqueueAwaitContinuationForRunning()
{
Action? continuation = Interlocked.Exchange(ref _continuation, null);
if (continuation != null)
{
ThreadPool.UnsafeQueueUserWorkItem(RunAwaitContinuation, continuation, false);
}
}
Здесь всё просто: этот метод одной атомарной операцией извлекает и обнуляет при этом ссылку на сохраненный делегат продолжения. Если делегат продолжения там был — планирует его на выполнение в пуле потоков путем передачи на выполнение делегата для метода RunAwaitContinuation. Вот исходный код этого метода:
void RunAwaitContinuation(Action Continuation)
{
_complete_event.Set();
Continuation();
}
Метод RunAwaitContinuation сначала устанавливает событие, которое потенциально может ждать метод GetResult() и затем вызывает делегат продолжения.
Если же метод FetchRequiredAsync не находится в состоянии ожидания, то поле _continuation
, содержащее делегат продолжения — пустое и метод EnqueueAwaitContinuationForRunning ничего не делает. Поэтому его можно смело вызывать без каких-либо проверок: все, что надо, он проверит сам.
Что ещё делает обсуждаемый класс такого, что это надо учесть
Первое — у него (точнее, у родительского класса) есть метод Abort, позволяющий прервать выполнение. В том, что касается обсуждаемого класса или метода, метод Abort во-первых, отменяет CompletionToken, вызывая тем самым завершение процесса фонового перечисления, а во-вторых, вызывает виртуальный метод DoAbort, который запускает на выполнение делегат продолжения(если он есть), чтобы завершить возможное ожидание в методе FetchRequiredAsync:
protected override void DoAbort(String TraceIdentifier)
{
EnqueueAwaitContinuationForRunning();
base.DoAbort(TraceIdentifier);
}
После завершения ожидания возможны несколько путей выполнения этого метода (если кому интересно, я готов подробно расписать это в комментариях), но все они так или иначе приводят к завершению этого метода.
Второе — обсуждаемый класс обрабатывает ситуацию, когда в процессе ожидания был вызван один из методов очистки (Dispose() или DisposeAsync()) — в отличие от многих других классов в .NET обсуждаемый класс позволяет их вызывать в произвольные моменты времени. Эти методы определены в разных родительских классах, но, в любом случае, они выставляют сначала признак очистки а потом, до начала, собственно, очистки любых ресурсов, вызывают виртуальный метод PreDispose():
protected override void PreDispose()
{
base.PreDispose();
EnqueueAwaitContinuationForRunning();
}
В обсуждаемом классе этот метод просто возобновляет выполнение метода FetchRequiredAsync, а уже в последнем происходит проверка методом CheckDisposed() на то, что запущена очистка. Метод CheckDisposed(), если очистка началась (признак начала очистки уже был выставлен) выбрасывает исключение ObjectDisposedException. Таким образом, при возобновлении выполнения метода FetchRequiredAsync задача, возвращаемая этим методом в случае начала очистки завершается с этим исключением.
Ну и, наконец, await this в FetchRequiredAsync должен реагировать на отмену маркера, переданного как параметр этого метода. Это делается обработчиком для маркера отмены, устанавливаемым через его метод Register. Этот обработчик тоже просто возобновляет метод FetchRequiredAsync, а исключение OperationCanceledException выбрасывается уже после возобновления вызовом метода ThrowIfCancellationRequested.
Является ли написанная выше реализация примером универсальной реализации?
Ни в коем случае. Кроме упомянутого ограничения на параллельность, у нее есть ещё одно ограничение: операция await в этой реализации никогда не выбрасывает исключений. Вообще-то, операция await может выбросить исключение, возникшее в ожидаемом — например, в задаче (Task). Но в этой конкретном применении мне это просто не требовалось: все ситуации приводящие к исключению, обрабатываются в другом месте, совершенно отдельно: исключения в фоновом перечислении обрабатываются вообще по совсем другой логике, приводя к изменению статуса исполнителя как целого (подробнее — в цитированных статьях по библиотеке), а вызов метода очистки или отмена маркера, переданного через параметр Token во время выполнения операции await приводит просто к возобновлению ожидающего метода FetchRequiredAsync, который, если его возобновление было вызвано исключительной ситуацией, выбрасывает надлежащее исключение.
Реализация же выброса исключения в операции await — это совершенно отдельная задача, от решения которой я просто уклонился.
На самом деле я этот вариант все же реализовывал, но чисто для себя, и такой вариант реализации обсуждаемого метода можно увидеть в побочной ветке репозитория.
Если разрешить await this выбрасывать исключение, то код обсуждаемого метода FetchRequiredAsync можно немного, если так выразиться, упростить:
async Task FetchRequiredAsync(Int32 MaxAdvance, List<TItem> Result, CancellationToken Token)
{
Token.ThrowIfCancellationRequested();
try {
using(Token.Register(()=>EnqueueAwaitContinuationForRunning(ExceptionDispatchInfo.Capture(new OperationCanceledException(Token))))) {
while(Result.Count < MaxAdvance && Status.IsRunning() ){
TItem? item;
if(_queue.TryTake(out item)) Result.Add(item!);
else if(_queue.IsAddingCompleted) break;
else await this;
}
}
}
finally {
Volatile.Write(ref _exceptInfo, null);
}
}
То есть, проверку на очистку экземпляра с выбросом исключения стало возможным вообще из метода убрать (а возможностью ее начала при синхронном выполнении метода я решил пренебречь), а проверку на отмену маркера — вынести из цикла в начало метода. Там эта проверка нужна, чтобы отменить метод даже при синхронном выполнении, если маркер при вызове метода уже отменен. Нужно это или нет — вопрос спорный, но, по крайней мере, тесты у меня такой вариант изначально проверяли, а потому я его реализовал. А еще — изменился обработчик отмены маркера Token, устанавливаемый через его метод Register: он теперь не просто запускает делегат продолжения, но и передает информацию о возникновении исключения OperationCanceledException. Как он это делает — об этом я напишу чуть дальше. Ну, а ещё цикл был обернут в конструкцию try...finally, смысл которой будет раскрыт позже.
Исключение при реализации его выброса операцией await нужно выбрасывать в методе GetResult(), поэтому его код поменялся:
void GetResult() {
_complete_event.Wait();
try {
_exceptInfo?.Throw();
}
finally {
_exceptInfo=null;
}
}
Информация об исключении передается через поле экземпляра объекта ExceptionDispatchInfo? _exceptInfo
. В обсуждаемом в статье классе так делать можно, потому что для каждого экземпляра одновременно может выполняться не более одного метода FetchRequiredAsync. Метод GetResult проверяет это поле, и если оно не пустое — выбрасывает переданное через него исключение, а потом безусловно делает его опять пустым.
Метод EnqueueAwaitContinuationForRunning получил новый необязательный параметр типа ExceptionDispatchInfo, через который ему может быть передана информация об исключении, которое нужно возбудить при завершении операции await. Эта информация запоминается в поле _exceptInfo
экземпляра при условии, что там не хранится информация о другом исключении. Так что при следующем вызове метода GetResult() при завершении операции await будет вызвано хранящееся в этом поле исключение.
void EnqueueAwaitContinuationForRunning(ExceptionDispatchInfo? ExceptInfo=null)
{
Interlocked.CompareExchange(ref _exceptInfo, ExceptInfo, null);
Action? continuation = Interlocked.Exchange(ref _continuation, null);
if (continuation != null)
{
ThreadPool.UnsafeQueueUserWorkItem(RunAwaitContinuation, continuation, false);
}
}
И, в любом случае, содержимое этого поля, чтобы не влиять на последующие вызовы обсуждаемого метода FetchRequiredAsync, сбрасывается при выходе из этого метода в той самой конструкцией try...finally, о которой я обещал рассказать.
Чтобы возобновить выполнение await this с выбросом исключения, методы, которым это требуется, вызывают EnqueueAwaitContinuationForRunning с непустым аргументом, содержащим информацию об исключении. В обсуждаемом классе таких методов два. Во-первых — это обработчик отмены маркера, который производит возобновление с исключением OperationCanceledException. Его реализация была уже приведена в коде метода FetchRequiredAsync, где он устанавливается. Другой метод, который производит возобновление с исключением — это PreDispose, возобновляющий выполнение после операции await this с выбросом исключения ObjectDisposedException. Его код после изменения выглядит так:
protected override void PreDispose()
{
base.PreDispose();
EnqueueAwaitContinuationForRunning(ExceptionDispatchInfo.Capture(new ObjectDisposedException(DisposedObjectName())));
}
В общем, я попробовал, как можно было реализовать ту же логику метода FetchRequiredAsync, используя выброс исключения в await this. И для полноты я привожу этот вариант здесь, в статье. Но в рабочем коде библиотеки я существующую реализацию менять не стал, ибо "не сломалось — не чини".
Можно подумать, что await всё же выбрасывает исключение в этом, основном, варианте, потому что в методе Schedule в одном месте выбрасывается исключение InvalidOperationException. Но в операцию await это исключение не попадает — оно возникает в потоке, в котором выброшенное исключение вообще не обрабатывается, что приводит к завершению всего приложения.
Это исключение играет роль плавкого предохранителя "на всякий случай". Проверка на то, что метод FetchRequiredAsync экземпляра не вызывается параллельно с другим вызовом, реально производится в доступном приложениям публичном методе родительского класса, вызывающего обсуждаемый метод (и если такое обнаруживается, приложение об этом уведомляется). Поэтому ситуация параллельного вызова метода FetchRequiredAsync, из приложения напрямую недоступного, может возникнуть только вследствие внутренней ошибки библиотеки, а в таком случае прекращение работы процесса IMHO оправдано.
Технически — да, конечно. И в своих экспериментах с кодом библиотеки я это даже сделал.
В обработчике этого исключения в методе Schedule вместо повторного выброса этого исключения оператором throw можно запустить выполнение продолжения в другом потоке через специально предназначенный для этого метод RunContinuationWithException.
void Schedule(Action continuation)
{
//...
try
{
//...
}
catch(Exception e)
{
//Do not re-throw the exception here, instead pass it to the continuation
ExceptionDispatchInfo exception_info = ExceptionDispatchInfo.Capture(e);
ThreadPool.UnsafeQueueUserWorkItem(RunContinuationWithException, (Continuation, exception_info), false);
}
//...
}
Метод RunContinuationWithException выглядит следующим образом:
void RunContinuationWithException((Action Continuation, ExceptionDispatchInfo? Error) State)
{
_exceptionPassed.Value=State.Error;
_complete_event.Set();
State.Continuation();
}
Он, так же, как и метод RunAwaitContinuation устанавливает событие, которое потенциально ожидает (на самом деле, этого никогла не происходит) метод GetResult(), сохраняет для метода GetResult() информацию об исключении и вызывает делегат продолжения. Информацию об исключении надо сохранить, потому что, как мы помним из предыдущего примера про выброс исключения операцией await (он — в скрытом тексте выше), его следует выбрасывать в методе GetResult. Но, в отличие от предыдущего примера, здесь нельзя просто взять и использовать для передачи информации об исключении какое-либо поле экземпляра обсуждаемого класса: поля экземпляра предназначены для await в нормальном (т.е. первом) вызове FetchRequiredAsync. Но выход есть: информация об исключении сохраняется в локальном для потока поле экземпляра:
ThreadLocal<ExceptionDispatchInfo?> _exceptionPassed=new ThreadLocal<ExceptionDispatchInfo?>();
Так делать можно, потому что метод GetResult гарантированно будет выполнен (делегатом продолжения) в том же потоке. Ну, а в методе GetResult() проверяется, что исключение было передано, и, если так, — выбрасывается заново:
void GetResult() {
_complete_event.Wait();
if(_exceptionPassed.Value is not null) _exceptionPassed.Value.Throw();
}
Короче, технически можно вместо прекращения работы приложения вызвать в данном случае ислючение в вызывающем коде, который сможет это исключение обрабоать. Однако, подумав, я решил оставить это исключение необработанным, потому что приложение, которое использует библиотеку, мало что может сделать с тем, что в коде библиотеки содержится ошибка.
Можно ли было сделать то же самое штатным образом?
Конечно. Иначе эта статья не была бы опубликована в хабе "Ненормальное программирование". Проще всего было бы использовать await с Task в качестве ожидаемого. Тут сразу напрашивается использование объекта типа TaskCompletionSource с ожиданием на его поле Task и вызовом соответствующих методов завершения (SetCompletion или SetException) там, где ожидание нужно завершить: из фонового метода перечисления или из методов DoAbort и PreDispose. Такой вариант реализуется несложно и он вполне совместим с любой версией .NET, вообще поддерживающей async/await. Но он имеет тот недостаток, что требует создавать объект в куче для каждого ожидания — т.е., нагружает сборщик мусора. Поэтому при изначальном написании варианта обсуждаемого класса я от него отказался: await this экономит на создании лишнего объекта в куче.
Но в современном .NET можно использовать в качестве ожидаемого структуру ValueTask, создаваемую на базе реализации интерфейса IValueTaskSource с помощью вспомогательного класса (точнее, структуры) ManualResetValueTaskSourceCore. Такая реализация тоже позволяет избежать лишних размещений объектов в куче, а потому является рекомендуемой для применения(хотя и совершенно безобразно для целей такого применения документированной).
Однако когда изначально писался код, обсуждаемый в статье, я ориентировался на совместимость с .NET Framework (причем — не самых новых версий) — в котором поддержка этого варианта появилась достаточно поздно и была добавлена через пакеты, а в базовую поставку не входила. Потому код был написан так, как написан, ну а дальше — как всегда: "не сломалось — не чини". Впрочем, реализацию на базе ValueTask я тоже сделал, для себя. Но, вообще-то, это уже не тема для статьи из хаба "Ненормальное программирование". Да и статья, в которой был реализован ValueTask с использованием ManualResetValueTaskSourceCore на Хабре уже публиковалась, как минимум, одна. Однако если в комментариях будет проявлен интерес, я эту реализацию, так или иначе, выложу для публики.
Заключение
В наше время, наверное не стоит использовать код из статьи как образец для новых разработок. Но вот как забавный пример реализации ожидаемого для использования с операцией await он, по моему мнению, вполне годится. А ещё этот код иллюстрирует, как вообще делать свою реализацию ожидаемого. Потому-то я и написал эту статью.
P.S. Здесь должна быть ссылка на мой телеграмм-канал — но у меня нет телеграмм-канала. Поэтому желающим читать меня придётся делать это на Хабре.