Найти тему
using Dev

Как на самом деле работает Async/Await в C# Ч.5

Итераторы C# приходят на помощь

Проблеск надежды на это решение на самом деле появился за несколько лет до того Task, как появился C# 2.0, когда в него была добавлена ​​поддержка итераторов.

«Итераторы?» ты спрашиваешь? — Ты имеешь в виду за IEnumerable<T>? Это тот самый. Итераторы позволяют вам написать один метод, который затем используется компилятором для реализации IEnumerable<T>и/или IEnumerator<T>. Например, если бы я хотел создать перечисляемое, возвращающее последовательность Фибоначчи, я мог бы написать что-то вроде этого:

-2

Затем я могу перечислить это с помощью foreach:

-3

Я могу скомпоновать его с другими IEnumerable<T>с помощью комбинаторов, например System.Linq.Enumerable:

-4

Или я могу просто вручную перечислить его напрямую через IEnumerator<T>:

-5

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

-6

Самое интересное в этом то, что для достижения вышеописанного нам нужно иметь возможность входить и выходить из этого Fib метода несколько раз. Мы вызываем MoveNext, он входит в метод, затем метод выполняется до тех пор, пока не встретит yield return, после чего вызов должен MoveNext вернуться true, а последующий доступ Current должен вернуть полученное значение. Затем мы вызываем MoveNext снова, и нам нужно иметь возможность продолжить работу Fib сразу после того, где мы остановились в последний раз, и сохранить все состояние предыдущего вызова. Итераторы по сути представляют собой сопрограммы, предоставляемые языком/компилятором C#, причем компилятор расширяет мой Fib итератор до полноценного конечного автомата:

-7
-8
-9

Вся логика Fib теперь находится внутри метода MoveNext, но как часть таблицы переходов, которая позволяет реализации перейти к тому месту, где она в последний раз остановилась, что отслеживается в сгенерированном поле состояния типа перечислителя. А переменные, которые я написал как локальные, такие как prev, next и sum, были «подняты» до полей в перечислителе, чтобы они могли сохраняться при вызовах MoveNext.

(Обратите внимание, что предыдущий фрагмент кода, показывающий, как компилятор C# генерирует реализацию, не будет компилироваться как есть. Компилятор C# синтезирует «непроизносимые» имена, то есть он называет типы и члены, которые он создает, способом, который является допустимым IL, но недопустимым C#. чтобы не конфликтовать с типами и членами, именуемыми пользователем. Я сохранил все имена, как это делает компилятор, но если вы хотите поэкспериментировать с компиляцией, вы можете переименовать элементы, чтобы использовать вместо них допустимые имена C#.)

В моем предыдущем примере последняя форма перечисления, которую я показал, включала вручную использование метода IEnumerator<T>. На этом уровне мы вызываем вручную MoveNext(), решая, когда настало подходящее время для повторного входа в сопрограмму. Но… что, если вместо того, чтобы вызывать его таким образом, я мог бы вместо этого сделать следующий вызов MoveNext фактически частью работы продолжения, выполняемой после завершения асинхронной операции? Что, если бы я мог yield return что-то представлять собой асинхронную операцию и заставить потребляющий код подключить продолжение к полученному объекту, где это продолжение затем выполняет MoveNext? При таком подходе я мог бы написать такой вспомогательный метод:

-10

Теперь это становится интересным. Нам дано перечисление задач, которые мы можем выполнить. Каждый раз, когда MoveNext переходим к следующему Task и получаем одно, мы затем подключаем к нему продолжение Task; когда этот Task завершится, он просто развернется и вернется к той же логике, которая выполняет a MoveNext, получает следующую Task и так далее. Это основано на идее Task как единого представления для любой асинхронной операции, поэтому передаваемое нам перечисляемое может быть последовательностью любых асинхронных операций. Откуда могла взяться такая последовательность? Конечно же, с помощью итератора. Помните наш предыдущий CopyStreamToStream пример и насколько ужасной была реализация на основе APM? Вместо этого рассмотрим следующее:

-11

Ого, это почти разборчиво. Мы вызываем этот IterateAsync помощник, и перечисляемое, которое мы ему передаем, создается итератором, который обрабатывает весь поток управления копией. Он вызывает Stream.ReadAsync и затем yield return-уть этот Task; эта полученная задача будет передана IterateAsync после ее вызова MoveNext и IterateAsync подключит к ней продолжение Task, которое после завершения просто перезвонит MoveNext и вернется в этот итератор сразу после yield. В этот момент Impl логика получает результат метода, вызывает WriteAsync и снова возвращает результат, Task который он произвел. И так далее.

И это, друзья мои, начало async/ awaitв C# и .NET. Примерно 95% логики поддержки итераторов и async/ await в компиляторе C# являются общими. Разный синтаксис, разные типы, но по сути одно и то же преобразование. Прикоснитесь к yield returns, и вы почти увидите await вместо них.

Фактически, некоторые предприимчивые разработчики использовали итераторы таким образом для асинхронного программирования до того, как async/ await появился на сцене. Аналогичное преобразование было прототипировано в экспериментальном языке программирования Axum , что послужило ключевым вдохновением для поддержки асинхронности в C#. Axum предоставил async ключевое слово, которое можно было поместить в метод, как async сейчас в C#. Task еще не был повсеместным, поэтому внутри async методов компилятор Axum эвристически сопоставлял синхронные вызовы методов с их аналогами APM, например, если он видел, что вы вызываете stream.Read, он находил и использовал соответствующие методы stream.BeginRead и stream.EndRead, синтезируя соответствующий делегат для передачи в Begin метод, а также генерировать полную реализацию APM для asyncопределяемого метода, чтобы она была композиционной. Он даже интегрирован с SynchronizationContext! Хотя в конечном итоге Axum был отложен, он послужил потрясающим и мотивирующим прототипом того, что в конечном итоге стало async/ awaitв C#.

Источник

to be continued...