Почему не все функции по умолчанию должны быть асинхронными?

107

Асинхронному Await модель .net 4.5 это парадигма меняется. Это слишком хорошо, чтобы быть правдой.

Я портировал некоторый код с большим количеством операций ввода-вывода в async-await, потому что блокировки остались в прошлом.

Довольно много людей сравнивают async-await с заражением зомби, и я обнаружил, что это довольно точно. Асинхронный код любит другой асинхронный код (вам нужна асинхронная функция, чтобы ожидать выполнения асинхронной функции). Так что все больше и больше функций становятся асинхронными, и это продолжает расти в вашей кодовой базе.

Изменение функций на асинхронные - несколько повторяющаяся и лишенная воображения работа. Добавьте asyncключевое слово в объявление, оберните возвращаемое значение, Task<>и все готово. Довольно неприятно, насколько легко весь процесс, и довольно скоро сценарий замены текста автоматизирует для меня большую часть «портирования».

А теперь вопрос ... Если весь мой код медленно превращается в асинхронный, почему бы просто не сделать его асинхронным по умолчанию?

Очевидная причина, по которой я предполагаю, - это производительность. Async-await имеет свои накладные расходы и код, который не обязательно должен быть асинхронным, желательно не должен. Но если производительность является единственной проблемой, конечно, некоторые умные оптимизации могут автоматически удалить накладные расходы, когда они не нужны. Я читал об оптимизации "быстрого пути" , и мне кажется, что она одна должна решить большую часть этого.

Возможно, это сравнимо со сменой парадигмы, вызванной сборщиками мусора. На заре GC освобождение собственной памяти было определенно более эффективным. Но массы по-прежнему выбирают автоматический сбор в пользу более безопасного и простого кода, который может быть менее эффективным (и даже это, возможно, больше не соответствует действительности). Может, здесь так и должно быть? Почему не все функции должны быть асинхронными?

талькол
источник
8
Отдайте должное команде C # за разметку карты. Как это было сотни лет назад, «здесь лежат драконы». Вы можете оборудовать корабль и отправиться туда, скорее всего, вы переживете его с сияющим Солнцем и ветром в вашей спине. А иногда и нет, они никогда не возвращались. Подобно async / await, SO наполнен вопросами от пользователей, которые не понимают, как они сошли с карты. Хотя они получили довольно хорошее предупреждение. Теперь это их проблема, а не проблема команды C #. Они отметили драконов.
Ханс Пассан
1
@Sayse, даже если вы удалите различие между функциями sync и async, вызовы функций, которые реализованы синхронно, все равно будут синхронными (как в вашем примере WriteLine)
talkol
2
«Оберните возвращаемое значение Task <>» Если только ваш метод не должен быть async(для выполнения некоторого контракта), это, вероятно, плохая идея. Вы получаете недостатки async (повышенная стоимость вызовов методов; необходимость использования awaitв вызывающем коде), но ни одного из преимуществ.
svick
1
Это действительно интересный вопрос, но, возможно, он не очень подходит для SO. Мы могли бы рассмотреть возможность переноса его на Программисты.
Эрик Липперт
1
Может я чего-то упускаю, но у меня точно такой же вопрос. Если async / await всегда идут парами, а код все еще должен ждать завершения выполнения, почему бы просто не обновить существующую платформу .NET и не сделать те методы, которые должны быть асинхронными - асинхронными по умолчанию БЕЗ дополнительных ключевых слов? Язык уже становится тем, от чего он был создан, - спагетти с ключевым словом. Я думаю, им следовало прекратить это делать после того, как было предложено «вар». Теперь у нас есть "динамический", asyn / await ... и т. Д. Почему вы, ребята, не просто .NET-ify javascript? ;))
monstro

Ответы:

128

Прежде всего, спасибо за добрые слова. Это действительно потрясающая функция, и я рад, что был ее небольшой частью.

Если весь мой код медленно превращается в асинхронный, почему бы просто не сделать его асинхронным по умолчанию?

Что ж, вы преувеличиваете; весь ваш код не становится асинхронным. Когда вы складываете два «простых» целых числа вместе, вы не ждете результата. Когда вы складываете два будущих целых числа вместе, чтобы получить третье будущее целое число - потому что Task<int>это целое число, к которому вы собираетесь получить доступ в будущем - конечно, вы, вероятно, будете ждать результата.

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

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

Теоретически теория и практика похожи. На практике это не так.

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

