Разве принципы асинхронного / ожидающего использования в C # не противоречат понятиям хорошей архитектуры и многоуровневой абстракции?

103

Этот вопрос касается языка C #, но я ожидаю, что он охватит другие языки, такие как Java или TypeScript.

Microsoft рекомендует лучшие практики по использованию асинхронных вызовов в .NET. Среди этих рекомендаций давайте выберем две:

  • измените сигнатуру асинхронных методов, чтобы они возвращали Task или Task <> (в TypeScript это будет Promise <>)
  • изменить имена асинхронных методов на xxxAsync ()

Теперь, при замене низкоуровневого синхронного компонента на асинхронный, это влияет на весь стек приложения. Поскольку async / await оказывает положительное влияние только в том случае, если используется «до конца», это означает, что имена сигнатур и методов каждого уровня в приложении должны быть изменены.

Хорошая архитектура часто предполагает размещение абстракций между каждым уровнем, так что замена компонентов нижнего уровня другими невидима компонентами верхнего уровня. В C # абстракции принимают форму интерфейсов. Если мы представляем новый низкоуровневый асинхронный компонент, каждый интерфейс в стеке вызовов должен быть либо изменен, либо заменен новым интерфейсом. Способ решения проблемы (асинхронный или синхронный) в реализующем классе больше не скрыт (абстрагирован) для вызывающих. Вызывающие абоненты должны знать, является ли это синхронизацией или асинхронностью.

Разве асинхронные / не ждут лучших практик, противоречащих принципам «хорошей архитектуры»?

Означает ли это, что каждому интерфейсу (скажем, IEnumerable, IDataAccessLayer) требуется свой асинхронный аналог (IAsyncEnumerable, IAsyncDataAccessLayer), чтобы их можно было заменить в стеке при переключении на асинхронные зависимости?

Если мы продвинем проблему чуть дальше, не будет ли проще предположить, что каждый метод является асинхронным (для возврата Task <> или Promise <>), и чтобы методы синхронизировали асинхронные вызовы, когда они на самом деле не являются асинхронный? Это то, что можно ожидать от будущих языков программирования?

corentinaltepe
источник
5
Хотя это звучит как отличный вопрос для обсуждения, я думаю, что это слишком основано на мнении, чтобы отвечать здесь.
Эйфорическое
22
@Euphoric: Я думаю, что проблема, описанная здесь, глубже, чем рекомендации C #, это всего лишь признак того факта, что изменение частей приложения на асинхронное поведение может иметь нелокальные последствия для всей системы. Так что моя интуиция говорит мне, что для этого должен быть несмысленный ответ, основанный на технических фактах. Следовательно, я призываю всех здесь не закрывать этот вопрос слишком рано, вместо этого давайте подождем, какие ответы придут (и если они слишком самоуверенны, мы все равно можем проголосовать за закрытие).
Док Браун
25
@DocBrown Я думаю, что более глубокий вопрос здесь заключается в следующем: «Может ли часть системы быть изменена с синхронной на асинхронную без частей, которые также должны быть изменены?» Я думаю, что ответ на этот вопрос ясен «нет». В этом случае я не вижу, как здесь применимы «концепции хорошей архитектуры и многоуровневой структуры».
Эйфорическая
6
@Euphoric: звучит как хорошая основа для неуверенного ответа ;-)
Док Браун
5
@Gherman: потому что C #, как и многие языки, не может выполнять перегрузку только на основе возвращаемого типа. В итоге вы получите асинхронные методы, которые имеют ту же сигнатуру, что и их аналоги синхронизации (не все могут принять a CancellationToken, а те, которые это делают, могут захотеть указать значение по умолчанию). Удаление существующих методов синхронизации (и упреждающее разрушение всего кода), очевидно, не является началом.
Йерун Мостерт

Ответы:

111

Какого цвета ваша функция?

Вы можете быть заинтересованы в том, какой цвет у Боба Нистрома. 1 .

В этой статье он описывает вымышленный язык, где:

  • Каждая функция имеет цвет: синий или красный.
  • Красная функция может вызывать синие или красные функции, без проблем.
  • Синяя функция может вызывать только синие функции.

