Насколько я понимаю, yield
ключевое слово, если оно используется внутри блока итератора, возвращает поток управления вызывающему коду, а при повторном вызове итератора он возобновляет работу с того места, где остановился.
Кроме того, он await
не только ожидает вызываемого объекта, но и возвращает управление вызывающему объекту только для того, чтобы продолжить с того места, где он остановился при вызове awaits
метода.
Другими словами, нет потока , а «параллелизм» async и await - это иллюзия, вызванная умным потоком управления, детали которого скрыты синтаксисом.
Я бывший программист на ассемблере, хорошо знаком с указателями инструкций, стеками и т. Д. И понимаю, как работают обычные потоки управления (подпрограммы, рекурсия, циклы, ветки). Но эти новые конструкции - я их не понимаю.
Когда await
достигается, как среда выполнения узнает, какой фрагмент кода следует выполнить дальше? Как он узнает, когда он может возобновить работу с того места, на котором остановился, и как он запоминает, где? Что происходит с текущим стеком вызовов, сохраняется ли он как-то? Что делать, если вызывающий метод выполняет вызовы других методов до того, как это await
... почему стек не перезаписывается? И как, черт возьми, среда выполнения могла бы пройти через все это в случае исключения и раскрутки стека?
Когда yield
достигается, как среда выполнения отслеживает точку, где нужно поднять что-то? Как сохраняется состояние итератора?
источник
Ответы:
Я отвечу на ваши конкретные вопросы ниже, но вам, вероятно, стоит просто прочитать мои обширные статьи о том, как мы разработали 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
создается как:Вот в основном это. Ожидание - это просто фантастическое возвращение.
Ну как это сделать без ожидания? Когда метод foo вызывает метод bar, мы каким-то образом запоминаем, как вернуться в середину foo, со всеми локальными переменными, активировавшими foo, независимо от того, что делает bar.
Вы знаете, как это делается на ассемблере. Запись активации для foo помещается в стек; он содержит ценности местных жителей. В момент вызова адрес возврата из foo помещается в стек. Когда bar готов, указатель стека и указатель инструкции сбрасываются туда, где они должны быть, и foo продолжает движение с того места, где он остановился.
Продолжение ожидания точно такое же, за исключением того, что запись помещается в кучу по той очевидной причине, что последовательность активаций не образует стек .
Делегат, который await дает в качестве продолжения задачи, содержит (1) число, которое является входом в таблицу поиска, которая дает указатель инструкции, которую вам нужно выполнить дальше, и (2) все значения локальных и временных переменных.
Там есть дополнительное снаряжение; например, в .NET запрещено переходить в середину блока try, поэтому вы не можете просто вставить адрес кода внутри блока try в таблицу. Но это бухгалтерские детали. По сути, запись активации просто перемещается в кучу.
Соответствующая информация в текущей записи активации никогда не помещается в стек; он выделяется из кучи с самого начала. (Ну, формальные параметры обычно передаются в стек или в регистры, а затем копируются в место в куче при запуске метода.)
Записи об активации вызывающих абонентов не сохраняются; ожидание, вероятно, вернется к ним, помните, так что с ними справятся нормально.
Обратите внимание, что это существенное различие между упрощенным стилем передачи продолжения в await и настоящими структурами вызова с текущим продолжением, которые вы видите в таких языках, как Scheme. На этих языках все продолжение, включая продолжение до вызывающих абонентов, фиксируется call-cc .
Эти вызовы методов возвращаются, и поэтому их записи активации больше не находятся в стеке на момент ожидания.
В случае неперехваченного исключения исключение перехватывается, сохраняется внутри задачи и повторно генерируется при получении результата задачи.
Помните всю бухгалтерию, о которой я упоминал ранее? Правильная семантика исключений была огромной проблемой, позвольте мне вам сказать.
Так же. Состояние локальных переменных перемещается в кучу, а число, представляющее инструкцию, с которой
MoveNext
следует возобновить выполнение при следующем вызове, сохраняется вместе с локальными переменными.И опять же, в блоке итератора есть множество приспособлений, чтобы убедиться, что исключения обрабатываются правильно.
источник
yield
это более легкий из двух, поэтому давайте рассмотрим его.Скажем, у нас есть:
Это немного компилируется, как если бы мы написали:
Таким образом, не так эффективно, как рукописная реализация
IEnumerable<int>
иIEnumerator<int>
(например, мы, скорее всего, не будем тратить зря, имея отдельный_state
,_i
и_current
в этом случае), но неплохо (уловка повторного использования себя, когда это безопасно, а не создание нового объект хорош) и расширяемый, чтобы иметь дело с очень сложнымиyield
методами.И конечно с тех пор
Такой же как:
Затем сгенерированный
MoveNext()
вызывается повторно.В
async
корпусе почти такой же принцип, но с небольшой дополнительной сложностью. Чтобы повторно использовать пример из другого кода ответа, например:Создает такой код:
Это более сложный, но очень похожий основной принцип. Основная дополнительная сложность в том, что сейчас
GetAwaiter()
используется. Если какое -то времяawaiter.IsCompleted
проверяется она возвращается ,true
так как задачаawait
ред уже завершен (например , случаи , когда он может вернуться синхронно) , то метод продолжает двигаться через состояние, но в остальном она позиционирует себя в качестве обратного вызова к awaiter.Что с этим происходит, зависит от ожидающего, с точки зрения того, что запускает обратный вызов (например, завершение асинхронного ввода-вывода, выполнение задачи в потоке) и какие требования существуют для маршалинга в конкретный поток или выполнения в потоке пула потоков. , какой контекст из исходного вызова может понадобиться, а может и нет, и так далее. Что бы это ни было, хотя что-то в этом awaiter вызовет в,
MoveNext
и он либо продолжит следующую часть работы (до следующейawait
), либо завершит и вернется, и в этом случае то,Task
что он реализует, станет завершенным.источник
yield
к скрученные вручную , когда есть преимущество в этом ( как правило , в качестве оптимизации, но хочет , чтобы убедиться , что исходная точка близка к сгенерированный компилятором поэтому из-за неверных предположений ничего не деоптимизируется). Второй был впервые использован в другом ответе, и в то время в моих собственных знаниях было несколько пробелов, поэтому я извлек для себя пользу, заполнив их, предоставив этот ответ путем ручной декомпиляции кода.Здесь уже есть масса отличных ответов; Я просто собираюсь поделиться несколькими точками зрения, которые помогут сформировать ментальную модель.
Сначала
async
компилятор разбивает метод на несколько частей; тоawait
выражение точка перелома. (Это легко понять для простых методов; более сложные методы с циклами и обработкой исключений также распадаются с добавлением более сложного конечного автомата).Во-вторых,
await
переводится в довольно простую последовательность; Мне нравится описание Люциана , которое на словах в значительной степени звучит примерно так: «если ожидаемое уже выполнено, получить результат и продолжить выполнение этого метода; в противном случае сохраните состояние этого метода и вернитесь». (Я использую очень подобную терминологию в моемasync
интро ).Остальная часть метода существует как обратный вызов для ожидаемого (в случае задач эти обратные вызовы являются продолжениями). Когда ожидаемый завершается, он вызывает свои обратные вызовы.
Обратите внимание , что стек вызовов не сохраняются и восстанавливаются; обратные вызовы вызываются напрямую. В случае перекрывающегося ввода-вывода они вызываются непосредственно из пула потоков.
Эти обратные вызовы могут продолжать выполнение метода напрямую или могут запланировать его запуск в другом месте (например, если
await
захваченный пользовательский интерфейсSynchronizationContext
и ввод-вывод завершены в пуле потоков).Это все просто обратные вызовы. Когда ожидаемый завершается, он вызывает свои обратные вызовы, и любой
async
метод, который уже был изменен,await
возобновляется. Обратный вызов переходит в середину этого метода и имеет в области его локальные переменные.Обратные вызовы являются не запускать определенный поток, и они не имеют их CallStack восстановлены.
Стек вызовов не сохраняется в первую очередь; в этом нет необходимости.
С синхронным кодом вы можете получить стек вызовов, который включает всех ваших вызывающих, и среда выполнения знает, куда вернуться, используя это.
С помощью асинхронного кода вы можете получить кучу указателей обратного вызова, основанных на некоторой операции ввода-вывода, которая завершает свою задачу, которая может возобновить
async
метод, завершающий свою задачу, который может возобновитьasync
метод, завершающий свою задачу, и т. Д.Таким образом, с синхронным код
A
вызывающегоB
вызоваC
, ваш стек вызовов может выглядеть следующим образом :тогда как асинхронный код использует обратные вызовы (указатели):
В настоящее время довольно неэффективно. :)
Он работает так же, как и любая другая лямбда - время жизни переменных увеличивается, а ссылки помещаются в объект состояния, который находится в стеке. Лучшим источником всех подробных сведений является серия EduAsync Джона Скита .
источник
yield
иawait
, хотя оба имеют дело с управлением потоком, две совершенно разные вещи. Поэтому я рассмотрю их отдельно.Цель
yield
состоит в том, чтобы упростить построение ленивых последовательностей. Когда вы пишете цикл перечислителя сyield
выражением в нем, компилятор генерирует массу нового кода, которого вы не видите. Под капотом он фактически генерирует совершенно новый класс. Класс содержит члены, отслеживающие состояние цикла, и реализацию IEnumerable, так что каждый раз, когда вы вызываетеMoveNext
его, проходите через этот цикл еще раз. Итак, когда вы выполняете такой цикл foreach:сгенерированный код выглядит примерно так:
Внутри реализации mything.items () находится набор кода конечного автомата, который выполняет один «шаг» цикла, а затем возвращается. Итак, пока вы пишете это в исходном коде как простой цикл, под капотом это не простой цикл. Итак, хитрость компилятора. Если вы хотите увидеть себя, извлеките ILDASM, ILSpy или аналогичные инструменты и посмотрите, как выглядит сгенерированный IL. Это должно быть поучительно.
async
иawait
, с другой стороны, совсем другой котел с рыбой. В абстрактном смысле Await является примитивом синхронизации. Это способ сказать системе: «Я не могу продолжать, пока это не будет сделано». Но, как вы заметили, не всегда есть нить.Речь идет о том, что называется контекстом синхронизации. Всегда один торчит. Задача их контекста синхронизации - планировать ожидаемые задачи и их продолжения.
Когда вы говорите
await thisThing()
, происходит несколько вещей. В асинхронном методе компилятор фактически разбивает метод на более мелкие фрагменты, каждый из которых является разделом «до ожидания» и разделом «после ожидания» (или продолжением). При выполнении await ожидаемая задача и последующее продолжение - другими словами, остальная часть функции - передается в контекст синхронизации. Контекст заботится о планировании задачи, и когда она завершается, контекст запускает продолжение, передавая любое возвращаемое значение, которое ему нужно.Контекст синхронизации может делать все, что хочет, до тех пор, пока он планирует что-то. Он мог использовать пул потоков. Он может создавать поток для каждой задачи. Он мог запускать их синхронно. Разные среды (ASP.NET и WPF) предоставляют разные реализации контекста синхронизации, которые делают разные вещи в зависимости от того, что лучше всего подходит для их сред.
(Бонус: когда-нибудь задумывались, что
.ConfigurateAwait(false)
делает? Он сообщает системе не использовать текущий контекст синхронизации (обычно в зависимости от типа вашего проекта - например, WPF или ASP.NET) и вместо этого использовать стандартный, который использует пул потоков).Итак, опять же, это большая уловка компилятора. Если вы посмотрите на сгенерированный код, он сложен, но вы сможете увидеть, что он делает. Подобные преобразования сложны, но детерминированы и математичны, поэтому замечательно, что компилятор делает их за нас.
PS Есть одно исключение из существования контекстов синхронизации по умолчанию - у консольных приложений нет контекста синхронизации по умолчанию. Посетите блог Стивена Туба для получения дополнительной информации. Это отличное место для поиска информации
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 . Если задача завершена, она использует свой результат в продолжении. Реализация awaiter решает, как вызвать продолжение. Обычно он использует контекст синхронизации вызывающего потока.Оба
yield
иawait
должны разделить метод на основе их возникновения, чтобы сформировать конечный автомат, где каждая ветвь машины представляет каждую часть метода.Вы не должны думать об этих концепциях в терминах «нижнего уровня», таких как стеки, потоки и т. Д. Это абстракции, и их внутренняя работа не требует какой-либо поддержки со стороны CLR, это просто компилятор, который творит чудеса. Это сильно отличается от сопрограмм Lua, которые имеют поддержку среды выполнения, или longjmp C , который является просто черной магией.
источник
await
не обязательно брать Задачу . Все , что сINotifyCompletion GetAwaiter()
достаточно. Немного похоже на то, какforeach
не нужноIEnumerable
,IEnumerator GetEnumerator()
достаточно всего с .