Комментарии 36
Если вы вставите этот код в Visual Studio, MonoDevelop или Try F #, вы тут же получите предупреждение:Но вы получите его и для C#.
Пункт «Подводный камень #6: Асинхронность не работает» просто странный. Я пишу Wait если мне нужно подождать и не пишу, если не нужно.
Статья не особо интересна, ибо всё это тысячу раз разжевано уже.
В этой статье я рассмотрел шесть случаев, в которых модель асинхронного программирования в C# ведёт себя неожиданным образом.
То есть неожиданным такое поведение будет ну вот если уже совсем не понимаешь что такое async/await и никогда ими не пользовался…
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.
То, что вы предполагаете только два варианта — пул и 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, чтобы явно выражать, что задача должна выполниться, но результат в явном виде не нужен.
Потому что "например".
:)
Здесь есть объяснение: 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 не понял, но у вас слишком много эмоций.
Асинхронность в C# и F#. Подводные камни асинхронности в C #