Как yield и await реализуют поток управления в .NET?

105

Насколько я понимаю, yieldключевое слово, если оно используется внутри блока итератора, возвращает поток управления вызывающему коду, а при повторном вызове итератора он возобновляет работу с того места, где остановился.

Кроме того, он awaitне только ожидает вызываемого объекта, но и возвращает управление вызывающему объекту только для того, чтобы продолжить с того места, где он остановился при вызове awaitsметода.

Другими словами, нет потока , а «параллелизм» async и await - это иллюзия, вызванная умным потоком управления, детали которого скрыты синтаксисом.

Я бывший программист на ассемблере, хорошо знаком с указателями инструкций, стеками и т. Д. И понимаю, как работают обычные потоки управления (подпрограммы, рекурсия, циклы, ветки). Но эти новые конструкции - я их не понимаю.

Когда awaitдостигается, как среда выполнения узнает, какой фрагмент кода следует выполнить дальше? Как он узнает, когда он может возобновить работу с того места, на котором остановился, и как он запоминает, где? Что происходит с текущим стеком вызовов, сохраняется ли он как-то? Что делать, если вызывающий метод выполняет вызовы других методов до того, как это await... почему стек не перезаписывается? И как, черт возьми, среда выполнения могла бы пройти через все это в случае исключения и раскрутки стека?

Когда yieldдостигается, как среда выполнения отслеживает точку, где нужно поднять что-то? Как сохраняется состояние итератора?

Джон Ву
источник
4
Вы можете посмотреть на сгенерированный код в TryRoslyn онлайн компилятором
Ксанатос
1
Вы можете проверить серию статей Eduasync Джона Скита.
Леонид Васильев
Связанное интересное чтение: stackoverflow.com/questions/37419572/…
Джейсон С.

Ответы:

115

Я отвечу на ваши конкретные вопросы ниже, но вам, вероятно, стоит просто прочитать мои обширные статьи о том, как мы разработали yield и await.

https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/

https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/

https://blogs.msdn.microsoft.com/ericlippert/tag/async/

Некоторые из этих статей уже устарели; сгенерированный код во многом отличается. Но они, безусловно, дадут вам представление о том, как это работает.

Кроме того, если вы не понимаете, как лямбда-выражения генерируются как классы закрытия, сначала поймите это . Вы не будете разбираться в асинхронности, если у вас нет лямбд.

Когда достигается ожидание, как среда выполнения узнает, какой фрагмент кода следует выполнить дальше?

await создается как:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

Вот в основном это. Ожидание - это просто фантастическое возвращение.

Как он узнает, когда он может возобновить работу с того места, на котором остановился, и как он запоминает, где?

Ну как это сделать без ожидания? Когда метод foo вызывает метод bar, мы каким-то образом запоминаем, как вернуться в середину foo, со всеми локальными переменными, активировавшими foo, независимо от того, что делает bar.

Вы знаете, как это делается на ассемблере. Запись активации для foo помещается в стек; он содержит ценности местных жителей. В момент вызова адрес возврата из foo помещается в стек. Когда bar готов, указатель стека и указатель инструкции сбрасываются туда, где они должны быть, и foo продолжает движение с того места, где он остановился.

Продолжение ожидания точно такое же, за исключением того, что запись помещается в кучу по той очевидной причине, что последовательность активаций не образует стек .

Делегат, который await дает в качестве продолжения задачи, содержит (1) число, которое является входом в таблицу поиска, которая дает указатель инструкции, которую вам нужно выполнить дальше, и (2) все значения локальных и временных переменных.

Там есть дополнительное снаряжение; например, в .NET запрещено переходить в середину блока try, поэтому вы не можете просто вставить адрес кода внутри блока try в таблицу. Но это бухгалтерские детали. По сути, запись активации просто перемещается в кучу.

Что происходит с текущим стеком вызовов, сохраняется ли он как-то?

Соответствующая информация в текущей записи активации никогда не помещается в стек; он выделяется из кучи с самого начала. (Ну, формальные параметры обычно передаются в стек или в регистры, а затем копируются в место в куче при запуске метода.)

Записи об активации вызывающих абонентов не сохраняются; ожидание, вероятно, вернется к ним, помните, так что с ними справятся нормально.

Обратите внимание, что это существенное различие между упрощенным стилем передачи продолжения в await и настоящими структурами вызова с текущим продолжением, которые вы видите в таких языках, как Scheme. На этих языках все продолжение, включая продолжение до вызывающих абонентов, фиксируется call-cc .

Что делать, если вызывающий метод вызывает другие методы до того, как он ожидает - почему стек не перезаписывается?

