Как стать автором
Обновить

Комментарии 36

Если вы вставите этот код в Visual Studio, MonoDevelop или Try F #, вы тут же получите предупреждение:
Но вы получите его и для C#.

Пункт «Подводный камень #6: Асинхронность не работает» просто странный. Я пишу Wait если мне нужно подождать и не пишу, если не нужно.

Статья не особо интересна, ибо всё это тысячу раз разжевано уже.
Большую часть этих «подводных камней» на собеседованиях спрашивают. Чтоб работать с асинхронным кодом, хорошо бы или понимать что под капотом у async/await или хотя бы заучить подобные моменты. Иначе будет бег по граблям.
Вот-вот. Я как бы тоже немного в недоумении от заявления:

В этой статье я рассмотрел шесть случаев, в которых модель асинхронного программирования в C# ведёт себя неожиданным образом.


То есть неожиданным такое поведение будет ну вот если уже совсем не понимаешь что такое async/await и никогда ими не пользовался…
Образованных людей не удивляет, что «яма» по-японски означает «гора». А вот то, что async в языках F# и С# означает разные понятия — почему-то удивляет, хотя эти языки — тоже сильно разные.
IMHO легче всего понять смысл ключевого слова async в заголовке метода в C# — это запомнить, что оно вообще не для кода, который вызывает метод. Вызывающему коду достаточно знать, что вызов этого метода возвращает что-то типа Task (и, возможно, — какой именно класс, чтобы получить результат), а это в сигнатуре метода прописывается вне зависимости от наличия async (с исключением для void, хотя IMHO вот это исключение сделано было зря). И потому, например, виртуальный метод без async в заголовке в базовом классе вполне успешно перекрывается методом с async в производном — т.е., по факту, сигнатуры у них одинаковые.
Ключевое слово async в C# описывает исключительно реализацию метода — дает возможность использовать в нем оператор await, т.е. служит для указанием для компилятора о необходимости преобразовать весьма хитрым способом тело метода, чтобы обеспечить временный возврат управления в планировщик в коде, который выглядит как последовательно выполняющийся безо всякой передачи управления (ну, и удостовриться, что в сигнатуре прописано возвращение Task или производного от него класса, т.к. с другим типом возвращаемого значения этот фокус с преобразованием проделать не получится).
PS Я тут, наверное, Капитаном Очевидность поработал, не спорю. Но факт появления такой статьи явно указывает на то, что услуги Капитана в данном вопросе востребованы.
PPS IMHO заголовок переведен неточно — я бы вообще не стал переводить первое слово «Async», т.к. из-за этого теряется смысл.
Ключевое слово async в C# описывает исключительно реализацию метода — дает возможность использовать в нем оператор await,

