yield return #dotnet #il-code Пародия на замыкания #dotnet #methods #gc ThreadPool.Intro #dotnet #threadpool Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview Сказка про Method as Parameter #dotnet #methods #gc Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization А вы никогда не задумывались, что async и await выглядят как-то инородно среди прочего C# кода? Больше нигде не встречается такого странного синтаксиса и таких модификаторов, кроме как в методах, работающих с Task и Task<T>.   А ещё интересно, сколько вообще стоит пользоваться async/await? И когда можно (нужно?) обходиться без них? Давайте выясним! Статья является продолжением предыдущей из серии статей про ThreadPool. Читать предыдущую статью для того, чтобы понять эту, необязательно. Но в дальнейшем это может быть не так. Следуй за мной. Я покажу тебе все тайны этого мира. Ты скажешь, что мы проходили это уже сто раз? Что мы уже были здесь? О нет, мы не ходим по кругу. Этот путь может быть бесконечно похож на тысячи других, которые мы уже проходили. Но он другой. И будут ещё тысячи и тысячи других. Что, кажется, что мы попали в прошлое? Что такое уже было? Нет, ни в коем случае. Присмотрись внимательно. Присмотрись к мелочам. Это ещё не волшебство. Это так, дешевые фокусы. Чтобы овладеть настоящей магией, нужно научиться чувствовать каждую мельчайшую частичку этого мира. Каждая крошка, каждый символ, каждый малейший кусочек этого мира имеет особенный смысл. Бесконечно важный и глубокий. Каждое сформированное слово не просто так. Возникая, они фундаментально меняют всё вокруг. Нельзя разбрасываться словами просто так. Также, как нельзя разбрасываться смыслом. Если что-то использовать бессмысленно, получится бессмыслица. Каждое слово, которое использовано, должно быть не просто так. Если ты не знаешь, что это слово значит, не смей использовать его. Иначе придётся отвечать за последствия.  Сложно представить себе современное приложение, которое не является многопоточным и не работает с Task. А в современном C#, наверное, каждый хотя бы раз пользовался async методами и вызывал их с ключевым словом await. Пользоваться методами, возвращающими Task, очень просто. И нужно. И современный C# очень хорош, позволяет достаточно эффективно и просто пользоваться такими методами, иногда даже не осознавая почти ничего об их устройстве. А современный .Net очень хорошо реализует эту механику, чтобы в большинстве случаев это работало достаточно эффективно. Сегодня мы совсем легонечко пощупаем таски со стороны их использования в цепочках вызовов методов. Task — очень многогранная штука. За ней может прятаться как закешированный и готовый результат, так и простая idle-пауза. Как сетевой вызов к соседнему сервису, так и нагромождение других Task и их Continuation'ов, выстроенных в сложную цепочку и переиспользующих результаты друг друга, способных работать целыми часами. Поэтому сегодня нам будет важен не «контент» или «полезная нагрузка» Task'а, а то, как с этим Task обращаются в коде. Task — это вполне обычный .NET объект — класс. Кстати, весьма тяжеловесный. Среди полей этого класса есть, например, «флажок IsCompleted». Или поле «посчитанный ответ», чтобы раздавать его всем налево и направо, если его попросят много раз (естественно после того, как ответ посчитается). Поэтому метод, который возвращает Task, её не исполняет и даже не дожидается результата её исполнения. Он просто возвращает объект. Он возвращает не результат выполнения Task'и, а саму Task. Так, например, вот такой метод не выполняет никакого ожидания и выполняется мгновенно: Ждать 100500 миллисекунд будет тот, кто попытается дождаться выполнения работы таски. Или попытается извлечь результат выполнения этой таски (у Task.Delay(), правда, нет никакого результата, кроме факта завершения работы). И если к моменту «заглядывания за результатом» этой таски 100500 миллисекунд уже прошло, то результат будет «мгновенным», а иначе, придётся ждать столько, сколько осталось подождать. Как бы мог выглядеть код, который дожидается результата выполнения этой таски? Как-нибудь так: Вот метод WaitTaskDelay() уже будет ожидать исполнения Task.Delay(100500), которую метод ReturnTaskDelay() только лишь стартанул и мгновенно вернул. И всё время ожидания метод WaitTaskDelay будет занимать целый поток у .NET'а, не давая никому другому им воспользоваться. А как ещё мы могли бы написать этот код? Например, используя ключевые слова async и await в методе ReturnTaskDelay (который в такой ситуации принято называть уже с суффиксом Async, т.е. ReturnTaskDelayAsync): На первый взгляд эти два куска кода очень похожи. Более того, кажется, что эффект от вызова метода WaitTaskDelay в обоих случаях будет абсолютно одинаковый. Так ли это? Давайте проверим. Task.Delay() — слишком сложная Task'а. Она чего-то ждёт, там внутри очередь Timer'ов, там ух какие дебри. Давайте возьмём чего-нибудь попроще. Как на счет «Таски с готовым ответом»? Task.FromResult(42) создаёт Task<int> сразу «завершенную» и с «посчитанным ответом 42». Если попросить у такого Task ответ, он «мгновенно» вернётся из закешированного состояния. Как если бы это была Task, которая скачивает ответ с другого сервера, но к моменту вашего обращения за результатом ответ она уже получила и ничего ждать не нужно. Перепишем наш код с использованием не Task.Delay(), а нашей Task с ответом на главный вопрос жизни, вселенной, и всего такого: Давайте вызовем методы CallMethod и CallAsyncMethod. Сравним с помощью бенчмарка, как они работают? Разница колоссальная. Async метод оказался в ~20 раз медленнее и нааллоцировал каких-то объектов! А мы в коде никаких объектов не создаём. Видимо, ключевые слова async и await таят в себе много интересностей. Ну, ничего страшного, мы такое уже проходили. Заглянем внутрь с помощью ildasm (ILSpy, или иными инструментами изучения IL-кода): Мы не просто так изучали yield return'ы. Это было просто необходимым шагом для сегодняшнего мероприятия. В этом мире всё не просто так. А значит, нам сразу должно броситься в глаза то, что внутри нашего класса ThreadPoolAwaitsCost (в этом классе я написал наш код) есть автосгенерированный тип <GetAnswerToTheUltimateQuestionOfLifeAsync>d__3. Кстати, мы не можем сразу предположить, что именно этот тип является тем, которым намусорили при использовании метода CallAsyncMethod. Потому что этот автосгенерированный тип — ValueType, или, проще говоря, struct. Этот тип реализует некий интерфейс IAsyncStateMachine: Оставим разбирательство, что нам нагенерировал компилятор, и что это вообще за тип такой. Давайте сравним, чем отличаются методы, которые мы вызывали и бенчмаркали. CallAsyncMethod и CallMethod выглядят совершенно одинаково. Они просто вызывают следующую функцию (GetAnswerToTheUltimateQuestionOfLifeAsync и GetAnswerToTheUltimateQuestionOfLife соответственно), как и написано у нас в коде, и получают объект Task<int>. А затем вызывают у него Getter свойства Result. Что ж, заглянем тогда в следующие по цепочке вызовов функции. Ага. А вот тут уже большое различие. Не-async метод очень простой. Он берет значение поля CompletedTask и сразу возвращает его. Что, в целом, от него и требовалось. А на async метод давайте посмотрим поближе и покрупнее. Цветом выделены участки кода, на которые следует обратить внимание, мы их сейчас разберём подробнее. Красненькое. В строке 0002 вызывают метод Create у AsyncTaskMethodBuilder<int>. Потом как-то работают с этим билдером и нашим сгенерированный типом <GetAnswerToTheUltimateQuestionOfLifeAsync>d__3. Синенькое. В строке 001d всё у того же AsyncTaskMethodBuilder<int> вызывают метод Start. Потом снова как-то манипулируют с билдером и нашим <GetAnswerToTheUltimateQuestionOfLifeAsync>d__3. Зелёненькое. В строке 0029 наконец-то у AsyncTaskMethodBuilder<int> вызывают Getter у свойства Task. Получают ту самую таску, которую мы должны вернуть из метода. И в конце-концов возвращают. А мы можем повторить такой же код сами? Написать аналог async метода с await'ом тасок, но без async и await? Да легко: Можете проверить сами, этот метод тоже возвращает 42. Забенчмаркаем его вместе с остальными. Получилось чуть быстрее. Наверное, просто потому, что компилятор очень громоздко реализовал метод MoveNext() в своём автосгенерированном классе. Он не подозревал, насколько простой у нас случай, и сгенерировал настоящий автомат с кучей состояний, с описанием их переходов. Чтобы работало в общем случае. Да, тот, кто реализует IAsyncStateMachine, на самом деле реализует просто автомат. Будь наш метод GetAnswerToTheUltimateQuestionOfLifeAsync сложный, с кучей await'ов и условиями переходов между ними, то в автомате сгенерированного кода мы бы встретили по состоянию на, условно, каждый await. И описание всех возможных переходов между ними. А руками собрать такой автомат в собственной реализации IAsyncStateMachine (как мы сделали в GetAnswerToTheUltimateQuestionOfLifeWithBuilder для автомата с одним состоянием) было бы слишком занудным занятием. Отлично. Теперь мы умеем писать код, который в итоге получается при использовании async и await, но без async и await, с помощью обычных языковых конструкций. И вот, снова получается, что никакого async и await не существует. Это просто такой сахар, чтобы инженерам не приходилось самим писать автоматы (кто прогуливал теорию автоматов в институте?). Что мы выяснили? Мы выяснили, что async и await это такие ключевые слова, которые на самом деле не являются частью .NET'а. Это просто сахар в языке C#, который исчезает при компиляции и превращается в обычные .NET объекты и методы, по пути переписывая наш код в другой. Что нам осталось выяснить? Посмотреть на то, как бы выглядел автомат в настоящем сложном случае с использованием async и await. Проникнуться логикой того, как действует компилятор и как работает async/await. Выяснить, а чем же всё-таки намусорил код с async и await и с нашей собственной реализацией IAsyncStateMachine. Совсем переусложнять циклами, кучей условий и сложными переходами не будем. Возьмём просто несколько await вызовов подряд: Пора написать автомат, описывающий переходы между await'ами. Почему мы концентрируемся именно на точках с await? Потому что, если нам придётся долго ожидать результата выполнения той Task, которой мы делаем await, мы не хотим блокировать поток .NET'а и сидеть ждать, бессмысленно его занимая, пока та таска завершится. Мы хотим сказать дотнету: «Дотнет-дотнет, мы запомнили, что остановились на ожидании вон той Task (записали себе в состояние). Мы не знаем, когда она закончит свою работу и не хотим продолжать занимать (собой) твой поток в пустую. Поэтому мы заканчиваем работать. Вызови нас снова (дёрни наш метод MoveNext()), когда эта таска завершится, а мы продолжим с этого момента дальше (ведь мы запомнили себе в состоянии, где мы остановились)». Разговаривать с Дотнетом очень увлекательно, знаете ли. Давайте ровно это и напишем, только на языке C#. Я оставлю наш Sample, а рядом напишу его альтернативу без async и await с использованием автомата в методе WithStateMachine(): Я постарался сделать код максимально читаемым и простым (от чего сам автомат и код стали далеко не самыми оптимальными) и снабдил его комментариями. Надеюсь, что всё было понятно. Код получился довольно простой, но объёмный. Понятно, зачем нужен сахар с async и await. Писать код без них слишком утомительно. Вы можете самостоятельно убедиться, что результаты выполнения обоих методов одинаковые. А тут всё очень просто. Если чуть-чуть покопаться в методах AsyncTaskMethodBuilder, то можно заметить, что когда мы делаем return machine.Builder.Task;, мы собственно и создаём ту самую таску, которую в итоге вернём наружу. И это всегда будет новый объект Task. Кстати, ещё Builder.SetResult (который нам необходимо сделать в конце работы с автоматом, чтобы вернуть результат) тоже кое-когда может создать новую Task, в которой просто лежит готовый ответ. Есть Task'и. И это обычные объекты. Ими можно обмениваться и пользоваться, как обычным object. Task'у можно «дождаться, пока работа внутри неё закончится», или «попросить у неё ответ, но перед этим сначала придётся дождаться, пока работа внутри закончится, если ещё не». Task'у можно «дождаться», используя ключевое слово await, но только в методах, помеченных как async. Если ждать выполнения работы в Task'е таким образом, то дотнет будет «отдыхать» и не занимать поток, пока работа в Task'е не закончится. А потом «разбудит» ваш метод (на самом деле уже не ваш метод, он его переделает в автомат) для продолжения работы с кодом, написанным после await. Вот только никаких async и await не существует. При компиляции они исчезают, превращаясь в вполне понятный и обычный C# код, описывающий автомат. Работающий с методами ThreadPool'а и Task'ами. По умолчанию, самым безопасным поведением наверное можно считать использование async-методов с await'ами. Так сложнее ошибиться, нужно меньше думать, у вас всегда будут читабельные стектрейсы. Они придуманы для того, чтобы было комфортно выстраивать сложные цепочки взаимодействия тасок друг с другом, чтобы переиспользовать результаты выполнения Task'ов друг у друга, и при этом не блокироваться на синхронном ожидании выполнения каждой из этих Task (.Result, .Wait(), GetAwaiter().GetResult()). Но в случаях, когда вам критична производительность, пользоваться await в async методах нужно осознанно и аккуратно. И иногда можно и вовсе не пользоваться. Например, если у вас есть готовая Task, которую нужно просто вернуть наружу, нет никакого смысла await'ить её. Просто сразу возвращайте, чтобы дотнету не пришлось делать невероятно много сложной работы. Нужно ли когда-нибудь самостоятельно реализовывать такой автомат вместо использования await'ов? Сомневаюсь. Но в теории могут быть ситуации, когда такое пригодится. Моя реализация WithStateMachine получилась на ~10% эффективнее, чем автосгенерированная по методу Sample ;) Зачем мы так подробно разбирали, казалось бы, интуитивные в использовании вещи? Зачем узнавали, как оно устроено внутри? Так надо. Так полезно. Эти знания нам ещё пригодятся. А понимание устройства работы тредпула может быть очень полезно на практике.