Эти вызовы методов возвращаются, и поэтому их записи активации больше не находятся в стеке на момент ожидания.

И как, черт возьми, среда выполнения могла бы пройти через все это в случае исключения и раскрутки стека?

В случае неперехваченного исключения исключение перехватывается, сохраняется внутри задачи и повторно генерируется при получении результата задачи.

Помните всю бухгалтерию, о которой я упоминал ранее? Правильная семантика исключений была огромной проблемой, позвольте мне вам сказать.

Когда доходность достигается, как среда выполнения отслеживает точку, где нужно поднять что-то? Как сохраняется состояние итератора?

Так же. Состояние локальных переменных перемещается в кучу, а число, представляющее инструкцию, с которой MoveNextследует возобновить выполнение при следующем вызове, сохраняется вместе с локальными переменными.

И опять же, в блоке итератора есть множество приспособлений, чтобы убедиться, что исключения обрабатываются правильно.

Эрик Липперт
источник
1
из-за предыстории авторов вопроса (ассемблер и др.), возможно, стоит упомянуть, что обе эти конструкции невозможны без управляемой памяти. Без управляемой памяти, пытающейся скоординировать время жизни замыкания, вы определенно отключитесь от начальной загрузки.
Джим
Ссылка на все страницы не найдена (404)
Digital3D
Все ваши статьи сейчас недоступны. Не могли бы вы их перепостить?
Михал Турчин,
1
@ MichałTurczyn: Они все еще в Интернете; Microsoft продолжает двигаться туда, где находится архив блога. Я постепенно перенесу их на свой личный сайт и постараюсь обновить эти ссылки, когда у меня будет время.
Эрик Липперт,
38

yield это более легкий из двух, поэтому давайте рассмотрим его.

Скажем, у нас есть:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

Это немного компилируется, как если бы мы написали:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

Таким образом, не так эффективно, как рукописная реализация IEnumerable<int>и IEnumerator<int>(например, мы, скорее всего, не будем тратить зря, имея отдельный _state, _iи _currentв этом случае), но неплохо (уловка повторного использования себя, когда это безопасно, а не создание нового объект хорош) и расширяемый, чтобы иметь дело с очень сложными yieldметодами.

И конечно с тех пор

foreach(var a in b)
{
  DoSomething(a);
}

Такой же как:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

Затем сгенерированный MoveNext()вызывается повторно.

В asyncкорпусе почти такой же принцип, но с небольшой дополнительной сложностью. Чтобы повторно использовать пример из другого кода ответа, например:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

Создает такой код:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

Это более сложный, но очень похожий основной принцип. Основная дополнительная сложность в том, что сейчас GetAwaiter()используется. Если какое -то время awaiter.IsCompletedпроверяется она возвращается , trueтак как задача awaitред уже завершен (например , случаи , когда он может вернуться синхронно) , то метод продолжает двигаться через состояние, но в остальном она позиционирует себя в качестве обратного вызова к awaiter.

Что с этим происходит, зависит от ожидающего, с точки зрения того, что запускает обратный вызов (например, завершение асинхронного ввода-вывода, выполнение задачи в потоке) и какие требования существуют для маршалинга в конкретный поток или выполнения в потоке пула потоков. , какой контекст из исходного вызова может понадобиться, а может и нет, и так далее. Что бы это ни было, хотя что-то в этом awaiter вызовет в, MoveNextи он либо продолжит следующую часть работы (до следующей await), либо завершит и вернется, и в этом случае то, Taskчто он реализует, станет завершенным.

Джон Ханна
источник
Вы нашли время, чтобы накатывать собственный перевод? О_О уао.
CoffeDeveloper
4
@DarioOO Первый я могу сделать довольно быстро, проделав много переводов из yieldк скрученные вручную , когда есть преимущество в этом ( как правило , в качестве оптимизации, но хочет , чтобы убедиться , что исходная точка близка к сгенерированный компилятором поэтому из-за неверных предположений ничего не деоптимизируется). Второй был впервые использован в другом ответе, и в то время в моих собственных знаниях было несколько пробелов, поэтому я извлек для себя пользу, заполнив их, предоставив этот ответ путем ручной декомпиляции кода.
Джон Ханна
13

Здесь уже есть масса отличных ответов; Я просто собираюсь поделиться несколькими точками зрения, которые помогут сформировать ментальную модель.

Сначала asyncкомпилятор разбивает метод на несколько частей; то awaitвыражение точка перелома. (Это легко понять для простых методов; более сложные методы с циклами и обработкой исключений также распадаются с добавлением более сложного конечного автомата).

