Как сделать выход и ждать реализации потока управления in.NET?


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

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

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

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

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

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

5 97

5 ответов:

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

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 is генерируется как:

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 неповрежденными, независимо от того, что делает бар.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// 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() неоднократно звонил.

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

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

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

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

второе, await переводится в довольно простой последовательности; мне нравится описание Люциана, что в словах довольно много "если ожидаемое уже завершено, получите результат и продолжите выполнение этого метода; в противном случае сохраните состояние этого метода и вернитесь". (Я использую очень похожую терминологию в моем async интро).

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

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

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

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

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

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

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

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

стек вызовов не сохраняется в во-первых, это не обязательно.

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

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

так, с синхронный код A вызов B вызов C, ваш callstack может выглядеть так:

A:B:C

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

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

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

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

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

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 ()-это куча кода state-machine, который будет делать один "шаг" цикла, а затем возвращаться. Поэтому, когда вы пишете его в источнике, как простой цикл, под капотом это не простой цикл. Так что компилятор обман. Если вы хотите увидеть себя, вытащите ILDASM или ILSpy или аналогичные инструменты и посмотрите, как выглядит сгенерированный IL. Это должно быть поучительно.

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

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

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

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

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

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

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

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

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

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