Хотя это и вымышлено, это происходит довольно регулярно в языках программирования:

  • В C ++ метод "const" может вызывать только другие методы "const" this.
  • В Haskell функция не-IO может вызывать только функции не-IO.
  • В C # функция синхронизации может вызывать только функции синхронизации 2 .

Как вы уже поняли, из-за этих правил красные функции имеют тенденцию распространяться вокруг базы кода. Вы вставляете один, и постепенно он колонизирует всю базу кода.

1 Боб Нистром, помимо ведения блога, также является частью команды Dart и написал эту маленькую серию Crafting Interpreters; настоятельно рекомендуется для любого языка программирования / компилятора afficionado.

2 Не совсем верно, так как вы можете вызывать асинхронную функцию и блокировать, пока она не вернется, но ...

Ограничение языка

По сути, это ограничение языка / времени выполнения.

Например, язык с многопоточностью M: N, такой как Erlang и Go, не имеет asyncфункций: каждая функция потенциально асинхронна, и ее «волокно» будет просто приостановлено, заменено и заменено обратно, когда оно снова будет готово.

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

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

Матье М.
источник
4
Функции ввода-вывода имеют тенденцию к распространению, но с усердием вы в основном можете изолировать их от функций вблизи (в стеке при вызове) точек входа вашего кода. Это можно сделать, вызвав эти функции для вызова функций ввода-вывода, а затем заставив другие функции обрабатывать выходные данные и возвращать любые результаты, необходимые для дальнейшего ввода-вывода. Я считаю, что этот стиль делает мои кодовые базы намного проще в управлении и работе. Интересно, есть ли следствие с синхронностью.
jpmc26
16
Что вы подразумеваете под потоками "M: N" и "1: 1"?
Капитан Мэн
14
@CaptainMan: поток 1: 1 означает отображение одного потока приложения на один поток ОС, это имеет место в таких языках, как C, C ++, Java или C #. Напротив, многопоточность M: N означает отображение потоков M приложений в потоки N OS; в случае Go поток приложения называется «goroutine», в случае с Erlang он называется «актером», и вы также можете слышать о них как «зеленые нити» или «волокна». Они обеспечивают параллелизм без параллелизма. К сожалению, статья в Википедии на эту тему довольно скудна.
Матье М.
2
Это в некоторой степени связано, но я также думаю, что эта идея «функционального цвета» также применима к функциям, которые блокируют ввод от пользователя, например модальные диалоговые окна, окна сообщений, некоторые формы консольного ввода-вывода и т. Д., Которые являются инструментами, которые рамки были с самого начала.
18:30
2
@MatthieuM. C # не имеет одного потока приложения на один поток ОС и никогда не имел. Это очень очевидно, когда вы взаимодействуете с нативным кодом, и особенно очевидно при работе в MS SQL. И, конечно, кооперативные процедуры всегда были возможны (и даже проще async); действительно, это был довольно распространенный шаблон для создания адаптивного пользовательского интерфейса. Это было так же красиво, как Эрланг? Нет. Но это все еще далеко от C :)
Luaan
82

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

max630
источник
27
Ответ так же прост, как это ИМО. Разница между синхронным и асинхронным процессом не является деталью реализации - это семантически иной контракт.
Муравей P
11
@AntP: я не согласен, что это так просто; он появляется на языке C #, но не на языке Go, например. Так что это не присуще асинхронным процессам, это вопрос того, как асинхронные процессы моделируются в данном языке.
Матье М.
1
@MatthieuM. Да, но вы можете использовать asyncметоды в C # для предоставления синхронных контрактов, если хотите. Единственная разница в том, что Go по умолчанию асинхронный, а C # по умолчанию синхронный. asyncдает вам вторую модель программирования - async это абстракция (что она на самом деле делает, зависит от времени выполнения, планировщика задач, контекста синхронизации, реализации ожидающего ...).
Луаан
6

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

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

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

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

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

Однако, как и многие вещи в этой области, это в значительной степени субъективно. Оба подхода имеют свои плюсы и минусы. Microsoft также начала соглашение о добавлении букв, указывающих на тип в начале имени переменной, и «m_», чтобы указать, что это член, что приводит к таким именам переменных, как m_pUser. Я хочу сказать, что даже Microsoft не является непогрешимой и тоже может совершать ошибки.

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