Первый момент снова: async в C # / VB / F # по сути является ограниченной формой передачи продолжения . Огромное количество исследований в сообществе функциональных языков было направлено на выяснение способов определения того, как оптимизировать код, который интенсивно использует стиль передачи продолжения. Команде компилятора, вероятно, придется решать очень похожие проблемы в мире, где "асинхронный" был по умолчанию, а неасинхронные методы должны были быть идентифицированы и деасинхронизированы. Команда C # на самом деле не заинтересована в решении открытых исследовательских задач, так что это большой аргумент против.

Второй аргумент против - то, что C # не имеет уровня "ссылочной прозрачности", который делает такие виды оптимизации более управляемыми. Под «ссылочной прозрачностью» я подразумеваю свойство, от которого значение выражения не зависит, когда оно оценивается . Выражения вроде 2 + 2ссылочно прозрачны; вы можете выполнить оценку во время компиляции, если хотите, или отложить ее до времени выполнения и получить тот же ответ. Но такое выражение, как x+yнельзя перемещать во времени, потому что x и y могут меняться со временем .

Async значительно усложняет рассуждение о том, когда произойдет побочный эффект. Перед async, если вы сказали:

M();
N();

и M()был void M() { Q(); R(); }, и N()был void N() { S(); T(); }, и Rи Sвызывать побочные эффекты, то вы знаете , что побочный эффект R, случается , прежде чем побочный эффект S в. Но если да, async void M() { await Q(); R(); }то вдруг это вылетает из окна. У вас нет гарантии, R()произойдет ли это до или после S()(если, конечно, этого M()не ждут; но, конечно, этого Taskне нужно ждать до конца N()).

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

Третий аргумент против заключается в том, что вы должны спросить: «Почему асинхронный режим такой особенный?» Если вы собираетесь утверждать, что каждая операция должна быть на самом деле, Task<T>тогда вы должны быть в состоянии ответить на вопрос «почему бы и нет Lazy<T>?». или "почему бы и нет Nullable<T>?" или "почему бы и нет IEnumerable<T>?" Потому что мы могли бы сделать это так же легко. Почему не должно быть так, чтобы каждая операция была отменена до нулевого значения ? Либо каждая операция вычисляется лениво, а результат кэшируется для дальнейшего использования , либо результатом каждой операции является последовательность значений, а не одно значение . Затем вы должны попытаться оптимизировать те ситуации, когда вы знаете: «О, это никогда не должно быть нулевым, поэтому я могу сгенерировать лучший код» и так далее.

Дело в том, что мне не ясно, что Task<T>на самом деле является настолько особенным, чтобы оправдать такую ​​большую работу.

Если вас интересуют подобные вещи, я рекомендую вам изучить функциональные языки, такие как Haskell, которые имеют гораздо более сильную ссылочную прозрачность и допускают все виды неупорядоченной оценки и автоматическое кэширование. Haskell также имеет гораздо более сильную поддержку в своей системе типов для видов «монадических лифтингов», о которых я говорил.

Эрик Липперт
источник
Для меня не имеет смысла вызывать асинхронные функции без ожидания (в общем случае). Если бы мы удалили эту функцию, компилятор мог бы сам решить, является ли функция асинхронной или нет (вызывает ли она await?). Тогда мы могли бы иметь одинаковый синтаксис для обоих случаев (асинхронный и синхронный) и использовать только await в вызовах в качестве дифференциатора. Заражение зомби решено :)
talkol
Я продолжил обсуждение в программистах по вашему запросу: programmers.stackexchange.com/questions/209872/…
talkol
@EricLippert - Очень хороший ответ, как всегда :) Мне было любопытно, можете ли вы уточнить "высокую задержку"? Есть ли здесь общий диапазон в миллисекундах? Я просто пытаюсь выяснить, где находится нижняя граница для использования async, потому что я не хочу злоупотреблять этим.
Travis J
7
@TravisJ: Рекомендация: не блокируйте поток пользовательского интерфейса более чем на 30 мс. Если больше, то вы рискуете сделать паузу заметной для пользователя.
Эрик Липперт
1
Меня беспокоит то, что выполняется ли что-то синхронно или асинхронно - это деталь реализации, которая может измениться. Но изменение в этой реализации вызывает волновой эффект через код, который ее вызывает, код, который ее вызывает, и так далее. В конечном итоге мы меняем код из-за кода, от которого он зависит, чего мы обычно стараемся избегать. Или мы используем, async/awaitпотому что что-то, что скрыто за слоями абстракции, может быть асинхронным.
Скотт Ханнен
23