Во-вторых, awaitпереводится в довольно простую последовательность; Мне нравится описание Люциана , которое на словах в значительной степени звучит примерно так: «если ожидаемое уже выполнено, получить результат и продолжить выполнение этого метода; в противном случае сохраните состояние этого метода и вернитесь». (Я использую очень подобную терминологию в моем asyncинтро ).

Когда достигается ожидание, как среда выполнения узнает, какой фрагмент кода следует выполнить дальше?

Остальная часть метода существует как обратный вызов для ожидаемого (в случае задач эти обратные вызовы являются продолжениями). Когда ожидаемый завершается, он вызывает свои обратные вызовы.

Обратите внимание , что стек вызовов не сохраняются и восстанавливаются; обратные вызовы вызываются напрямую. В случае перекрывающегося ввода-вывода они вызываются непосредственно из пула потоков.

Эти обратные вызовы могут продолжать выполнение метода напрямую или могут запланировать его запуск в другом месте (например, если awaitзахваченный пользовательский интерфейс SynchronizationContextи ввод-вывод завершены в пуле потоков).

Как он узнает, когда он может возобновить работу с того места, на котором остановился, и как он запоминает, где?

Это все просто обратные вызовы. Когда ожидаемый завершается, он вызывает свои обратные вызовы, и любой asyncметод, который уже был изменен, awaitвозобновляется. Обратный вызов переходит в середину этого метода и имеет в области его локальные переменные.

Обратные вызовы являются не запускать определенный поток, и они не имеют их CallStack восстановлены.

Что происходит с текущим стеком вызовов, сохраняется ли он как-то? Что делать, если вызывающий метод вызывает другие методы до того, как он ожидает - почему стек не перезаписывается? И как, черт возьми, среда выполнения могла бы пройти через все это в случае исключения и раскрутки стека?

Стек вызовов не сохраняется в первую очередь; в этом нет необходимости.

С синхронным кодом вы можете получить стек вызовов, который включает всех ваших вызывающих, и среда выполнения знает, куда вернуться, используя это.

С помощью асинхронного кода вы можете получить кучу указателей обратного вызова, основанных на некоторой операции ввода-вывода, которая завершает свою задачу, которая может возобновить asyncметод, завершающий свою задачу, который может возобновить asyncметод, завершающий свою задачу, и т. Д.

Таким образом, с синхронным код Aвызывающего Bвызова C, ваш стек вызовов может выглядеть следующим образом :

A:B:C

тогда как асинхронный код использует обратные вызовы (указатели):

A <- B <- C <- (I/O operation)

Когда доходность достигается, как среда выполнения отслеживает точку, где нужно поднять что-то? Как сохраняется состояние итератора?

В настоящее время довольно неэффективно. :)

Он работает так же, как и любая другая лямбда - время жизни переменных увеличивается, а ссылки помещаются в объект состояния, который находится в стеке. Лучшим источником всех подробных сведений является серия EduAsync Джона Скита .

Стивен Клири
источник
7

yieldи await, хотя оба имеют дело с управлением потоком, две совершенно разные вещи. Поэтому я рассмотрю их отдельно.

Цель yieldсостоит в том, чтобы упростить построение ленивых последовательностей. Когда вы пишете цикл перечислителя с yieldвыражением в нем, компилятор генерирует массу нового кода, которого вы не видите. Под капотом он фактически генерирует совершенно новый класс. Класс содержит члены, отслеживающие состояние цикла, и реализацию IEnumerable, так что каждый раз, когда вы вызываете MoveNextего, проходите через этот цикл еще раз. Итак, когда вы выполняете такой цикл foreach:

foreach(var item in mything.items()) {
    dosomething(item);
}

сгенерированный код выглядит примерно так:

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

Внутри реализации mything.items () находится набор кода конечного автомата, который выполняет один «шаг» цикла, а затем возвращается. Итак, пока вы пишете это в исходном коде как простой цикл, под капотом это не простой цикл. Итак, хитрость компилятора. Если вы хотите увидеть себя, извлеките ILDASM, ILSpy или аналогичные инструменты и посмотрите, как выглядит сгенерированный IL. Это должно быть поучительно.

asyncи await, с другой стороны, совсем другой котел с рыбой. В абстрактном смысле Await является примитивом синхронизации. Это способ сказать системе: «Я не могу продолжать, пока это не будет сделано». Но, как вы заметили, не всегда есть нить.

Речь идет о том, что называется контекстом синхронизации. Всегда один торчит. Задача их контекста синхронизации - планировать ожидаемые задачи и их продолжения.