Нил
источник
6
«Во время выполнения преобразовать асинхронный вызов в синхронный тривиально». Я не уверен, что это именно так. В .NET использование .Wait()метода и т.п. может привести к негативным последствиям, а в js, насколько я знаю, это вообще невозможно.
max630
2
@ max630 Я не говорил, что нет одновременных проблем для рассмотрения, но если изначально это была синхронная задача, скорее всего, она не создает взаимоблокировки. Тем не менее, тривиальный не означает «двойной щелчок здесь, чтобы преобразовать в синхронный». В js вы возвращаете экземпляр Promise и вызываете для него функцию решения.
Нил
2
да, это совершенно больно в заднице, чтобы преобразовать асинхронный обратно в синхронизацию
Ewan
4
@Neil В javascript, даже если вы позвоните, Promise.resolve(x)а затем добавите к нему обратные вызовы, эти обратные вызовы не будут выполнены немедленно.
Ник
1
@Neil, если интерфейс предоставляет асинхронный метод, ожидать, что ожидание задачи не приведет к тупику, не является хорошим предположением. Для интерфейса гораздо лучше показать, что он на самом деле синхронен в сигнатуре метода, чем обещание в документации, которое может измениться в более поздней версии.
Карл Уолш
2

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

Это было бы здорово, и никто бы не порекомендовал вам сменить их имена.

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

public class HTTPClient
{
    public HTTPResponse GET()
    {
        //send data
        while(!timedOut)
        {
            //check for response
            if(response) { 
                this.GotResponse(response); 
            }
            this.YouCanWait();
        }
    }

    //tell calling code that they should watch for this event
    public EventHander GotResponse
    //indicate to calling code that they can go and do something else for a bit
    public EventHander YouCanWait;
}

Это те две части информации, которые нужны вызывающему коду для выполнения кода асинхронным способом, который вещи любят Taskи asyncинкапсулируют.

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

Ewan
источник
0

Я остановлюсь на главном вопросе в меньшей степени на C # ness и более обобщенно:

Разве асинхронные / не ждут лучших практик, противоречащих принципам «хорошей архитектуры»?

Я бы сказал, что все зависит от того, какой выбор вы сделаете при разработке своего API и что вы дадите пользователю.

Если вы хотите, чтобы одна функция вашего API была только асинхронной, мало интересует соблюдение соглашения об именах. Просто всегда возвращайте Task <> / Promise <> / Future <> / ... как возвращаемый тип, это самодокументирование. Если он хочет получить синхронизированный ответ, он все равно сможет сделать это, ожидая, но если он всегда это делает, то это образцовый шаблон.

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

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

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

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

Может быть, кто-то еще может поделиться своим опытом с этим, потому что у меня нет опыта работы с такими системами.

Walfrat
источник
2
@Miral Вы использовали «вызов асинхронного метода из метода синхронизации» в обеих возможностях.
Адриан Рагг
@AdrianWragg Так я и сделал; в моем мозгу должно быть состояние гонки. Я исправлю это.
Мирал
Это наоборот; тривиально вызвать асинхронный метод из метода синхронизации, но невозможно вызвать метод синхронизации из асинхронного метода. (И когда все полностью разваливается, это когда кто-то пытается все-таки сделать последнее, что может привести к тупикам.) Так что, если вам нужно было выбрать один, асинхронный по умолчанию - лучший выбор. К сожалению, это также более сложный выбор, потому что асинхронная реализация может вызывать только асинхронные методы.
Мирал
(И под этим я, конечно, имею в виду метод блокирующей синхронизации. Вы можете вызывать что-то, что делает чисто привязанные к ЦП вычисления синхронно из асинхронного метода - хотя вы должны стараться избегать этого, если вы не знаете, что находитесь в рабочем контексте а не контекст пользовательского интерфейса - но блокировка вызовов, которые ждут простоя на блокировке или для ввода-вывода или для другой операции, является плохой идеей.)
Miral