Почему не все функции должны быть асинхронными?

Как вы сказали, производительность - одна из причин. Обратите внимание, что вариант «быстрого пути», с которым вы связались, действительно улучшает производительность в случае завершенной задачи, но по-прежнему требует гораздо больше инструкций и накладных расходов по сравнению с одним вызовом метода. Таким образом, даже при наличии «быстрого пути» вы добавляете много сложностей и накладных расходов с каждым вызовом асинхронного метода.

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

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

Кроме того, существует множество подпрограмм, которые по своей природе не являются асинхронными. Это имеет больше смысла как синхронные операции. Например, принуждение Math.Sqrtк использованию Task<double> Math.SqrtAsyncбыло бы нелепым, поскольку нет никаких причин для того, чтобы это было асинхронным. Вместо того , чтобы asyncпротолкнуть ваше приложение, вы бы в конечном итоге с awaitpropogating везде .

Это также полностью нарушит текущую парадигму, а также вызовет проблемы со свойствами (которые, по сути, являются просто парами методов ... будут ли они также работать асинхронными?), И будет иметь другие последствия во всем дизайне фреймворка и языка.

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

Рид Копси
источник
Точно то, что я собирался написать (производительность), обратная совместимость может быть другим делом, dll должны использоваться со старыми языками, которые также не поддерживают async / await
Sayse
Создание sqrt async не является нелепым, если мы просто удалим различие между функциями синхронизации и асинхронности
talkol
@talkol Думаю, я бы изменил это - почему каждый вызов функции должен принимать на себя сложность асинхронности?
Рид Копси
2
@talkol Я бы сказал, что это не обязательно так - асинхронность сама по себе может добавлять ошибки, которые хуже, чем блокировка ...
Рид Копси
1
@talkol Как await FooAsync()проще чем Foo()? И иногда вместо небольшого эффекта домино у вас постоянно появляется огромный эффект домино, и вы называете это улучшением?
svick
4

Помимо производительности - асинхронный режим может стоить производительности. На клиенте (WinForms, WPF, Windows Phone) это благо для производительности. Но на сервере или в других сценариях без пользовательского интерфейса вы платите за производительность. Вы, конечно же, не хотите использовать асинхронный режим по умолчанию. Используйте его, когда вам нужны преимущества масштабируемости.

Используйте его, когда находитесь в сладком месте. В других случаях - нет.

usr
источник
2
+1 - Простая попытка создать мысленную карту кода, выполняющего 5 асинхронных операций параллельно со случайной последовательностью завершения, для большинства людей доставит достаточно боли в течение дня. Рассуждения о поведении асинхронного (и, следовательно, по своей сути параллельного) кода намного сложнее, чем старый добрый синхронный код ...
Алексей Левенков
2

Я считаю, что есть веская причина сделать все методы асинхронными, если они не нужны - расширяемость. Выборочное создание асинхронных методов работает только в том случае, если ваш код никогда не развивается, и вы знаете, что метод A () всегда привязан к ЦП (вы сохраняете его синхронизацию), а метод B () всегда привязан к вводу-выводу (вы отмечаете его как асинхронный).

Но что, если все изменится? Да, A () выполняет вычисления, но в какой-то момент в будущем вам пришлось добавить туда ведение журнала или отчет, или определяемый пользователем обратный вызов с реализацией, которая не может предсказать, или алгоритм был расширен и теперь включает не только вычисления ЦП, но и также какой-то ввод / вывод? Вам нужно будет преобразовать метод в асинхронный, но это сломает API, и все вызывающие абоненты в стеке также должны будут быть обновлены (и это могут быть даже разные приложения от разных поставщиков). Или вам нужно будет добавить асинхронную версию вместе с версией синхронизации, но это не имеет большого значения - использование версии синхронизации будет блокировать и, следовательно, вряд ли приемлемо.

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

Alex
источник
Хотя это кажется крайним комментарием, в нем много правды. Во-первых, из-за этой проблемы: «это нарушит API и всех вызывающих абонентов вверх по стеку» async / await делает приложение более тесно связанным. Это может легко нарушить принцип замены Лискова, если, например, подкласс хочет использовать async / await. Кроме того, сложно представить архитектуру микросервисов, в которой большинству методов не нужен async / await.
ItsAllABadJoke