Когда вы говорите await thisThing(), происходит несколько вещей. В асинхронном методе компилятор фактически разбивает метод на более мелкие фрагменты, каждый из которых является разделом «до ожидания» и разделом «после ожидания» (или продолжением). При выполнении await ожидаемая задача и последующее продолжение - другими словами, остальная часть функции - передается в контекст синхронизации. Контекст заботится о планировании задачи, и когда она завершается, контекст запускает продолжение, передавая любое возвращаемое значение, которое ему нужно.

Контекст синхронизации может делать все, что хочет, до тех пор, пока он планирует что-то. Он мог использовать пул потоков. Он может создавать поток для каждой задачи. Он мог запускать их синхронно. Разные среды (ASP.NET и WPF) предоставляют разные реализации контекста синхронизации, которые делают разные вещи в зависимости от того, что лучше всего подходит для их сред.

(Бонус: когда-нибудь задумывались, что .ConfigurateAwait(false)делает? Он сообщает системе не использовать текущий контекст синхронизации (обычно в зависимости от типа вашего проекта - например, WPF или ASP.NET) и вместо этого использовать стандартный, который использует пул потоков).

Итак, опять же, это большая уловка компилятора. Если вы посмотрите на сгенерированный код, он сложен, но вы сможете увидеть, что он делает. Подобные преобразования сложны, но детерминированы и математичны, поэтому замечательно, что компилятор делает их за нас.

PS Есть одно исключение из существования контекстов синхронизации по умолчанию - у консольных приложений нет контекста синхронизации по умолчанию. Посетите блог Стивена Туба для получения дополнительной информации. Это отличное место для поиска информации asyncи awaitв целом.

Крис Таварес
источник
1
«Он говорит системе не использовать контекст синхронизации по умолчанию, а вместо этого использовать контекст по умолчанию, который использует пул потоков». Вы можете прояснить, что вы имеете в виду? «не использовать значение по умолчанию, использовать значение по умолчанию»
Кролтан
3
Извините, перепутала терминологию, поправлю пост. По сути, не используйте стандартную для среды, в которой вы находитесь, используйте стандартную для .NET (т.е. пул потоков).
Крис Таварес
очень просто, смог понять, вы получили мой голос :)
Ehsan
4

Обычно я бы рекомендовал взглянуть на CIL, но в этом случае это беспорядок.

Эти две языковые конструкции похожи по работе, но реализованы немного по-разному. По сути, это просто синтаксический сахар для магии компилятора, на уровне сборки нет ничего сумасшедшего / небезопасного. Рассмотрим их кратко.

yieldэто более старая и простая инструкция, и это синтаксический сахар для основного конечного автомата. Метод возвращает IEnumerable<T>или IEnumerator<T>может содержать yield, который затем преобразует метод в фабрику конечного автомата. Следует обратить внимание на то, что код в методе не запускается в тот момент, когда вы его вызываете, если есть yieldвнутри. Причина в том, что код, который вы пишете, переносится в IEnumerator<T>.MoveNextметод, который проверяет состояние, в котором он находится, и запускает правильную часть кода. yield return x;затем преобразуется во что-то вродеthis.Current = x; return true;

Если вы немного поразмышляете, вы можете легко проверить сконструированный конечный автомат и его поля (по крайней мере, одно для состояния и для локальных). Вы даже можете сбросить его, если измените поля.

awaitтребует некоторой поддержки со стороны библиотеки типов и работает несколько иначе. Он принимает аргумент Taskили Task<T>, затем либо приводит к его значению, если задача завершена, либо регистрирует продолжение через Task.GetAwaiter().OnCompleted. Объяснение полной реализации системы async/ awaitзаняло бы слишком много времени, но это тоже не так уж и мистично. Он также создает конечный автомат и передает его в продолжение в OnCompleted . Если задача завершена, она использует свой результат в продолжении. Реализация awaiter решает, как вызвать продолжение. Обычно он использует контекст синхронизации вызывающего потока.

Оба yieldи awaitдолжны разделить метод на основе их возникновения, чтобы сформировать конечный автомат, где каждая ветвь машины представляет каждую часть метода.

Вы не должны думать об этих концепциях в терминах «нижнего уровня», таких как стеки, потоки и т. Д. Это абстракции, и их внутренняя работа не требует какой-либо поддержки со стороны CLR, это просто компилятор, который творит чудеса. Это сильно отличается от сопрограмм Lua, которые имеют поддержку среды выполнения, или longjmp C , который является просто черной магией.

IllidanS4 хочет вернуть Монику
источник
5
Боковое примечание : awaitне обязательно брать Задачу . Все , что с INotifyCompletion GetAwaiter()достаточно. Немного похоже на то, как foreachне нужно IEnumerable, IEnumerator GetEnumerator()достаточно всего с .
IllidanS4 хочет вернуть Монику