MoveNext
Так, метод точки входа был вызван, структура машины состояний была инициализирована, был вызван метод Start, который, в свою очередь, вызвал MoveNext. Что такое MoveNext? Это метод, содержащий всю оригинальную логику из метода разработчика, но с множеством изменений. Давайте начнем просто с изучения каркаса метода. Вот декомпилированная версия того, что компилятор генерирует для нашего метода, но с удаленным всем содержимым внутри сгенерированного блока try:
В любой другой работе, выполняемой MoveNext, есть обязанность завершить возвращаемую задачу из асинхронного метода задачи, когда все работы выполнены. Если тело блока try выбрасывает исключение, которое не обрабатывается, то задача будет помечена как сбойная с этим исключением. И если асинхронный метод успешно достигает своего конца (эквивалентно возврату синхронного метода), он успешно завершит возвращаемую задачу. В любом из этих случаев устанавливается состояние машины состояний, указывающее на завершение. (Я иногда слышу, что разработчики теоретизируют о различии между исключениями, выброшенными до первого await и после... на основе вышеуказанного, должно быть ясно, что это не так. Любое исключение, которое не обрабатывается внутри асинхронного метода, независимо от того, где оно находится в методе и независимо от того, выполнил ли метод yield, в конечном итоге попадет в вышеуказанный блок catch, с пойманным исключением, которое затем сохраняется в задаче, возвращаемой асинхронным методом.)
Также обратите внимание, что это завершение проходит через builder, используя его методы SetException и SetResult, которые являются частью шаблона для builder, ожидаемого компилятором. Если асинхронный метод ранее приостанавливался, builderуже должен был создать задачу в рамках обработки этой приостановки (мы увидим, как и где это происходит вскоре), в этом случае вызов SetException/SetResult завершит эту задачу. Однако, если асинхронный метод ранее не приостанавливался, то мы еще не создали задачу или ничего не возвращали вызывающему, поэтому builder имеет больше гибкости в том, как он создает эту задачу. Если вы помните, что в методе входа в систему было сказано ранее, последнее, что он делает, это возвращает задачу вызывающему, делая это, возвращая результат доступа к свойству Task-a builder-a (так много вещей, называемых "задачей", я знаю):
Builder знает, приостанавливался ли метод, в этом случае у него уже есть задача, которую он просто возвращает. Если метод никогда не приостанавливался и у builder-a еще нет задачи, он может создать завершенную задачу здесь. В этом случае, при успешном завершении, он может просто использовать Task.CompletedTask, вместо выделения новой задачи, избегая любого выделения. В случае обобщенной задачи Task<TResult>, builder может просто использовать Task.FromResult<TResult>(TResult result).
Builder также может выполнить любые переводы, которые, по его мнению, подходят для создаваемого объекта. Например, Task действительно имеет три возможных конечных состояния: успех, сбой и отменен. Метод SetException builder AsyncTaskMethodBuilder специально обрабатывает исключение OperationCanceledException, переводя задачу в конечное состояние TaskStatus.Canceled, если предоставленное исключение или является производным от OperationCanceledException; в противном случае задача завершается как TaskStatus.Faulted. Такое различие часто не очевидно в коде, потребляющем; поскольку исключение хранится в задаче независимо от того, помечено ли оно как Отменено или Сбой, код, ожидающий эту задачу, не сможет наблюдать разницу между состояниями (оригинальное исключение будет распространено в любом случае)... это влияет только на код, который напрямую взаимодействует с задачей, например, через ContinueWith, который имеет перегрузки, позволяющие вызывать продолжение только для подмножества состояний завершения.
Теперь, когда мы понимаем аспекты жизненного цикла, вот все, что заполнено внутри блока try в MoveNext:
Эта степень сложности может показаться знакомой. Помните, как наш вручную реализованный BeginCopyStreamToStream, основанный на APM, был запутанным? Это немного менее сложно, но и лучше, поскольку компилятор делает всю работу за нас, переписывая метод в форме передачи продолжения, при этом обеспечивая сохранение всех необходимых состояний для этих продолжений. Тем не менее, мы можем слегка приблизиться и следовать за этим. Помните, что состояние было инициализировано как -1 в точке входа. Затем мы входим в MoveNext, обнаруживая, что это состояние (которое теперь хранится в локальной переменной num) ни равно 0, ни равно 1, и, следовательно, выполняем код, создающий временный буфер, а затем ветвится к метке IL_008b, где он делает вызов stream.ReadAsync. Обратите внимание, что в этот момент мы все еще выполняемся синхронно от этого вызова к MoveNext, и, следовательно, синхронно от Start, и, следовательно, синхронно от точки входа, что означает, что код разработчика вызвал CopyStreamToStreamAsync и он все еще синхронно выполняется, не возвращая еще Task для представления конечного завершения этого метода. Это может быть ожидаемым изменением...
Мы вызываем Stream.ReadAsync и получаем от него Task<int>. Чтение может быть завершено синхронно, оно может быть завершено асинхронно, но так быстро, что оно уже завершено, или оно может еще не быть завершено. В любом случае, у нас есть Task<int>, который представляет его конечное завершение, и компилятор генерирует код, который проверяет этот Task<int>, чтобы определить, как продолжить: если Task<int> действительно уже завершен (не важно, было ли это завершение синхронным или просто в тот момент, когда мы проверили), то код этого метода может просто продолжать выполняться синхронно... нет смысла тратить ненужные затраты на постановку работы в очередь для обработки остальной части выполнения метода, когда мы можем просто продолжать работать здесь и сейчас. Но чтобы обработать случай, когда Task<int> еще не завершен, компилятор должен сгенерировать код для подключения продолжения к Task. Он, следовательно, должен сгенерировать код, который спрашивает у Task: "Вы готовы?" Он общается с Task напрямую, чтобы спросить об этом?
Было бы ограничивающим, если бы единственное, что можно было ожидать в C#, было System.Threading.Tasks.Task. Также было бы ограничивающим, если бы компилятор C# должен был знать о каждом возможном типе, который может быть ожидаемым. Вместо этого C# делает то, что обычно делает в таких случаях: он использует паттерн API. Код может ожидать что угодно, что предоставляет соответствующий паттерн, паттерн "ожидателя" (так же, как вы можете foreach любого, что предоставляет правильный паттерн "перечисления"). Например, мы можем дополнить тип MyTask, который мы написали ранее, чтобы реализовать паттерн ожидателя:
Тип может быть ожидаемым, если он предоставляет метод GetAwaiter(), который Task и предоставляет. Этот метод должен возвращать что-то, что в свою очередь предоставляет несколько членов, включая свойство IsCompleted, которое используется для проверки в момент вызова IsCompleted, завершена ли операция. И вы можете видеть это: в IL_008b, возвращаемая задача от ReadAsync имеет на ней вызванный метод GetAwaiter(), а затем к этому экземпляру ожидателя структуры доступ к IsCompleted. Если IsCompleted возвращает true, то мы в конечном итоге попадем в IL_00f0, где код вызывает другой член ожидателя: GetResult(). Если операция не удалась, GetResult() отвечает за выброс исключения, чтобы распространить его из ожидания в асинхронном методе; в противном случае GetResult() отвечает за возврат результата операции, если он есть. В случае ReadAsync, если этот результат равен 0, то мы выходим из нашего цикла чтения/записи, переходим к концу метода, где он вызывает SetResult, и мы закончили.
Однако, прежде чем что-либо из этого может произойти, нам нужно подключить продолжение к ожидаемой задаче (заметим, что, чтобы избежать погружения в стек, как в случае с APM, если асинхронная операция завершается после того, как IsCompleted возвращает false, но до того, как мы подключим продолжение, продолжение все равно должно быть вызвано асинхронно из вызывающего потока, и, следовательно, оно будет помещено в очередь). Поскольку мы можем ожидать что угодно, мы не можем просто общаться с экземпляром Task напрямую; вместо этого нам нужно пройти через какой-то паттерн-основанный метод для выполнения этого.
Это означает, что на ожидателе есть метод, который будет подключать продолжение? Это было бы разумно; ведь сам Task поддерживает продолжения, имеет метод ContinueWith и т.д. ... не должно ли быть так, что TaskAwaiter, возвращаемый от GetAwaiter, предоставляет метод, который позволяет нам настроить продолжение? Да, это так. Паттерн ожидателя требует, чтобы ожидатель реализовывал интерфейс INotifyCompletion, который содержит единственный метод void OnCompleted(Action continuation). Ожидатель также может опционально реализовывать интерфейс ICriticalNotifyCompletion, который наследует INotifyCompletion и добавляет метод void UnsafeOnCompleted(Action continuation). Как мы обсуждали ранее о ExecutionContext, вы можете предположить, что разница между этими двумя методами заключается в том, что оба подключают продолжение, но в то время как OnCompleted должен передавать ExecutionContext, UnsafeOnCompleted не обязан. Потребность в двух отдельных методах здесь в основном историческая, связанная с Code Access Security, или CAS. CAS больше не существует в .NET Core, и он отключен по умолчанию в .NET Framework, и покажет зубы только если вы возвращаетесь к устаревшей частичной функции доверия. Когда используется частичный доверие, информация CAS передается как часть ExecutionContext, и, следовательно, не передавать его является "небезопасным", отсюда и префикс "Unsafe". Такие методы также были помечены как [SecurityCritical], и частично доверенный код не может вызывать метод [SecurityCritical]. В результате были созданы две вариации OnCompleted, с компилятором предпочитающим использовать UnsafeOnCompleted, если он предоставлен, но с вариантом OnCompleted всегда предоставленным самостоятельно, если ожидатель должен поддерживать частичное доверие. С точки зрения асинхронного метода, однако, конструктор всегда передает ExecutionContext через точки ожидания, поэтому ожидатель, который также это делает, является ненужной и дублирующейся работой.
Окей, так что ожидатель действительно предоставляет метод для подключения продолжения. Компилятор мог бы использовать его напрямую, за исключением очень критической части головоломки: что именно должно быть продолжением? И более того, с каким объектом оно должно быть ассоциировано? Помните, что структура машины состояний находится в стеке, и вызов MoveNext, который мы в данный момент выполняем, является вызовом метода на этом экземпляре. Нам нужно сохранить машину состояний, чтобы при возобновлении у нас были все правильные состояния, что означает, что машина состояний не может просто продолжать жить в стеке; ей нужно быть скопированной в какое-то место на куче, поскольку стек в конечном итоге будет использоваться для другой последующей, несвязанной работы, выполняемой этим потоком. И затем продолжение должно вызывать метод MoveNext на этой копии машины состояний на куче.
Более того, ExecutionContext также имеет значение здесь. Машине состояний нужно гарантировать, что любые окружающие данные, хранящиеся в ExecutionContext, захватываются в момент приостановки и затем применяются в момент возобновления, что означает, что продолжение также должно включать этот ExecutionContext. Просто создание делегата, который указывает на MoveNext на машине состояний, недостаточно. Это также нежелательная нагрузка. Если при приостановке мы создаем делегат, который указывает на MoveNext на машине состояний, каждый раз, когда мы это делаем, мы будем упаковывать структуру машины состояний (даже когда она уже находится на куче как часть другого объекта) и выделять дополнительный делегат (ссылка на объект this делегата будет на вновь упакованную копию структуры). Мы, таким образом, нуждаемся в сложном танце, в котором мы гарантируем, что мы продвигаем структуру только с стека на кучу в первый раз, когда метод приостанавливает выполнение, но во все последующие раза использует тот же объект на куче в качестве цели для MoveNext, и в процессе гарантирует, что мы захватили правильный контекст, и при возобновлении гарантирует, что мы используем этот захваченный контекст для вызова операции.
Это гораздо больше логики, чем мы хотим, чтобы компилятор генерировал... мы хотим, чтобы она была инкапсулирована в помощнике, по нескольким причинам. Во-первых, это много сложной кодовой базы, который должен быть встроен в каждую сборку пользователя. Во-вторых, мы хотим позволить настройку этой логики в рамках реализации паттерна конструктора (мы увидим пример того, почему позже, когда будем говорить о пулинге). И в-третьих, мы хотим иметь возможность развивать и улучшать эту логику и иметь возможность, чтобы существующие ранее скомпилированные бинарные файлы просто становились лучше. Это не гипотетическое; код библиотеки для этой поддержки был полностью переработан в .NET Core 2.1, таким образом, что операция стала гораздо более эффективной, чем она была в .NET Framework. Мы начнем с исследования того, как это работало в .NET Framework, а затем посмотрим, что происходит сейчас в .NET Core.
Вы можете видеть, что происходит в коде, сгенерированном компилятором C#, когда нам нужно приостановить:
Мы сохраняем в поле состояния идентификатор состояния, который указывает на место, куда мы должны перейти, когда метод возобновится. Затем мы сохраняем сам ожидатель в поле, чтобы он мог быть использован для вызова GetResult после возобновления. И затем, едва перед тем как вернуться из вызова MoveNext, последнее, что мы делаем, это вызываем <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this), просим builder подключить продолжение к ожидателю для этой машины состояний. (Обратите внимание, что он вызывает AwaitUnsafeOnCompleted builder-a, а не AwaitOnCompleted builder-а, потому что ожидатель реализует ICriticalNotifyCompletion; машина состояний обрабатывает передачу ExecutionContext, поэтому нам не нужно требовать от ожидателя этого делать... как упоминалось ранее, это было бы лишним и ненужной нагрузкой.)
Реализация этого метода AwaitUnsafeOnCompleted слишком сложна, чтобы скопировать здесь, поэтому я кратко опишу, что он делает в .NET Framework:
- Он использует ExecutionContext.Capture() для захвата текущего контекста.
- Затем выделяет объект MoveNextRunner для обертывания как захваченного контекста, так и упакованной машины состояний (которую мы еще не имеем, если это первый раз, когда метод приостанавливается, поэтому мы просто используем null в качестве заполнителя).
- Затем создает делегат Action к методу Run на этом MoveNextRunner; именно так он может получить делегат, который вызовет MoveNext машины состояний в контексте захваченного ExecutionContext.
- Если это первый раз, когда метод приостанавливается, у нас еще не будет упакованной машины состояний, поэтому в этот момент мы упаковываем ее, создавая копию на куче, сохраняя экземпляр в локальной переменной, типизированной как интерфейс IAsyncStateMachine. Эта упаковка затем сохраняется в MoveNextRunner, который был выделен.
- Теперь приходит довольно запутанный шаг. Если вы посмотрите на определение структуры машины состояний, оно содержит builder, public AsyncTaskMethodBuilder <>t__builder;, и если вы посмотрите на определение builder-a, оно содержит внутренний IAsyncStateMachine m_stateMachine;. Builder должен ссылаться на упакованную машину состояний, чтобы при последующих приостановках он мог видеть, что машина состояний уже упакована, и не нуждается в ее повторной упаковке. Но мы только что упаковали машину состояний, и эта машина состояний содержала builder, поле m_stateMachine которого было null. Нам нужно изменить это поле упакованной машины состояний builder-a, чтобы указать на его родительскую упаковку. Чтобы достичь этого, интерфейс IAsyncStateMachine, который реализует сгенерированная компилятором структура машины состояний, включает метод void SetStateMachine(IAsyncStateMachine stateMachine);, и эта структура машины состояний включает реализацию этого метода интерфейса:
Таким образом, конструктор упаковывает машину состояний, а затем
передает эту упаковку методу SetStateMachine упаковки, который вызывает
метод SetStateMachine builder-a, который сохраняет упаковку в поле. Ура.
6. Наконец, у нас есть Action, который представляет продолжение, и это
передается в метод UnsafeOnCompleted ожидателя. В случае с TaskAwaiter
задача сохранит этот Action в списке продолжений задачи, так что когда
задача завершится, она вызовет Action, вызовет обратно через
MoveNextRunner.Run, вызовет обратно через ExecutionContext.Run, и, наконец,
вызовет метод MoveNext машины состояний, чтобы снова войти в машину
состояний и продолжить выполнение с того места, где она остановилась.
Это происходит в .NET Framework, и вы можете увидеть результат этого в профилировщике, например, запустив профилировщик выделения, чтобы увидеть, что выделяется на каждом ожидании. Давайте рассмотрим этот глупый программу, которую я написал просто для выделения затрат на выделение, связанных с ожиданием:
Эта программа создает AsyncLocal<int> для передачи значения 42 через все последующие асинхронные операции. Затем она вызывает SomeMethodAsync 1000 раз, каждый из которых приостанавливается/возобновляется 1000 раз. В Visual Studio я запускаю это с помощью профилировщика отслеживания выделения объектов .NET, который дает следующие результаты:
Это... много выделений! Давайте рассмотрим каждое из этих выделений, чтобы понять, откуда они исходят.
ExecutionContext. Здесь выделяется более миллиона экземпляров. Почему? Потому что в .NET Framework ExecutionContext является изменяемой структурой данных. Поскольку мы хотим передать данные, которые были присутствуют в момент, когда асинхронная операция была разветвлена, и мы не хотим, чтобы они видели изменения, выполненные после этого разветвления, нам нужно скопировать ExecutionContext. Каждая ответвленная операция требует такого копирования, поэтому с 1000 вызовами SomeMethodAsync, каждый из которых приостанавливается/возобновляется 1000 раз, у нас есть миллион экземпляров ExecutionContext. Ой.
Action. Аналогично, каждый раз, когда мы ожидаем что-то, что еще не завершено (что является нашим случаем с миллионом ожиданий Task.Yield()), мы заканчиваемся выделением нового делегата Action, чтобы передать его методу UnsafeOnCompleted ожидателя.
MoveNextRunner. То же самое; здесь выделяется миллион экземпляров, поскольку, как было объяснено ранее, каждый раз, когда мы приостанавливаемся, мы выделяем новый MoveNextRunner для хранения Action и ExecutionContext, чтобы выполнить первый с последним.
LogicalCallContext. Еще миллион. Это детали реализации AsyncLocal<T> в .NET Framework; AsyncLocal<T> хранит свои данные в "логическом контексте вызова" ExecutionContext, что является очень сложным способом сказать, что это общее состояние, передаваемое с ExecutionContext. Так что, если мы делаем миллион копий ExecutionContext, мы делаем миллион копий LogicalCallContext тоже.
QueueUserWorkItemCallback. Каждый Task.Yield() ставит в очередь элемент работы в пул потоков, что приводит к миллиону выделений объектов элементов работы, используемых для представления этих миллион операций.
Task<VoidResult>. Здесь тысяча, так что, по крайней мере, мы вышли из "клуба миллиона". Каждое асинхронное вызовов Task, которое завершается асинхронно, нужно выделить новый экземпляр Task для представления конечного завершения этого вызова.
<SomeMethodAsync>d__1. Это упаковка структуры машины состояний, сгенерированной компилятором. 1000 методов приостанавливаются, 1000 упаковок происходят.
QueueSegment/IThreadPoolWorkItem[]. Здесь несколько тысяч, и они технически не связаны с асинхронными методами, а скорее с работой, поставленной в очередь в пул потоков в целом. В .NET Framework очередь пула потоков - это связанный список сегментов. Эти сегменты не повторно используются; для сегмента длины N, как только N элементов работы были помещены в очередь и из нее извлечены, сегмент отбрасывается и оставляется на сборку мусора.
Это было .NET Framework. Это .NET Core:
Так многое красивее! Для этого образца на .NET Framework было более 5 миллионов выделений, общая сумма которых составляла примерно 145 МБ выделенной памяти. Для того же образца на .NET Core было только примерно 1000 выделений, общая сумма которых составляла всего около 109 КБ. Почему так много меньше?
ExecutionContext. В .NET Core ExecutionContext теперь является неизменяемым. Недостатком этого является то, что каждое изменение контекста, например, установка значения в AsyncLocal<T>, требует выделения нового ExecutionContext. Преимуществом, однако, является то, что передача контекста гораздо более распространена, чем его изменение, и поскольку ExecutionContext теперь неизменяем, нам больше не нужно клонировать его как часть передачи. "Захват" контекста просто означает чтение его из поля, а не чтение и клонирование его содержимого. Так что не только передача контекста гораздо более распространена, чем его изменение, но и она гораздо дешевле.
LogicalCallContext. Это больше не существует в .NET Core. В .NET Core единственная цель ExecutionContext - это хранение для AsyncLocal<T>. Другие вещи, которые имели свое особое место в ExecutionContext, моделируются в терминах AsyncLocal<T>. Например, олицетворение в .NET Framework передавалось как часть SecurityContext, который является частью ExecutionContext; в .NET Core олицетворение передается через AsyncLocal<SafeAccessTokenHandle>, который использует valueChangedHandler для внесения соответствующих изменений в текущий поток.
QueueSegment/IThreadPoolWorkItem[]. В .NET Core глобальная очередь ThreadPool теперь реализована как ConcurrentQueue<T>, и ConcurrentQueue<T> была переписана как связанный список сегментов нефиксированного размера. Как только размер сегмента достаточно велик, чтобы сегмент никогда не заполнялся, поскольку постоянные выделения и извлечения соответствуют друг другу, дополнительные сегменты не требуются, и тот же большой сегмент используется бесконечно.
Что касается остальных выделений, таких как Action, MoveNextRunner и <SomeMethodAsync>d__1? Понимание того, как остальные выделения были устранены, требует погружения в то, как это теперь работает на .NET Core.
Давайте откатим наше обсуждение назад к тому моменту, когда мы обсуждали, что происходит в момент приостановки:
Код, который здесь генерируется, одинаков для любой целевой платформы, будь то .NET Framework или .NET Core, поэтому независимо от того, какую платформу мы целевым образом, сгенерированный IL для этой приостановки одинаков. Однако то, что меняется, это реализация этого метода AwaitUnsafeOnCompleted, которая в .NET Core значительно отличается:
В начале все начинается так же: метод вызывает ExecutionContext.Capture() для получения текущего контекста выполнения.
Затем вещи начинают расходиться от .NET Framework. Конструктор в .NET Core имеет всего одно поле:
После захвата ExecutionContext, он проверяет, содержит ли поле m_task экземпляр AsyncStateMachineBox<TStateMachine>, где TStateMachine - это тип структуры машины состояний, сгенерированной компилятором. Тип AsyncStateMachineBox<TStateMachine> является "волшебством". Он определен следующим образом:
Вместо того чтобы иметь отдельную задачу, это сама задача (заметьте ее базовый тип). Вместо упаковки машины состояний, структура просто живет как строго типизированное поле этой задачи. И вместо того чтобы иметь отдельный MoveNextRunner для хранения Action и ExecutionContext, они просто являются полями этого типа, и поскольку это экземпляр, который хранится в поле m_task builder-a, у нас есть прямой доступ к нему и не нужно перевыделять вещи при каждой приостановке. Если ExecutionContext изменяется, мы можем просто перезаписать поле новым контекстом и не нужно выделять ничего еще; любое Action все еще указывает на правильное место. Таким образом, после захвата ExecutionContext, если у нас уже есть экземпляр AsyncStateMachineBox<TStateMachine>, это не первый раз, когда метод приостанавливается, и мы можем просто сохранить в него только что захваченный ExecutionContext. Если у нас еще нет экземпляра AsyncStateMachineBox<TStateMachine>, тогда нам нужно выделить его:
Обратите внимание на эту строку, которую исходный код комментирует как "важно". Это заменяет тот сложный танец с SetStateMachine в .NET Framework, так что SetStateMachine вообще не используется в .NET Core. TaskField, который вы видите там, является ссылкой на поле m_task AsyncTaskMethodBuilder. Мы выделяем AsyncStateMachineBox<TStateMachine>, затем через taskField сохраняем этот объект в поле m_task builder-a(это builder, который находится в структуре машины состояний на стеке), и затем копируем эту машину состояний на стеке (которая теперь уже содержит ссылку на упаковку) в упаковку AsyncStateMachineBox<TStateMachine> на куче, так что AsyncStateMachineBox<TStateMachine> соответственно и рекурсивно ссылается на себя. Это все еще довольно запутанно, но это гораздо более эффективное запутанство.
Затем мы можем получить Action к методу на этом экземпляре, который вызовет его MoveNext, который сделает соответствующее восстановление ExecutionContext перед вызовом в StateMachine‘s MoveNext. И этот Action может быть сохранен в поле _moveNextAction таким образом, чтобы любое последующее использование могло просто повторно использовать тот же Action. Этот Action затем передается в awaiter‘s UnsafeOnCompleted для подключения продолжения.
Это объясняет, почему большинство остальных выделений исчезли: <SomeMethodAsync>d__1 не упаковывается и вместо этого просто живет как поле на самой задаче, а MoveNextRunner больше не нужен, поскольку он существовал только для хранения Action и ExecutionContext. Но, как я отметил, одна из приятных вещей о том, что детали реализации откладываются в основную библиотеку, заключается в том, что она может эволюционировать со временем, и мы уже видели, как она эволюционировала от .NET Framework к .NET Core. Она также эволюционировала дальше от первоначального переписывания для .NET Core, с дополнительными оптимизациями, которые получают преимущества от внутреннего доступа к ключевым компонентам системы. В частности, асинхронная инфраструктура знает о основных типах, таких как Task и TaskAwaiter. И поскольку она знает об этом и имеет внутренний доступ, ей не нужно следовать публично определенным правилам. Паттерн ожидания, за которым следует язык C#, требует, чтобы у ожидателя был метод AwaitOnCompleted или AwaitUnsafeOnCompleted, оба из которых принимают продолжение в виде Action, и это означает, что инфраструктуре нужно быть в состоянии создать Action для представления продолжения, чтобы работать с произвольными ожидателями, о которых инфраструктура ничего не знает. Но если инфраструктура сталкивается с ожидателем, о котором она знает, она не обязана следовать тому же кодовому пути. Для всех основных ожидателей, определенных в System.Private.CoreLib, инфраструктура имеет более стройный путь, который она может следовать, путь, который не требует Action вовсе. Эти ожидатели все знают о IAsyncStateMachineBoxes и могут рассматривать объект упаковки сам по себе как продолжение. Так, например, YieldAwaitable, возвращаемый Task.Yield, может напрямую поместить IAsyncStateMachineBox в ThreadPool в качестве элемента работы, и TaskAwaiter, используемый при ожидании Task, может напрямую сохранить IAsyncStateMachineBox в списке продолжений Task. Нет необходимости в Action, нет необходимости в QueueUserWorkItemCallback.
Таким образом, в очень распространенном случае, когда асинхронный метод ожидает только вещи из System.Private.CoreLib (Task, Task<TResult>, ValueTask, ValueTask<TResult>, YieldAwaitable и варианты ConfigureAwait этих), худший случай - это только одно выделение накладных расходов, связанных с всем жизненным циклом асинхронного метода: если метод когда-либо приостанавливается, он выделяет этот один тип, производный от Task, который хранит все остальные необходимые состояния, и если метод никогда не приостанавливается, не взимается дополнительное выделение.
Мы можем избавиться от этого последнего выделения также, если хотим, хотя бы в усредненном смысле. Как было показано, есть конструктор по умолчанию, связанный с Task (AsyncTaskMethodBuilder), и аналогично есть конструктор по умолчанию, связанный с Task<TResult> (AsyncTaskMethodBuilder<TResult>) и с ValueTask и ValueTask<TResult> (AsyncValueTaskMethodBuilder и AsyncValueTaskMethodBuilder<TResult> соответственно). Для ValueTask/ValueTask<TResult> конструкторы на самом деле довольно просты, поскольку они сами обрабатывают случай синхронного и успешного завершения, в котором случае асинхронный метод завершается без приостановки, и конструкторы могут просто вернуть ValueTask.Completed или ValueTask<TResult>, обернув значение результата. Для всего остального они просто делегируют AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>, поскольку возвращаемый ValueTask/ValueTask<TResult> просто оборачивает Task и может использовать всю ту же логику. Но .NET 6 и C# 10 ввели возможность для метода переопределить конструктор, используемый на основе каждого метода, и ввели несколько специальных конструкторов для ValueTask/ValueTask<TResult>, которые могут пулировать объекты IValueTaskSource/IValueTaskSource<TResult>, представляющие конечное завершение, вместо использования Tasks.
Мы можем увидеть влияние этого на нашем образце. Давайте немного изменим наш SomeMethodAsync, который мы профилировали, чтобы он возвращал ValueTask вместо Task:
В результате будет создана эта точка входа:
Теперь добавим [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] к объявлению SomeMethodAsync:
и вместо этого компилятор выводит это:
Фактический код C#, генерируемый для всей реализации, включая весь конечный автомат (не показан), практически идентичен; единственная разница — это тип построителя, который создается, сохраняется и, таким образом, используется везде, где мы ранее видели ссылки на построитель . И если вы посмотрите на кодPoolingAsyncValueTaskMethodBuilder , вы увидите, что его структура почти идентична структуре AsyncTaskMethodBuilder, включая использование некоторых из тех же общих процедур для выполнения таких вещей, как обработка известных типов ожидающих. Ключевое отличие состоит в том, что вместо того, чтобы делать это new AsyncStateMachineBox<TStateMachine>() при первом приостановке метода, он вместо этого выполняет StateMachineBox<TStateMachine>.RentFromCache(), а после завершения асинхронного метода ( SomeMethodAsync) и await при возвращенном ValueTaskзавершении арендованный блок возвращается в кеш. Это означает (амортизированное) нулевое распределение:
Этот тайник сам по себе немного интересен. Объединение объектов в пул может быть хорошей и плохой идеей. Чем дороже создание объекта, тем ценнее его объединение; так, например, гораздо более ценно объединять действительно большие массивы, чем объединять очень маленькие массивы, потому что более крупные массивы не только требуют большего количества циклов ЦП и доступа к памяти для обнуления, но и оказывают большее давление на сборщик мусора при сборе чаще. Однако для очень маленьких объектов объединение их в пул может оказаться отрицательным. Пулы — это просто распределители памяти, как и сборщик мусора, поэтому при объединении вы обмениваете затраты, связанные с одним распределителем, на затраты, связанные с другим, а сборщик мусора очень эффективен при обработке множества крошечных, недолговечных объектов. . Если вы проделываете много работы в конструкторе объекта, отказ от этой работы может свести на нет затраты на сам распределитель, что сделает объединение в пул ценным. Но если вы практически ничего не делаете в конструкторе объекта и объединяете его, вы делаете ставку на то, что ваш распределитель (ваш пул) более эффективен для используемых шаблонов доступа, чем GC, и это часто является плохой ставкой. Существуют и другие затраты, и в некоторых случаях вам придется эффективно бороться с эвристикой GC; например, GC оптимизирован на основе предположения, что ссылки от объектов более высокого поколения (например, gen2) к объектам более низкого поколения (например, gen0) относительно редки, но объединение объектов в пул может сделать эти предпосылки недействительными.
Теперь объекты, созданные асинхронными методами, не являются крошечными и могут находиться на очень горячих путях, поэтому объединение в пул может быть разумным. Но чтобы сделать его максимально ценным, мы также хотим избежать как можно большего количества накладных расходов. Таким образом, пул устроен очень просто: аренда и возврат выполняются очень быстро практически без конфликтов, даже если это означает, что в конечном итоге он может выделить больше, чем если бы он более агрессивно кэшировал больше. Для каждого типа конечного автомата реализация объединяет до одного конечного автомата на поток и одного конечного автомата на ядро ; это позволяет ему арендовать и возвращать данные с минимальными накладными расходами и минимальной конкуренцией (ни один другой поток не может одновременно обращаться к кешу, специфичному для потока, и редко, когда другой поток одновременно обращается к кешу, специфичному для ядра). И хотя этот пул может показаться относительно небольшим, он также весьма эффективен для значительного сокращения распределения в устойчивом состоянии, учитывая, что пул отвечает только за хранение объектов, которые в данный момент не используются; у вас может быть миллион асинхронных методов, находящихся в работе в любой момент времени, и хотя пул может хранить только один объект на поток и на ядро, он все равно может избежать удаления большого количества объектов, поскольку ему нужно только хранить объект, достаточно длинный, чтобы передать его из одной операции в другую, а не пока он используется этой операцией.
to be continued...