Найти в Дзене
using Dev

Как на самом деле работает Async/Await в C# Ч.1

Доброго времени суток. Не так давно наткнулся на статью на сайте https://devblogs.microsoft.com/ в которой подробно рассказывается об истории, стоящей за проектными решениями, и деталях реализации async/ await в C# и .NET. Статья вышла крайне интересной и полезной, поэтому захотелось поделиться её переводом с вами. Сама статья крайне объемная, и для удобства была разделена на части.

Поддержка async/await существует уже более десяти лет. За это время способ написания масштабируемого кода для .NET изменился, и стало возможным и чрезвычайным распространенным использование этой технологии, не понимая, что именно происходит внутри. Вы начинаете с синхронного метода, как на примере (этот метод является «синхронным», потому что вызывающая сторона не сможет делать что-либо еще, пока вся эта операция не завершится и управление не будет возвращено вызывающей стороне):

-2

Затем вы добавляете несколько ключевых слов, меняете несколько имен методов и в итоге получаете следующий асинхронный метод (этот метод “асинхронный”, потому что ожидается, что управление будет возвращено вызывающему его объекту очень быстро и, возможно, до завершения работы, связанной со всей операцией):

-3

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

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

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

В начале...

Еще в .NET Framework 1.0 существовал шаблон модели асинхронного программирования, также известный как шаблон APM, также известный как шаблон начала/окончания, также известный как шаблон IAsyncResult. На высоком уровне схема проста. Для синхронной операции DoStuff:

-4

будет два соответствующих метода как часть шаблона: BeginDoStuff метод и EndDoStuff метод:

-5

BeginDoStuff будет принимать все те же параметры, что и DoStuff, однако, кроме того, он также будет принимать AsyncCallback делегат и непрозрачное состояние object, один или оба из которых могут быть null. Метод Begin отвечал за инициирование асинхронной операции, и если он был снабжен обратным вызовом (часто называемым «продолжением» для начальной операции), он также отвечал за обеспечение вызова обратного вызова после завершения асинхронной операции. Метод Begin также создает экземпляр типа, который реализует IAsyncResult, используя необязательное свойство state для заполнения этого IAsyncResultсвойства AsyncState:

-6

Затем экземпляр IAsyncResult будет возвращен из метода Begin, а также передан в метод, AsyncCallback когда он в конечном итоге будет вызван. Когда он будет готов использовать результаты операции, вызывающий объект затем передаст этот экземпляр IAsyncResult методу End, который отвечает за обеспечение завершения операции (синхронно ожидая ее завершения путем блокировки), а затем возвращая любой результат операции, включая распространение любых ошибок/исключений, которые могли произойти. Таким образом, вместо того, чтобы писать код, подобный следующему, для синхронного выполнения операции:

-7

методы Begin/End можно использовать следующим образом для асинхронного выполнения одной и той же операции:

-8

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

Однако дальше все стало только сложнее. Например, существует проблема «погружения в стек». Погружение в стек — это когда код многократно выполняет вызовы, которые погружаются все глубже и глубже в стек, до такой степени, что это потенциально может привести к переполнению стека. Методу Begin разрешено вызывать обратный вызов синхронно, если операция завершается синхронно, что означает, что вызов Begin может сам напрямую вызывать обратный вызов. И «асинхронные» операции, которые выполняются синхронно, на самом деле очень распространены; они не являются «асинхронными», потому что они гарантированно завершатся асинхронно, а скорее просто разрешены. Например, рассмотрим асинхронное чтение из какой-либо сетевой операции, например получение из сокета. Если вам нужен лишь небольшой объем данных для каждой отдельной операции, например чтение некоторых данных заголовка из ответа, вы можете установить буфер, чтобы избежать накладных расходов на множество системных вызовов. Вместо того, чтобы выполнять небольшое чтение только для того объема данных, который вам нужен немедленно, вы выполняете более крупное чтение в буфер, а затем потребляете данные из этого буфера до тех пор, пока он не будет исчерпан; это позволяет сократить количество дорогостоящих системных вызовов, необходимых для фактического взаимодействия с сокетом. Такой буфер может существовать за любой асинхронной абстракцией, которую вы используете, так что первая «асинхронная» операция, которую вы выполняете (заполнение буфера), завершается асинхронно, но тогда все последующие операции, пока этот базовый буфер не будет исчерпан, на самом деле не нужно делать. любой ввод-вывод, вместо этого просто извлечение из буфера, и, таким образом, все может выполняться синхронно. Когда метод Begin выполняет одну из этих операций и обнаруживает, что она завершается синхронно, затем он может синхронно вызвать обратный вызов. Это означает, что у вас есть один кадр стека, вызывающий метод Begin, другой кадр стека для самого метода Begin, а теперь еще один кадр стека для обратного вызова. Что произойдет, если этот обратный вызов вернется и снова вызовет Begin? Если эта операция завершается синхронно и ее обратный вызов вызывается синхронно, вы снова находитесь в стеке на несколько кадров глубже. И так далее, и так далее, пока не закончится стек.

Это реальная возможность, которую легко воспроизвести. Попробуйте эту программу на .NET Core:

-9

Здесь я настроил простой клиентский сокет и серверный сокет, соединенные друг с другом. Сервер отправляет 100 000 байт клиенту, который затем приступает к их использованию BeginRead/EndRead потреблению «асинхронно» по одному (это ужасно неэффективно и делается только для примера). Обратный вызов, переданный для BeginRead после завершения чтения, вызывает EndRead, а затем, если он успешно прочитал нужный байт (в этом случае он еще не был в конце потока), он выдает другой BeginRead через рекурсивный вызов ReadAgain. Однако в .NET Core операции с сокетами выполняются намного быстрее, чем в .NET Framework, и будут выполняться синхронно, если ОС способна выполнить операцию синхронно (учитывая, что само ядро ​​имеет буфер, используемый для выполнения операций получения сокета). Таким образом, этот стек переполняется:

-10

Есть два возможных способа компенсировать это:

  1. Не допускайте синхронного вызова AsyncCallback. Если он всегда вызывается асинхронно, даже если операция завершается синхронно, то риск выпадения стека исчезает. Но то же самое касается и производительности, потому что операции, которые выполняются синхронно (или так быстро, что их практически невозможно отличить), очень распространены, и заставлять каждую из них ставить обратный вызов в очередь увеличивает измеримые накладные расходы.
  2. Используйте механизм, который позволяет вызывающему, а не обратному вызову выполнять работу продолжения, если операция завершается синхронно. Таким образом, вы избегаете дополнительного фрейма метода и продолжаете выполнять последующую работу, не углубляясь в стек.

Шаблон APM поставляется с опцией (2). Для этого IAsyncResult интерфейс предоставляет два связанных, но разных элемента: IsCompleted и CompletedSynchronously. IsCompleted сообщает вам, завершена ли операция: вы можете проверить это несколько раз, и в конечном итоге она перейдет из false в true, а затем останется там. В отличие от этого, CompletedSynchronously никогда не меняется (а если и меняется, то это неприятная ошибка); он используется для связи между вызывающим методом Begin и AsyncCallback тем, кто из них отвечает за выполнение любой работы по продолжению. Если CompletedSynchronously - false, то операция завершается асинхронно, и любая работа по продолжению в ответ на завершение операции должна быть оставлена на усмотрение обратного вызова; в конце концов, если работа не завершилась синхронно, вызывающий Begin не сможет с ней справиться, поскольку пока неизвестно, что операция выполнена (и если вызывающий вызов просто вызовет End, он заблокируется до завершения операции). Однако, если CompletedSynchronously - true, если обратный вызов должен был обрабатывать работу по продолжению, то это чревато погружением в стек, поскольку он будет выполнять эту работу по продолжению глубже в стеке, чем там, где она началась. Таким образом, любые реализации, связанные с такими погружениями в стек, должны быть проверены CompletedSynchronously и чтобы вызывающий метод Begin выполнял работу продолжения, если это возможно true, что означает, что обратный вызов в этом случае должен не выполнять работу продолжения. Вот почему CompletedSynchronously это никогда не должно меняться: вызывающий и обратный вызов должны видеть одно и то же значение, чтобы гарантировать, что работа с продолжением выполняется один и только один раз, независимо от условий гонки.

В нашем предыдущем DoStuff примере это приводит к следующему коду:

-11

Это полный бред. И до сих пор мы рассматривали только использование шаблона… мы не рассматривали реализацию шаблона. В то время как большинству разработчиков не нужно беспокоиться о конечных операциях (например, о реализации реальных Socket. BeginReceive/EndReceive методов, которые взаимодействуют с операционной системой), многим, многим разработчикам нужно было бы позаботиться о составлении этих операций (выполнении нескольких асинхронных операций, которые вместе образуют более крупную операцию), что означает не только использование других методов начала / завершения, но и реализацию их самостоятельно, чтобы саму вашу композицию можно было использовать в другом месте. И вы заметите, что в моем предыдущем DoStuff примере не было потока управления. Добавьте в это несколько операций, особенно даже с таким простым потоком управления, как цикл, и внезапно это становится прерогативой экспертов, которым нравится мучиться, или авторов постов в блогах, пытающихся доказать свою точку зрения.

Итак, просто чтобы прояснить этот момент, давайте реализуем полный пример. В начале этого поста я показал CopyStreamToStream метод, который копирует все данные из одного потока в другой (аналогично Stream.CopyTo, но, ради пояснения, предполагая, что такого не существует):

-12

Все просто: мы многократно считываем из одного потока, а затем записываем полученные данные в другой, читаем из одного потока и записываем в другой, и так далее, пока у нас не останется больше данных для чтения. Теперь, как бы мы реализовали это асинхронно, используя шаблон APM? Что-то вроде этого:

-13
-14
-15

И, даже со всей этой галиматьей, это все равно не самая лучшая реализация. Например, IAsyncResult реализация блокирует каждую операцию, а не выполняет что-либо более безблокировочным способом, где это возможно, Exception хранится в необработанном виде, а не в виде ExceptionDispatchInfo, что позволило бы увеличить его стек вызовов при распространении, в каждой отдельной операции задействовано много ресурсов (например, делегат выделяется для каждого BeginWrite вызова) и так далее. Теперь представьте, что вам приходится делать все это для каждого метода, который вы хотели написать. Каждый раз, когда вы хотели написать повторно используемый метод, который потреблял бы другую асинхронную операцию, вам нужно было бы выполнять всю эту работу. И если вы хотите написать повторно используемые комбинаторы, которые могли бы эффективно работать с несколькими дискретными IAsyncResultкодами (подумайте Task.WhenAll), это другой уровень сложности; каждая операция, реализующая и предоставляющая свои собственные API, специфичные для этой операции, означала, что не было общего языка для обсуждения их всех одинаково (хотя некоторые разработчики писали библиотеки, которые пытались немного облегчить нагрузку, обычно с помощью другого уровня обратных вызовов, который позволял API предоставлять соответствующий AsyncCallback методу Begin).

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

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

to be continued...