Итераторы и async-методы C# - так ли они похожи?
Когда был анонсирован C# 5.0 с поддержкой async
, то многие любители поворчать заметили, что в мажорной фиче как таковой нету никакой инновации, ведь товарищ Джеффри Рихтер уже сто лет назад прекрасно делал те же трюки в своей библиотечке Wintellect Power Threading с помощью типа AsyncEnumerator
и фичи C# версии аж 2.0 - методов-итераторов.
Отчасти это действительно так, но разбирая детали реализации async
/await
во время реализации декомпиляции async
-методов в движке декомпилятора нашего продукта откопались интересные особенности, из которых захотелось сформировать список отличий async
-методов от итераторов:
- В отличие от итераторов в Python, конструкция
yield return
в C# - стейтмент, а не выражение. В связи с этим, значения из итераторов можно только возвращать, а вот как-то сообщать коду итератора какие-либо значения из “внешнего мира” немного проблематично - например, через состояние во вспомогательных объектах. Другое дело - ключевое словоawait
, порождающее выражение, которое можно сколько угодно раз использовать в одном стейтменте, еще и вкладывая друг в друга. Такая гибкость крайне усложняет реализацию трансформацииasync
-метода в конечный автомат, так как появляется необходимость в умении сохранять значение со стека (практически всегда это временные значения, получающиеся во время вычисления выражений текущего стейтмента) в замыкание, если какой-либо изawait
‘ов потребует отложить исполнение метода.
Трансформация еще более усложняется тем, что на стеке могут существовать значения типа (непредставимого явно в C#), значения которого нельзя откладывать в замыкания (так как получаемый MSIL перестает быть верифицируемым) - типа managed-ссылок, используемого для ref
/out
-параметров в C#. В таких случаях компилятор C# должен куда-то сохранить в замыкание все необходимое для того, чтобы повторно получить managed-ссылку после продолжения исполнения async
-метода. Вот такой простой async
-метод:
class Foo {
async void Bar(int[] xs, int i, Task<int> t) {
M(ref xs[i], await t);
}
}
Разворачивается вот в такую простыню, использующую даже семейство типов System.Tuple<..>
из состава BCL 4.0:
class Foo {
void Bar(int[] xs, int i, Task<int> t) {
BarStateMachine sm;
// складываем параметры и this в замыкание
sm._this = this;
sm._xs = xs;
sm._i = i;
sm._t = t;
// подготавливаем инфраструктуру
sm._builder = AsyncVoidMethodBuilder.Create();
sm._state = -1;
// теперь структура на стеке полностью инициализирована
// запускаем метод, не бокся и не копируя структуру
sm._builder.Start(ref sm);
}
}
[StructLayout(LayoutKind.Auto)] // выровнить поля
struct BarStateMachine : IAsyncStateMachine {
// инфраструктура:
public int _state;
public AsyncVoidMethodBuilder _builder;
private TaskAwaiter<int> _awaiter;
private object _stack;
// замыкание:
public Foo _this;
public int[] _xs;
public int _i;
public Task<int> _t
void IAsyncStateMachine.MoveNext() {
try { // всегда исполняемся под try-catch
Foo @this;
int[] xs;
int i;
TaskAwaiter<int> awaiter;
if (_state == -1) { // проверяем состояние
// нас запустили первый раз, достаем
// замыкание в локальные переменные:
var thisCopy = _this;
var xsCopy = _xs;
var iCopy = _i;
awaiter = _t.GetAwaiter();
if (!awaiter.IsCompleted) {
// нужно отложить исполнение метода, складываем
// состояние стека в кортеж, а его в замыкание:
_stack = Tuple.Create(thisCopy, xsCopy, iCopy);
_state = 0; // двигаем состояние
_awaiter = awaiter;
// тут боксинг структуры, создание делегата для
// продолжения исполнения и передача его awaiter'у:
_builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // возвращаем управление
}
// awaiter сразу вернул результат
i = iCopy;
xs = xsCopy;
@this = thisCopy;
}
else { // продолжаем исполнение
// достаем из замыкания кортеж с состоянием стека
var tuple = (Tuple<Foo, int[], int>) _stack;
@this = tuple.Item1;
xs = tuple.Item2;
i = tuple.Item3;
awaiter = _awaiter;
// подчищаем замыкание чтобы не удерживать объекты
_stack = null;
_awaiter = default(TaskAwaiter<int>);
_state = -1;
}
// достаем результат из awaiter'а
var result = awaiter.GetResult();
// и наконец вызываем метод M()
@this.M(ref xs[i], result);
} catch (Exception ex) {
_state = -2;
_builder.SetException(ex);
return;
}
_state = -2;
_builder.SetResult();
}
}
- У итераторов из-за природы интерфейса
IEnumerable
есть особенность -GetEnumerator()
можно вызвать несколько раз. Каждый из возвращенныхIEnumerator
‘ов представляет собой исполнение итератора с переданными ему значениями параметров. Так как параметры по сути являются переменными и их могут изменить в теле итератора, компилятор C# откладывает значения параметров в отдельное замыкание, которое потом копирует в каждый изIEnumerator
‘ов. Однако, если итератор сделать возвращающим типIEnumerator
, то дополнительное замыкание создаваться не будет и итератор будет действительно “одноразовым”.
Однако, на момент реализации итераторов в C# 2.0 почему-то совсем не задумались о сборке мусора. В отличие от обычных методов, в которых и локальные переменные (и даже this
) могут быть (и часто бывают) собраны сборщиком мусора еще до окончания работы метода, в итераторах все локальные переменные, попавщие в замыкание (то есть существующие между yield return
‘ами), будут строгими ссылками, держащими объекты пока весь объект итератора не подвергнется сборке мусора.
Ситуацию можно улучшить, если анализировать достижимость переменных замыкания и при выходе переменной из зоны достижимости в коде итератора затирать ее поле в замыкании null
‘ом (или default(T)
для типов-значений). Именно это происходит в async
-методах (можно увидеть на примере поля _awaiter
из кода выше), так как async
-методы всегда “одноразовые” и не хранят состояние первоначального вызова.
- Вызов итератора возвращает управление сразу же - итератор сохраняет значения переданных параметров в
IEnumerable
/IEnumerator
-замыкание, которое тут же возвращается вызывающей стороне. В случаеasync
-методов момент возврата управления вызывающей стороне может быть неизвестен. Тип, реализующий “awaiter pattern” (наличие которого требует выражениеawait
) и отвечающий за планировку вызова продолженияasync
-метода и возврат значенияawait
-выражения, может сообщить коду async-метода, что результат получен синхронно (TaskAwaiter
, например, синхронно вернет результат в случае, если Task подawait
‘ом уже находится вCompleted
-состоянии). В таком случаеawait
-выражение будет выполнено синхронно, а значит нет никаких гарантий когда именно будет возвращено исполнение вызывающей сторонеasync
-метода.
Из этого так же следует, что и экземпляр Task
, возвращаемый самими async
-методами в случае их полностью синхронного исполнения, возвращается вызывающей стороне уже в состоянии Completed
, что может так же повлечь синхронные исполнения await
‘ов у вызывающей стороны. Это одновременно и замечательная оптимизация, и потенциальный источник неочевидного поведения, если ожидать от какого-нибудь await
‘а “форка” потока исполнения метода (лучше сделать await Task.Yield()
если нужна гарантия и не жалко потенциального лишнего перключения контекста).
Однако есть и замечательный плюс - в отличие от итераторов, всяческие проверки значений аргументов в начале async
-методов происходят сразу и синхронно. Итераторы же могут маскировать неудовлетворение их предусловий, так как начало тема метода-итератора вызывается только на первый вызов MoveNext()
, которого может и не быть вовсе (например, xs.Take(0)
).
- Из-за того, что
async
-метод может выполниться целиком синхронно, его местами значительно проще декомпилировать, чемyield return
-итераторы. Можно просто удалить ребра графа потока исполнения, отвечающие за отложенное исполнениеawait
-выражений (конкретноif (!awaiter.IsCompleted) { … }
) и код метода уже станет вполне узнаваемым и пригодным для дальнейших трансформаций.
У итераторов тоже есть дополнительные ребра, вот только они наоборот усложняют декомпиляцию. Дополнительные путь исполнения нужен для того, чтобы предусмотреть поведение итератора в случае, если его IEnumerator
‘у пользователь сделает Dispose()
, не выполнив итератор до конца - ничего подобного в async
-методах нет (поэтому нельзя написать полноценные итераторы на async
-методах). В случае Dispose
‘а итератор просто исполняет все блоки finally
, в которых находится yield return
, на котором последний раз остановился итератор:
IEnumerator<int> Boo(int x, int y) {
try { yield return x; }
finally { Console.WriteLine("finally1"); }
try { yield return y; }
finally { Console.WriteLine("finally2"); }
}
Трансформируется во что-то такое:
class BooIterator : IEnumerator<int>, IDisposable {
int _current, _state;
public int _x, _y;
public bool MoveNext() {
try {
switch (_state) {
case 0:
_state = 1;
_current = _x; // первый yeild return
_state = 2;
return true;
case 2:
_state = 1;
Finally1(); // вышли из первого finally
_state = 3;
_current = _y; // второй yield return
_state = 4;
return true;
case 4:
_state = 3;
Finally2(); // вышли из второго finally
break;
}
return false;
}
fault { // только в случае исключений
Dispose();
}
}
public void Dispose() {
switch (_state) {
case 1:
case 2:
try { } finally { Finally1(); }
break;
case 3:
case 4:
try { } finally { Finally2(); }
break;
}
}
void Finally1() {
_state = -1;
Console.WriteLine("finally1");
}
void Finally2() {
_state = -1;
Console.WriteLine("finally2");
}
}
Получается, что один и тот же код finally
-блоков может потребоваться и во время MoveNext()
, и во время вызова Dispose()
- поэтому каждый finally
-блок вообще порождает отдельный метод, что вынуждает разработчика декомпилятора склеивать обратно итератор из кусочков разных методов. Помимо этого, из-за того что итератор на каждый yield return
всегда прерывает свое исполнение, приходится собирать табличку состояний итератора и заменять всякие _state = 42;
на дополнительные ребра-переходы непосредственно к коду 42-ого состояния итератора.
- Как говориться, “опытный читатель мог заметить”, что в отличие от итераторов, для
async
-методов для замыкания генерируется не класс, а структура. Это тоже оптимизация, позволяющая экономить на аллокации хранилища для замыкания в куче, но только в случаях когда всеawait
-выражения выполнятся синхронно. Часто-ли так будет случаться - сложно судить, но я сильно сомневаюсь. Зато данная особенность замечательно запутывает реализации компиляторов и декомпиляторов, так как приходится учиться работать с value type-замыканиями.
Еще хуже то, что замыкания разного вида “не компоузятся” вовсе, поэтому для async
-лямбда-выражений и async
-анонимных-методов (слава Вселенной что ребята из Редмонда вообще сделали их, конечно), генерируется по два замыкания. Например:
void M(int x) {
Func<Task> f = async () => {
Console.WriteLine(x);
};
}
Превращается в:
private void M(int x) {
var displayClass1 = new DisplayClass1();
displayClass1._x = x;
Func<Task> func = displayClass1.Lambda;
}
// замыкание для лямбда-выражения
[CompilerGenerated]
sealed class DisplayClass1 {
public int _x;
public Task Lambda() {
DisplayClass2 displayClass2;
displayClass2._this = this;
displayClass2._builder = AsyncTaskMethodBuilder.Create();
displayClass2._state = -1;
displayClass2._builder.Start(ref displayClass2);
return displayClass2._builder.Task;
}
// замыкание для async-метода
[StructLayout(LayoutKind.Auto)]
struct DisplayClass2 : IAsyncStateMachine {
public int _state;
public AsyncTaskMethodBuilder _builder;
public DisplayClass1 _this;
void IAsyncStateMachine.MoveNext() {
try {
if (_state != -3)
Console.WriteLine(_this._x);
}
catch (Exception ex) {
_state = -2;
_builder.SetException(ex);
return;
}
_state = -2;
_builder.SetResult();
}
}
}
- Есть еще одна оптимизация в итераторах, которая отсутствует в
async
-методах. Есть мнение, что в абсолютном большинстве случаев использованияIEnumerable
-итераторов, методGetEnumerator()
будет вызываться ровно один раз, поэтому хотелось бы вовсе ликвидировать аллокацию экземпляраIEnumerator
‘а. Для этого используется достаточно грубый хак:
[CompilerGenerated]
private sealed class Iterator
: IEnumerable<int>, IEnumerator<int> { // все в одном
private int _current;
private int _state;
private int _initialThreadId;
public Iterator(int state) {
_state = state; // изначально тут будет -2
// гыгы, запоминаем в каком треде вызвали итератор
_initialThreadId = Environment.CurrentManagedThreadId;
}
IEnumerator<int> IEnumerable<int>.GetEnumerator() {
// если энумератор спросили с того же треда
if (Environment.CurrentManagedThreadId == _initialThreadId
&& _state == -2) { // и нас не трогали до этого
_state = 0; // отмечаем что нас использовали
return this; // никаких аллокаций
}
return new Iterator(0);
}
...
}
Почему “хак”? Да потому что получается, что кодогенерация языковой фичи завязана на инфраструктуру трединга, которая в мире разных дотнет-платформ может отличаться. Приведенный выше кусок кода скомпилирован под .NET Framework 4.5, в котором ввели статическое свойство Environment.CurrentManagedThreadId
чтобы хоть как-то обобщить способ идентификации тредов. До 4.5 этот же итератор компилировался бы с вызовом Thread.CurrentThread.ManagedThreadId
- это приводит к тому, что проект, тергетируемый под .NET Framework 4.5, но реально использующий только апишки .NET Framework 4.0 будет крэшиться на итераторах по MethodMissingException
будучи запущенным на машине с .NET Framework только версии 4.0. Очень хорошо, что async
-методы “одноразовые” и подобных оптимизаций там нету.
Подводя итог хочется сказать, что не смотря на множество отличий в кодогенерации итераторов и async
-методов, для конечного пользователя важным я бы назвал лишь недетерменированность момента возвращения управления вызвающей стороне.