Тогда вполне естественный вопрос — а накой так сделали?
Чтобы писать внутри await, не нужно помечать метод как async. Компилятор вполне способен сам увидеть слово await внутри и отработать его включения соответствующих разрезаний. Всё равно компилятор не однопроходный, чтобы не иметь возможности вернуться к уже прочитанному. И зачем оно такое?
Да, был бы нормальный синхронный метод, но который зовёт await (предположим, что это допустимо напрямую в синхронном методе — C# вроде склоняется к подобному).


Чтобы был смысл помечать весь метод как async, надо, чтобы он чем-то отличался по сути. Например, он бы возвращал всегда Task согласно типу результата для всего выполнения тела, которое бы инициировалось в фоне. Да, "async void" возвращал бы Task, и так далее. Потом на него надо было бы звать await.

Это более естественная интерпретация для тех, кто вообще работал с асинхронным кодом с порождением всяких Task, ожиданий Future и т.п. — и основная суть статьи, как я понимаю, в том, что F# следует общеожидаемым подходам, а C#, внезапно, нет — там по факту async зачем-то всего лишь разрешает await внутри, а async-функцию нужно рассматривать не как тело задачи в целом, а как её запускатель.


Или вот пример из Microsoftʼовской доки:


await Task.WhenAll(eggsTask, baconTask, toastTask);

Чтобы написать тут await, я должен пометить метод как async. Тогда метод станет возвращать Task, который я должен awaitʼить. Но если я, наоборот, хотел, чтобы этот метод был синхронным, потому что мне незачем его выделять в асинхронность — мне тут таки надо подождать, пока все не завершатся? Получается, я этого уже не могу — мне надо вводить промежуточный await и ждать Task только потому, что меня язык тут загнал на рельсы?


услуги Капитана в данном вопросе востребованы.

Является ли работой Капитана Очевидность выдавать сообщения типа "а вот в C# почему-то всё неочевидно, там своя особенная логика"?

Но если я, наоборот, хотел, чтобы этот метод был синхронным, потому что мне незачем его выделять в асинхронность — мне тут таки надо подождать, пока все не завершатся?

В большинстве случаев это ошибка. Потоки пула лучше возвращать в пул, а в потоках UI синхронно ждать задачу и вовсе опасно.


В меньшинстве случаев существуют вот такие варианты (отличаются кидаемым исключением):


Task.WaitAll(eggsTask, baconTask, toastTask);
Task.WhenAll(eggsTask, baconTask, toastTask).GetAwaiter().GetResult();
В большинстве случаев это ошибка. Потоки пула лучше возвращать в пул, а в потоках UI синхронно ждать задачу и вовсе опасно.

То, что вы предполагаете только два варианта — пул и UI — уже характерно. Для меня, например, UI однозначно за пределами типовой области применения (сетевые серверы и т.п.)


Но подобные ошибки в любой области можно сделать ещё 100500 методами, не обязательно этим — и даже проще явно начать делать блокирующую операцию.


В меньшинстве случаев существуют вот такие варианты

Их особое размещение показывает, что как раз вначале построили текущую модель, а затем пристроили затычку на особый случай.

То, что вы предполагаете только два варианта — пул и UI — уже характерно. Для меня, например, UI однозначно за пределами типовой области применения (сетевые серверы и т.п.)

А какие ещё варианты вы видите-то?

Уже сказал: сетевое. А там сильно больше возможностей — например, можно явно порождать тред на вид активности, не выводя за установленные пределы нагрузки, или даже на клиента, если их мало (да, бывает не тот highload, где миллионы клиентов, а тот, где их пара десятков, но грузят жестоко)… главное, что ограничение "вот этот тред никогда не блокировать" не абсолютно.

Ну, если вы порождаете отдельный поток на запрос — то просто не используйте асинхронное API.

Ну, если вы порождаете отдельный поток на запрос — то просто не используйте асинхронное API.

Ну, я подозревал, что несмотря на "даже..." кто-то в это вцепится ;(


А почему вы не учитываете возможность, что при треде на запрос исходящие из него (например) API запросы могут быть запущены параллельно?

То, что вы предполагаете только два варианта — пул и UI — уже характерно. Для меня, например, UI однозначно за пределами типовой области применения (сетевые серверы и т.п.)

А как насчет сервера под названием Internet Information Server и выполняющейся на нем программы, написанной на ASP.NET? Это попадает под понятие "(сетевые серверы и т.п.)"?
А там ведь при проектировании ASP.NET было принято решение, что код обработки одного запроса HTTP (кроме специально явно сделанных параллельных вставок) выполняется в однопоточном режиме: его в любой момент времени может выполнять только один поток (не обязательно один и тот же). Решение это было принято, опять-таки, не с потолка: написание однопоточных программ сильно легче, чем многопоточных, потому что при этом нет необходимости думать о гонках, синхронизации и т.п. вещах, весьма сложных для неподготовленного прикладного программиста.
Это попадает под понятие "(сетевые серверы и т.п.)"?

Вполне.


что код обработки одного запроса HTTP (кроме специально явно сделанных параллельных вставок) выполняется в однопоточном режиме: его в любой момент времени может выполнять только один поток (не обязательно один и тот же).

Это ортогонально тому, что я говорю.
С другой стороны, разве нельзя из обработчика уже своим кодом вызвать что угодно внешнее, не ограниченное этими рамками?

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

А почему вдруг глюки только на этом? Они могут возникнуть на 100500 причинах, и если уж писать, то с пониманием проблем.


А написано все это было чисто для иллюстрации, что необходимость поддержки однопоточного выполнения в многопоточной среде может возникнуть и на сетевом сервере: например, чтобы облегчить написание программ.

И тоже нет возражений, кроме одного: к чему это всё было? Спор с коллегой mayorovp был о том, в каких тредах допустимо блокироваться.

А почему вдруг глюки только на этом?

Глюки с синхронизацией относятся к числу самых трудноуловимых — думаю, с этим вы спорить не будете. Поэтому они заслуживают того, чтобы использовать модель выполнения, в которой их возникновение вообще невозможно (это я — про причины выбора однопоточной модели).
к чему это всё было?

Я так вас понял, что, по-вашему, в вашей области "(сетевые серверы и т.п.)" ситуация с одним специально выделенным потоком, который блокировать нехорошо, невозможна. И указал на контрпример, где эта ситуация имеет место быть. Если же я понял вас неправильно, и что вы на самом деле допускаете место для однопоточной обработки в этой области, то это действительно было ни к чему, в таком случае приношу свои извинения.
Глюки с синхронизацией относятся к числу самых трудноуловимых — думаю, с этим вы спорить не будете. Поэтому они заслуживают того, чтобы использовать модель выполнения, в которой их возникновение вообще невозможно (это я — про причины выбора однопоточной модели).

Верно. Но если мы говорим про async/await, мы на самом деле не выбираем "однопоточную" модель — она для нас эмулируется (и мы надеемся на качество её эмуляции). Это, чуть отклоняясь в сторону, к вопросу о пулах: при одном пуле на всех можно запросто влететь в какой-то starvation не имея ручек управления против него. А ещё есть вопросы зависимости запросов между собой...


И указал на контрпример, где эта ситуация имеет место быть.

Ну насколько я понял, проблема будет только при исчерпании допустимого пула — если в нём все заблокировались (ну или при подходе к этому пределу будет замедление работы). В остальном полагаются на логику управления асинхронными операциями.


А так — да, дизайн может быть самым разным — например, по нитке на очередь сообщений. Тогда суммарная производительность зависит от количества таких очередей. Но если обработка компактна и однотипна — это может давать преимущество...

Но если мы говорим про async/await

В случае ASP.NET изначально говорить о нем было нельзя: изначально конвейер обработки одного запроса в ASP.NET был чисто синхроннным, хотя разыные запросы обрабатывались параллельно. Асинхронное выполнение обработчика запросов (причем — ещё в старом варианте, с IAsyncResult) в конвейере стала довольно поздним добавлением. Ну, а поддержка Task (и, как следствие, async/await) была добавлена ещё позднее, на основе старого варианта.
Причем с асинхронностью, особенно — именно в варианте async/await — в ASP.NET (который Framework, т.е. для IIS, ASP.NET Core имеет другую архитектуру) нужно быть очень осторожным как раз в силу этой самой однопоточности: задачи завершения (часть кода после await) выполняются в выделенном для обработки запроса потоке последовательно друг за другом, и если одна из таких задач ждет результата другой такой задачи, оказавшейся в очереди позади, то возникнет тупик (deadlock).
Тогда вполне естественный вопрос — а накой так сделали?

Делал это не я, поэтому точно скаазть не могу. Однако соображения, почему стоило сделать именно так, у меня есть. Основное — это для людей, которые будут читать код. Человеку сильно удобнее читать код последовательно, а не во много проходов, поэтому ему совсем не лишней будет пометка async в заголовке метода, указывающая, что будет возвращаться не результат (как написано в return), а задача, возвращающая этот результат.
основная суть статьи, как я понимаю, в том, что F# следует общеожидаемым подходам, а C#, внезапно, нет

Дело в том, что «общеожидаемые подходы» в F# и C# в реальности разные. F# — функциональный язык, там ожидаемо, что единица программы возвращает вычисление, т.е. способ получения результата, который, будучи примененным к исходным данным, даст результат. При таком подходе разница, между тем, является ли вычисление синхронным или нет, невелика. В C#, как в языке императивном, ожидаемо, что единица программы возвращает результат вычисления. Поэтому когда в качестве результата возвращается не сам результат, а способ его получения в виде задачи («подождать завершения и получить результат»), то это сильно отличается от просто вовзращения результата «здесь и сейчас».
пометка async в заголовке метода, указывающая, что будет возвращаться не результат (как написано в return), а задача, возвращающая этот результат.

Так против этой пометки — если она именно так и работает — возражения нет.
Но вот снова возвращаясь к этому:


Если вы пишете async void Foo() {… }, то компилятор C# генерирует метод, который возвращает void.

Правда это или нет? Возвращается void или Task? Если первое — это уже заметное нарушение логики.
Или это зависит ещё от чего-то? Точный ответ требует обширного практического опыта, поэтому полагаюсь на тех, кто с этим работал много вживую.

А второе — как понимать, что async-функция выполняет часть до первого await — синхронно? Она не async?
Тогда не проще было бы явно сказать, что она возвращает Task, а остальное уже вопрос внутренней реализации?

Поэтому когда в качестве результата возвращается не сам результат, а способ его получения в виде задачи («подождать завершения и получить результат»), то это сильно отличается от просто вовзращения результата «здесь и сейчас».

Верно. Но функция, которая возвращает Task, уже достаточно объявляет этим, как дальше обрабатывать её значение.

Тогда не проще было бы явно сказать, что она возвращает Task, а остальное уже вопрос внутренней реализации?

Ну, я как раз об этом и написал вначале. Да и если документацию почитать, то это ясно становится.
И вообще async/await в C# сделан для упрощения написания программ, которые можно было бы написать и без него, чисто через Task Parallel Library (кстати, я тут на Хабре видел про это совершенно дивное словосочетание для описания этого подхода — «континуация таски»). Но чтобы такую программу написать или даже читать, требуется дополнительный навык.
PS А вообще, лучше сразу иметь в виду, что нечто, на первый взгляд сходное, может означать в разных яхыках разное.
Ну, я как раз об этом и написал вначале. Да и если документацию почитать, то это ясно становится.

Так нет же, я про другой вариант — когда функция явно возвращает Task, а откуда она взяла — это уже её дело.

PS А вообще, лучше сразу иметь в виду, что нечто, на первый взгляд сходное, может означать в разных яхыках разное.

Ну так вся статья исходит из этого: что C# и F# сделали по-разному. А то, на что я начал отвечать — оправдания логики C# там, где она откровенно странная.

Так нет же, я про другой вариант — когда функция явно возвращает Task, а откуда она взяла — это уже её дело.

Кажется, я в прошлый раз кое-что пропустил — про async void — и ответил сразу на вторую часть вашего замечания.
Так вот, исправляю: объявляя метод async void, программист явно указывает на свою полную незаинтересованность в любых результатах выполнения этого метода. Поэтому исключение (которое — тоже результат) в примере #3 гибнет где-то за пределами внимания программиста.
К примененению делегата это тоже относится, даже если программист слепо доверил выведению компиятору вывод типа значения метода делегата.
Но в примерах #4 и #5 «странности» в поведении вызваны другой особенностью C#: по умолчанию в C# все задачи независимы. Код основной программы ждет завершения задач, запускаемых через Parallel.For (так что претензии к типу делегата void тут необоснованы), но вот сами эти задачи не ждут завершения запущенных их них задач. Аналогично в #5 задача не ждет запушенной из нее задачи. Возможность использовать парадигму «родительская/дочерняя задача» — так, чтобы задача перед своим завершением дождалась зовершения задач, запущенных ею — в C# есть, но это надо указывать явно.
А то, на что я начал отвечать — оправдания логики C# там, где она откровенно странная.

Странное — понятие чисто субъективное. То есть — у каждого свое. Человеку, привыкшему только к императивному программированию, странным может казаться вообще вся логика функционального программирования — он, к примеру, привык видеть промежуточное состояние в виде результатов. И наоборот.
Вообще же async/await — это способ, которым в C# скрывается сложность асинхронного программирования для тех, кто привык к программированию чисто последовательному, синхронному и императивному (а таких, вероятно, большинство). И в целом он с этой своей задачей довольно успешно справляется, вроде бы.
Правда это или нет? Возвращается void или Task? Если первое — это уже заметное нарушение логики.

Разумеется, она возвращает void. А где нарушение логики?


А второе — как понимать, что async-функция выполняет часть до первого await — синхронно? Она не async?
Тогда не проще было бы явно сказать, что она возвращает Task, а остальное уже вопрос внутренней реализации?

В большинстве случаев это можно считать вопросом внутренней реализации. Но иногда эта деталь имеет значение, например в обработчиках событий UI.

Разумеется, она возвращает void. А где нарушение логики?

В том, что это тоже результат, ожидание которого может быть важно.


Но иногда эта деталь имеет значение, например в обработчиках событий UI.

А почему такое внимание UI, если для других целей тоже может быть важно, что какая-то длительная активность с блокировкой была вызвана до первого await()?

В том, что это тоже результат, ожидание которого может быть важно.

Если ожидание этого результата важно — то нужно возвращать не void, а Task. async void-методы сделаны для тех случаев, когда результата никто не ждёт.


Или теперь уже вы будете отрицать существование подобных случаев?


А почему такое внимание UI

Потому что "например".

async void-методы сделаны для тех случаев, когда результата никто не ждёт.

Ну вот потому и жалоба в статье на ещё одно исключение, которое надо запомнить.


Или теперь уже вы будете отрицать существование подобных случаев?

Близко к тому, да. Эти случаи настолько специфичны, что лучше было бы для них придумать какой-нибудь Task.Detach() или SideEffectTask, чтобы явно выражать, что задача должна выполниться, но результат в явном виде не нужен.


Потому что "например".

:)

Эти случаи настолько специфичны, что лучше было бы для них придумать какой-нибудь Task.Detach() или SideEffectTask, чтобы явно выражать, что задача должна выполниться, но результат в явном виде не нужен.

Ну и куда вы засунете этот Task.Detach() в обработчике события WinForms?

Здесь есть объяснение: https://learn.microsoft.com/en-au/archive/blogs/ericlippert/asynchrony-in-c-5-part-six-whither-async

По итогу обсуждения достоинства в ключевом слове перевесили недостатки его отсутствия.

В этой статье я рассмотрел шесть случаев, в которых модель асинхронного программирования в C# ведёт себя неожиданным образом.

Автор хотел сказать "где C# ведёт себя именно так, как заявлено". Неожиданностью это может быть только для того, кто решил не вдаваться в механику async/await и просто кодит по принциву "так принято" (кстати — совершенно реальный ответ на собесе на позицию синьёра на вопрос "а вот зачем тут вообще await?")

Так проблема как раз в этой механике.


В естественно возникающих реализациях на C++, или в том, что сделано в Python — проблемы нет потому, что чётко известно: async-функция возвращает не свой результат, а объект, грубо говоря, Task её исполнения, от которого потом ждём результата. Если это строго типизировано, то async void недопустим в корне: может быть только async Task, да-да, включая async Task, если важен только факт завершения. Но уже по типу результата понятно, что его положено явно ждать.

Второе, что await допустим только в коде, который уже помечен как async. Если вам нужно из обычного синхронного кода подождать результата, то на это делается вызов переходника (в Python это, например, asyncio.get_event_loop().run_until_complete(task)). И вообще граница между синхронным и асинхронным кодом достаточно жёсткая, и пересекать её нужно явными указаниями (синхронный зовёт методы EventLoop, например, поставить задачи на исполнение; асинхронный может звать синхронный код через run_in_executor, который тоже возвращает Task, которую можно ждать, когда надо).


Когда же имеем хохмы типа


В C# первая часть кода в async-методе выполняется синхронно (в потоке вызывающей стороны). Вы можете исправить это, например, добавив в начале await Task.Yield().

этот костыль, конечно, запоминается после первой тройки наступленных грабель, но показывает принципиальную странность модели.


Ещё проблема, которая заметна при сравнении с аналогами — отсутствие штатных средств управления, в каком пуле будут выполнены задачи. На практике вполне бывает необходимость держать несколько пулов и с QoS между ними (с гарантированными полосами и приоритетами), и возможность управлять, в какой пул поставить задачу. Во всех альтернативных средствах, хоть и не видны на поверхности, но такие средства есть. Тут я их в принципе не вижу.

реализациях на C++, или в том, что сделано в Python — проблемы нет потому, что чётко известно: async-функция возвращает не свой результат, а объект, грубо говоря, Task её исполнения, от которого потом ждём результата

Серьёзно? public Task<MyObject>() — что в этой нотации вам неочевидно и заставляет думать, что функция возвращает что-то иное, отличное от "не свой результат, а объект, грубо говоря, Task её исполнения, от которого потом ждём результата".


Тут я их в принципе не вижу.

Само собой, если не смотреть — то и не увидишь. Это ж надо документацию почитать, чтоб увидеть, что есть Task.Run, а есть Task.Start (который получает TaskScheduler), есть сам ThreadPool в конце концов… Но нет же, это слишком сложно. Вся кастомная логика должна прям вот тут кишками наружу торчать прямо из await-сахара, чтоб каждый мамкин кодер потом орал на весь SSO "а если вот сюда передать говно — то и сработает, как говно!"

что в этой нотации вам неочевидно и заставляет думать, что функция возвращает что-то иное, отличное от "не свой результат, а объект, грубо говоря, Task её исполнения, от которого потом ждём результата".

Ну вот это как раз:


Если вы пишете async void Foo() {… }, то компилятор C# генерирует метод, который возвращает void.

Заметьте, возражения против этого факта не было. Были возражения типа "да, так и надо, так спроектировано".


Само собой, если не смотреть — то и не увидишь. Это ж надо документацию почитать, чтоб увидеть, что есть Task.Run, а есть Task.Start (который получает TaskScheduler), есть сам ThreadPool в конце концов…

И что даёт их наличие для решения данного вопроса?


Вся кастомная логика должна прям вот тут кишками наружу торчать прямо из await-сахара

Откуда вывод про "торчать кишками"? Я не давал к нему никакого повода.


Я сравниваю с вариантом, в котором:
1) любая функция со словом async возвращает Task, если объявлен результат типа T, но ничего не выполняет синхронно, кроме порождения Task и шедулинга его выполнения; соответственно вот это

В C# первая часть кода в async-методе выполняется синхронно (в потоке вызывающей стороны). Вы можете исправить это, например, добавив в начале await Task.Yield().
не имеет смысла, потому что не происходит;
2) можно вызвать await на Task; из async-функции — напрямую; из синхронного кода — ну для простоты пусть тоже напрямую (да, блокируется).

А теперь найдите принципиальное отличие этого предложения от того, что описано для F#. И где тут какие-то "торчащие кишки"? ;)


каждый мамкин кодер потом орал на весь SSO

Акроним SSO не понял, но у вас слишком много эмоций.

Task — это частный случай того, что можно ждать. В общем случае надо иметь лишь GetAwaiter(), возвращающий подходящий результат.
Иными словами, Task и await — это ортогональные вещи.
Программы вообще имеют свойство вести себя так, как написаны, а не так, как подразумевал автор.
Ну, наверное 7 лет назад это и правда могло быть удивительным для тех, кто не особо подробно читает документацию, но для 2020 года статья не особо актуальна как мне кажется.
Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Публикации

Истории