Когда был анонсирован 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-методов, для конечного пользователя важным я бы назвал лишь недетерменированность момента возвращения управления вызвающей стороне.