Подтвердить что ты не робот

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

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

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

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

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

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

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

4b9b3361

Ответ 1

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

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/

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

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

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

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 вызывает панель методов, мы как-то помним, как вернуться к середине foo, со всеми локалями активации foo intact, независимо от того, что делает бар.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ответ 2

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 -using.

И, конечно же, поскольку

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

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

Ответ 3

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

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

Во-вторых, await переводится в довольно простую последовательность; Мне нравится описание Lucian, которое в словах довольно "если ожидаемое уже завершено, получить результат и продолжить выполнение этого метода, в противном случае сохранить этот метод state и return". (Я использую очень похожую терминологию в своем async intro).

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

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

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

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

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

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

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

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

Столбец не сохраняется в первую очередь; это не обязательно.

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

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

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

A:B:C

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

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

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

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

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

Ответ 4

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

Цель yield - упростить создание ленивых последовательностей. Когда вы пишете цикл enumerator с инструкцией 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 thisThing(), происходит пара вещей. В асинхронном методе компилятор фактически отбрасывает метод на более мелкие куски, каждый фрагмент представляет собой раздел "до ожидания" и раздел "после ожидания" (или продолжения). Когда ожидание выполняется, ожидаемая задача и следующее продолжение - другими словами, остальная часть функции - передается в контекст синхронизации. Контекст заботится о планировании задачи, и когда он закончил контекст, он запускает продолжение, передавая любое возвращаемое значение, которое оно хочет.

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

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

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

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

Ответ 5

Как правило, я бы пересчитал, глядя на CIL, но в случае их это беспорядок.

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

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

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

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

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

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