Pull to refresh

Comments 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?

В этой статье я рассмотрел шесть случаев, в которых модель асинхронного программирования в 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 года статья не особо актуальна как мне кажется.
Sign up to leave a comment.

Articles