Яким чином вихід та очікування впровадження потоку управління в .NET?


105

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

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

Іншими словами - немає жодної нитки , а "паралельність" асинхронізації та очікування - це ілюзія, спричинена розумним потоком контролю, деталі якого приховані синтаксисом.

Зараз я колишній програміст збірки і дуже добре знайомий з покажчиками інструкцій, стеками тощо, і я розумію, як працюють нормальні потоки управління (підпрограма, рекурсія, петлі, гілки). Але ці нові конструкції - я їх не розумію.

Коли awaitбуде досягнуто значення, як виконувати час виконання, який фрагмент коду повинен виконуватися далі? Звідки він знає, коли він може відновитись, де зупинився, і як він пам’ятає, де? Що відбувається з поточним стеком викликів, чи його якимось чином зберігають? Що робити, якщо метод виклику робить інші виклики методу, перш ніж awaitвін-- чому стек не перезаписується? І як, на землі, час виконання буде проходити все це у випадку винятку та стеку?

Коли yieldбуде досягнуто, як час виконання відстежує точку, куди потрібно підібрати речі? Як зберігається стан ітератора?


4
Ви можете подивитися на згенерований код в TryRoslyn онлайн компілятором
Ксанатос

1
Ви можете перевірити серію статей Eduasync від Джона Скіта.
Леонід Василев

Пов’язане цікаве прочитання: stackoverflow.com/questions/37419572/…
Jason C

Відповіді:


115

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

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 викликає рядок методу, ми якось пам'ятаємо, як повернутися до середини кадра, з усіма місцевими жителями активації foo неушкодженими, незалежно від того, що робить бар.

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

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

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

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

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

Відповідна інформація в поточному записі активації ніколи не ставиться в стек в першу чергу; він виділяється поза грою з початку руху. (Ну, формальні параметри передаються в стек або в регістри нормально, а потім копіюються в кучу, коли метод починається.)

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

Зауважте, що це германна різниця між спрощеним стилем продовження проходження стилю очікування та справжніми структурами виклику з поточним продовженням, які ви бачите на таких мовах, як схема. У цих мовах все продовження, включаючи продовження назад у абонентів, фіксується call-cc .

Що робити, якщо метод виклику робить інші виклики методу, перш ніж він чекає-- чому стек не перезаписується?

Ці виклики методу повертаються, і тому записи їх активації більше не стоять на стеку в момент очікування.

І як, на землі, час виконання буде проходити все це у випадку винятку та стеку?

У випадку невдалого винятку виняток перехоплюється, зберігається всередині завдання та повторно викидається при отриманні результату завдання.

Пам'ятаєте все те бухгалтерію, про яке я згадував раніше? Правильне визначення семантики виключень було величезним болем, дозвольте сказати.

Коли дохідність буде досягнута, як час виконання слідкує за точкою, де потрібно підібрати речі? Як зберігається стан ітератора?

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

І знову: в ітераторі є купа передач, щоб переконатися, що винятки керовані правильно.


1
зважаючи на передумови авторів запитань (асамблер та ін.), можливо, варто згадати, що обидва ці конструкції неможливі без керованої пам’яті. без керованої пам’яті спроби координувати термін служби закриття, безумовно, ви змусите вас запускати завантажувачі.
Джим

Всі посилання на сторінки не знайдено (404)
Digital3D

Усі ваші статті зараз недоступні. Не могли б ви їх репоставити?
Michał Turczyn

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редакції вже виконано (наприклад, випадки, коли він може повертатися синхронно), тоді метод продовжує рухатися через стани, але в іншому випадку він встановлює себе як зворотний виклик для офіціанта.

Що саме з цим відбувається, залежить від офіціанта, з точки зору того, що запускає зворотний виклик (наприклад, завершення вводу / виводу async, завдання, що працює над завершенням потоку) та які вимоги є для маршалінгу певного потоку або запуску на потоці нитки пулу , який контекст у вихідному дзвінку може бути, а може і не потрібен тощо. Як би там не було, хоча щось із того, хто чекає, він зателефонує, 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 або подібні інструменти і подивіться, як виглядає створений ІР. Це повинно бути повчальним.

asyncа awaitз іншого боку - це цілий інший чайник риби. Очікування - це абстрактне примітивне синхронізація. Це спосіб сказати системі "я не можу продовжувати, поки це не буде зроблено". Але, як ви зазначали, не завжди є нитка.

Що це участь є те , що називається контекст синхронізації. Завжди там один висить. Завдання синхронізаційного контексту полягає в плануванні завдань, які чекають на них, та їх продовження.

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

Контекст синхронізації може робити все, що завгодно, доки він планує вміст. Він може використовувати пул потоків. Це може створити потік для кожного завдання. Це може запустити їх синхронно. Різні середовища (ASP.NET проти WPF) забезпечують різні реалізації контексту синхронізації, які роблять різні дії на основі того, що найкраще для їх середовища.

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

Отже, знову-таки, це багато хитрощів компілятора. Якщо ви подивитеся на згенерований код його складний, але ви повинні мати можливість бачити, що він робить. Ці види перетворень важкі, але детерміновані та математичні, тому прекрасно, що компілятор робить їх для нас.

PS Є один виняток із існування контекстів синхронізації за замовчуванням - консольні програми не мають контексту синхронізації за замовчуванням. Перегляньте блог Стівена Туба, щоб отримати більше інформації. Це прекрасне місце, щоб шукати інформацію про asyncі awaitвзагалі.


1
"Це говорить системі не використовувати контекст синхронізації за замовчуванням, а замість цього використовувати типовий, який використовує пул потоків", чи можете ви зрозуміти, що ви маєте на увазі під цим? "не використовувати за замовчуванням, використовувати за замовчуванням"
Kroltan,

3
Вибачте, змішавши свою термінологію, я виправлю посаду. В основному не використовуйте типовий для середовища, в якому ви перебуваєте, використовуйте типовий для .NET (тобто пул потоків).
Кріс Таварес

дуже просто, зміг зрозуміти, ти отримав мій голос :)
Ehsan Sajjad

4

Як правило, я б рекомендував дивитись на CIL, але у випадку з цим - це безлад.

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

yieldце старше і простіше твердження, і це синтаксичний цукор для базової машини машини. Метод, що повертає IEnumerable<T>або IEnumerator<T>може містити a 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, у яких є підтримка часу виконання, або longjmp C , що є просто чорною магією.


5
Бічна примітка : awaitне потрібно приймати завдання . Все, з INotifyCompletion GetAwaiter()чим достатньо. Трохи схожий на те, як foreachне потрібно IEnumerable, нічого з IEnumerator GetEnumerator()цього достатньо.
IllidanS4 хоче, щоб Моніка повернулася
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.