В последние время, я выступал на мероприятиях и отвечал на вопрос аудитории между моими выступлениями о Асинхронном программировании, я обнаружил что некоторые люди путали многопоточное и асинхронное программирование, а некоторые говорили, что это одно и тоже. Итак, я решил разъяснить эти термины и добавить еще одно понятие Параллелизм. Здесь есть две концепции и обе они совершенно разные, первая синхронное и асинхронное программирование и вторая – однопоточные и многопоточные приложения. Каждая программная модель (синхронная или асинхронная) может работать в однопоточной и многопоточной среде. Давайте обсудим их подробно.
Синхронная программная модель – это программная модель, когда потоку назначается одна задача и начинается выполнение. Когда завершено выполнение задачи тогда появляется возможность заняться другой задачей. В этой модели невозможно останавливать выполнение задачи чтобы в промежутке выполнить другую задачу. Давайте обсудим как эта модель работает в одно и многопоточном сценарии.
Однопоточность – если мы имеем несколько задач, которые надлежит выполнить, и текущая система предоставляет один поток, который может работать со всеми задачами, то он берет поочередно одну за другой и процесс выглядит так:

Здесь мы видим, что мы имеем поток (Поток 1) и 4 задачи, которые необходимо выполнить. Поток начинает выполнять поочередно одну за одной и выполняет их все. (Порядок, в котором задачи выполняются не влияет на общее выполнение, у нас может быть другой алгоритм, который может определять приоритеты задач.
Многопоточность – в этом сценарии, мы использовали много потоков, которые могут брать задачи и приступать к работе с ними. У нас есть пулы потоков (новые потоки также создаются, основываясь на потребности и доступности ресурсов) и множество задач. Итак, поток может работать вот так:

Здесь мы можем видеть, что у нас есть 4 потока и столько же задач для выполнения, и каждый поток начинает работать с ними. Это идеальный сценарий, но в обычных условиях мы используем большее количество задач чем количество доступных потоков, таким образом освободившийся поток получает другое задание. Как уже говорилось создание нового потока не происходит каждый раз потому что для этого требуются системные ресурсы такие как процессор, память и начальное количество потоков должно быть определенным.
Теперь давайте поговорим о Асинхронной модели и как она ведет себя в одно и многопоточной среде.
Асинхронная модель программирования – в отличии от синхронной программной модели, здесь поток однажды начав выполнение задачи может приостановить выполнение сохранив текущее состояние и между тем начать выполнение другой задачи.

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

Здесь мы можем видеть, что одна и та же задача скажем Т4, Т5, Т6 … обрабатывается несколькими потоками. Это красота этого сценария. Как мы можем видеть, что задача Т4 начала выполнение первой Потоком 1 и завершен Потоком 2. Подобным образом задча Т6 выполнена Потоком 2, Потоком 3 и Потоком 4. Это демонстрирует максимальное использование потоков.
Итак, до сих пор мы обсудили 4 сценария:
- Синхронный однопоточный
- Синхронный многопоточный
- Асинхронный однопоточный
- Асинхронный многопоточный
Давайте обсудим еще один термин – параллелизм.
Параллелизм
Проще говоря параллелизм способ обработки множественных запросом одновременно. Так как мы обсуждали два сценария когда обрабатывались множественные запросы, многопоточное программирование и асинхронная модель (одно и многопоточная). В случае асинхронной модели будь она однопоточной или многопоточной, в то время, когда выполняются множество задач, некоторые из них приостанавливаются, а некоторые выполняются. Существует много особенностей, но это выходит за рамки этой публикации.
Как обсуждалось ранее новая эпоха за асинхронным программированием. Почему это так важно?
Преимущества асинхронного программирования
Существует две вещи очень важные для каждого приложения – удобство использования и производительность. Удобство использования потому что пользователь нажав кнопку чтобы сохранить некоторые данные что в свою очередь требует выполнения множества задач таких как чтение и заполнение данных во внутреннем объекте, установление соединения с SQL и сохранения его там. В свою очередь SQL запускается на другой машине в сети и работает под другим процессом, это может потребовать много время. Таким образом если запрос обрабатывается одним процессом экран будет находится в зависшем состоянии до тех пор, пока процесс не завершится. Вот почему сегодня многие приложения и фреймворки полностью полагаются на асинхронную модель.
Производительность приложения и системы также очень важны. Было замечено в то время как выполняется запрос, около 70-80% из них попадают в ожидании зависимых задач. Таким образом, это может быть максимально использовано в асинхронном программирование, где, как только задача передается другому потоку (например, SQL), текущий поток сохраняет состояние и доступен для выполнения другого процесса, а когда задача sql завершается, любой поток, который является свободным, может заняться этой задачей.
Асинхронность в ASP.NET
Асинхронность в ASP.NET может стать большим стимулом для повышения производительности вашего приложения. Вот, как IIS обрабатывает запрос:

Когда запрос получен IIS, он берет поток из пула потоков CLR (IIS не имеет какого-либо пула потоков, а сам вместо этого использует пул потоков CLR) и назначает его ему, который далее обрабатывает запрос. Поскольку количество потоков ограничено, и новые могут быть созданы с определенным пределом, тогда если поток будет находится большую часть времени в состоянии ожидания, то это сильно ударит по вашему серверу, вы можете предположить, что это реальность. Но если вы пишете асинхронный код (который теперь становится очень простым и может быть написан почти аналогично синхронному при использовании новых ключевых слов async / await), то он будет работать намного быстрее, и пропускная способность вашего сервера значительно возрастет, потому что вместо ожидания какого-нибудь завершения, он будет доступен пулу потоков, для нового запроса. Если приложение имеет множество зависимостей и длительный процесс выполнения, то для этого приложения асинхронное программирование будет не меньшем благом.
Итак, теперь мы поняли разницу многопоточного, асинхронного программирования и преимущества, которые мы можем получить, используя асинхронную модель программирования.
Комментарии (44)
IL_Agent
09.09.2017 20:01Отличие асинхронности от многопоточности хорошо проилюстрюрованно. А вот абзац про параллелизм написан сумбурно и с ошибками.
AndreyRubankov
10.09.2017 12:15+1И снова статья, где автор преувеличивает недостатки синхронной модели и скрывает недостатки асинхронной.
Во-первых, графики, они построены так, чтобы казалось: «Воу! Асинхронная модель выполняет больше задач!», а по факту, там просто какие-то кубики без привязки ко времени и размерам задачи. Классический маркетинговый прием.
Во-вторых, возьмем к примеру цитату:
Здесь мы можем видеть, что одна и та же задача скажем Т4, Т5, Т6 … обрабатывается несколькими потоками. Это красота этого сценария. Как мы можем видеть, что задача Т4 начала выполнение первой Потоком 1 и завершен Потоком 2. Подобным образом задча Т6 выполнена Потоком 2, Потоком 3 и Потоком 4. Это демонстрирует максимальное использование потоков.
Автор рассказывает, как же это круто, что 4 потока обрабатывают один запрос, но ни слова не говорит про синхронизацию этих 4х потоков, чтобы они могли работать с shared state задачи.
т.е. на графике, между каждым кубиком нужно добавить еще добавить прослойку из задержек на синхронизацию состояний.
Так же автор ничего не сказал про то, что состояния должны быть «сохранены» в неком объекте для continuation (продолжения выполнения через время).
Доля правды в статье есть, асинхронность – это хороший подход, но не на столько хороший, как его расхваливает автор.AndreyRubankov
10.09.2017 14:03Распишу пример в цифрах:
Задача занимает 1000 ед. времени.
Задача может быть разделена на 4 подзадачи по 250 ед. времени.
Синхронизация «железных потоков» 10 ед. времени.
Сохранение состояния континуации 1 ед. времени (допустим она сверхлегкая).
Есть 4 потока и 8 задач.
вариант1. Каждая подзадача зависит от предыдущей и не может быть выполнена в параллели.
(подготовить запрос в базу, сходить в базу, обработать результат, отдать наружу)
вариант2. Каждая подзадача независима и может быть выполнена в параллели.
(проксировать запрос на 4 других сервиса, и больше ничего не делать)
вариани3. Каждая 2 подзадачи независимы, 2 – зависимы.
(подготовить 2 запроса, отправить 2 запроса в 2 разных системы, смержить ответы и отдать наружу)
«Синхронная» многопоточная модель:
ход выполнения: 2 задачи + 2 синхронизации
8 задач = 1000 + 10 + 1000 + 10 = 2020 ед. времени (в каждом потоке).
1 задача будет выполнена за 1010 ед. времени.
«Асинхронная» многопоточная модель:
#1: Каждая подзадача зависит от предыдущей и не может быть выполнена в параллели.
ход выполнения: 8 подзадач + 8 сохранений состояния + 8 синхронизаций.
8 задач = (8 * 250) + (8 * 1) + (8 * 10) = 2088 е.д. времени.
1 задача будет выполнена за: (4 * 250) + (4 * 1) + (4 * 10) = 1044 ед. времени
#2: Каждая подзадача независима и может быть выполнена в параллели.
ход выполнения: 8 подзадач + 8 сохранений состояния + 8 синхронизаций.
8 задач = (8 * 250) + (8 * 1) + (8 * 10) = 2088 е.д. времени.
1 задача будет выполнена за: (1 * 250) + (1 * 1) + (1 * 10) = 261 ед. времени
#3: Каждая 2 подзадачи независимы, 2 – зависимы.
ход выполнения: 8 подзадач + 8 сохранений состояния + 8 синхронизаций.
8 задач = (8 * 250) + (8 * 1) + (8 * 10) = 2088 е.д. времени.
1 задача будет выполнена за: (3 * 250) + (3 * 1) + (3 * 10) = 788 ед. времени
*но тут все на много интереснее: этот вариант не может быть идеально положен на таймлайн потоков, потому 3-4 задачи из 8 будет раскиданы по таймлайну и выполнены в среднем за:
(5 * 250) + (5 * 1) + (5 * 10) = 1305 ед. времени, на ~25% дольше чем в синхронной модели.
Подводя итог:
— Синхронная модель = стабильность и предсказуемость по времени обработки и отдачи результата.
— Асинхронная модель = менее предсказуема в поведении и необходимо изучать среду в которой будет использована данная модель и требования к системе.
В идеальном варианте (#2) такой подход может дать в ~3.5 раза больше скорость обработки. Но таких задач на практике крайне мало, если есть вообще.
В более реальном случае (#1), такой подход может даже стабильно замедлить систему.
Или сделать ее нестабильной (#3) – когда все зависит от планировщика подзадач и часть задач может отдаваться на 25% быстрее, а часть на 25% медленнее, чем в синхронной модели.
ps: в примере #3, если нагрузка будет не сильно плотной, то система получит стабильный прирост на ~25%, если же нагрузка на систему возрастет, то система станет менее стабильной и может перейти даже в разряд деградации производительности. Потому, в этом случае, нужно делать замеры и подстраиваться под возрастающую нагрузку заблаговременно.
pps: данные примеры – это крайне упрощенная теория, а не реальные исследования. В реальном мире все на много сложнее и сильно может отличаться от приведенных цифр!lair
10.09.2017 15:46Вот поэтому и говорят, что асинхрония в первую очередь выгодна для IO-bound задач, а не вычислительных.
AndreyRubankov
10.09.2017 17:23Да именно, и я прекрасно это понимаю.
Но к сожалению, очень многие этого не понимают. А подобные статьи лишь усиливают мнение, что Асинхронность – это идеальный подход без недостатков.
В статье явно прослеживается посыл: «синхронность – плохо, асинхронность – хорошо. точка.»
mrigi
10.09.2017 12:15-1Важно помнить, что .net пытается быть умнее чем ему следовало бы. По умолчанию, если я не ошибаюсь, количество одновременных потоков на пуле равно количеству ядер процессора. И вот ваша супер-пупер параллельная программа на дефолтных настройках может вести себя совсем по разному на разных процессорах. Да, это всё решаемо через конфиги, но зачем усложнять? Если физических ядер нехватает, не значит что не нужно создавать новые потоки когда это напрямую говорит разработчик.
Похожая история с параллельными соединениями того же HttpClient или сервисов. Например вы хотите асинхронно в 10 запросов выкачать что-то с какого-то сайта. Всё будет работать, но сильно медленее чем вам хотелось бы. И вот вы убиваете не один час дебажа свой проект и проклиная криворуких разработчиков того сайта, который медленно качается. А может даже махаете рукой и пускаете в production. Но потом оказывается, что на сетевые запросы тоже пул и вы по умолчанию зачем-то ограничены 2-мя соединениями на сайт. Это тоже решаемо, но кто их просил? Спасибо разработчикам дотнета за столь очевидную функциональность!
Обычно если с инструментами приходится бороться, то это записывают в минус инструментам.mayorovp
10.09.2017 14:54+1Если физических ядер нехватает, не значит что не нужно создавать новые потоки когда это напрямую говорит разработчик.
Когда программисту нужен новый поток — он его создает через
new Thread
. И никто не ограничивает число потоков созданных этим способом.
Если же разработчик использует другие инструменты — то он не "непрямую говорит", а завязывается на детали реализации. Ну и кто ему злобный буратина при этом?
mrigi
10.09.2017 17:52-2«Другие инструменты» продвигаются компанией разработчиком фреймворка как «best practices». Действительно, зачем им следовать. Особенно новым разработчикам.
Да и с new Thread немного повезло, в те далёкие времена видно вещества не так торкали разработчиков .net. А вот создание «напрямую» new HttpClient абсолютно ничем не поможет. Всё равно будет ограничение на количество параллельных сетевых соединений. «Детали реализации» спрятаны где-то глубоко в виде анального зонда. Кому такой заднеприводный подход доставляет удовольствие, могут быть радостными буратинами. Каждому своё.lair
10.09.2017 18:17+1«Другие инструменты» продвигаются компанией разработчиком фреймворка как «best practices».
Ну так в тех же best practices и объясняется, как эти инструменты работают.
lair
10.09.2017 18:20+1на сетевые запросы тоже пул и вы по умолчанию зачем-то ограничены 2-мя соединениями на сайт.
Ну как "зачем-то"...
A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy.
mrigi
10.09.2017 18:45+1RFC2616 obsolete уже далеко не один год. И это условное ограничение, которое и так носило больше рекомендательный характер, сто лет в обед как убрали. От сетевого примитива подобное поведение не ожидается вообще, так что кроме как подводным камнем подобное не назовёшь. Тут просто нечего защищать и оправдывать.
lair
10.09.2017 18:53+1Ожидания — вещь хрупкая и неожиданная, люди таких разных вещей ожидают или не ожидают… вот то, что в документации на видном месте этого нет — это правда печаль.
(В той же документации, кстати, неоднократно сказано "не надо создавать новые HttpClient, неполезно это".)
vitaliy91
Использование async/await действительно увеличит пропускную способность сервера, но у асинхронной машины есть накладные расходы которые немного увеличат время выполнения операции. Проще говоря если у вас не несколько сотен запросов в секунду, быстрее будет работать синхронный код.
lair
… а будет ли это критично, если у вас не несколько сотен запросов в секунду?
vitaliy91
Синхронный код начнет проигрывать когда начнет истощаться пул потоков. Оценив будет ли это происходить в вашей среде исполнения можно выбирать ту или иную модель кода. Кроме того синхронный код немного чище.
babylon
Можно сделать рассинхронизацию так, что вы не поймете когда начнёт "наполняться пул потоков". Собственно правильные диспетчеры тасков так и работают перераспределяя нагрузку по ядрам.
lair
… а когда все ядра заняты?
dmitry_dvm
А потом, в случае роста, радостно переписывать
Чище за счет отсутствия слов async/await?
vitaliy91
Это сугубо ваше дело как писать изначально. Чище за счет отсутствия слов async/await, необходимости оборачивать каждый результат в Task<> (ключевое слово var лучше не использую где возвращаемый тип не очевиден). Также пропадает необходимость писать ConfigureAwait(false) для каждой асинхронной задачи. Пропадает необходимость дописывать суффикс Async к названию метода как это требует MSDN
mayorovp
ConfigureAwait(false)
для каждой задачи писать и так не обязательно.vitaliy91
Обязательно если вы пишете библиотеку
mayorovp
Если вы про взаимоблокировки — то есть более красивые способы их избежать. Если про что-то другое — то жду аргументов.
lair
Какие, кстати? Я бы с удовольствием почитал.
mayorovp
Task.Run(...)
await ContextSwitcher.SwitchToBackground();
(нестандартное решение, но широко распространенное)Task.Wait
илиTask<>.Result
Первые два решения подходят для библиотек. Необходимость третьего можно прописать в документации на библиотеку.
lair
Спасибо.
(третье мне точно не подходит, я людям не верю)
lair
Нет, обязательно тогда, когда вы знаете, что кусок после
await
можно не выполнять в том же синхронизационном контексте. Это не зависит от "библиотека-не библиотека".Да и то, на самом деле, не "обязательно", а желательно.
vitaliy91
Если вы пишете библиотеку не только для себя то вы не можете знать где она будет работать. Если контекст синхронизации вам не нужен то необходимо его отпустить
lair
Нет никакого "необходимо". Это правило хорошего тона по отношению к людям, которые будут использовать вашу библиотеку неправильно.
Особенно смешно это "необходимо" выглядит в ситуации, когда после
await
вызывается код, который вы не контролируете (например, переданный делегат или вброшенный сервис).vitaliy91
Эту рекомендацию я взял из Best Practice и всегда ей придерживаюсь. Если вы вызываете код который вы не контролируете то очевидно контекст синхронизации вам нужен, просто восстановите его перед вызовом делегата. Но до тех пор пока контекст вам не нужен, зачем его дергать после каждого await?
Вот пример с делегатом:
Вот как я бы это сделал:
mayorovp
Не вижу в вашем коде
ConfigureAwait(false)
. Видимо, писать его и не настолько обязательно :-)vitaliy91
Вы зря придираетесь.
mayorovp
Я не придираюсь, а напоминаю вам, что исходно вы говорили об обязательности этой конструкции. Если вы признаете, что той же цели можно достичь альтернативными методами — то о чем вообще спор-то?
vitaliy91
Последнее я говорил
Не столь важно как вы это сделаете, через ConfigureAwait или другим способом. Я лишь хочу показать на сколько это важно. К примеру возьму я библиотеку из nuget для WinForm и пока выполняется асинхронная функция она не должна прерывать UI без необходимости. Или еще хуже: может произойти дедлок если UI синхронно ждет завершение этого таска.
lair
Ну пока в качестве доказательства важности вы только ссылаетесь на Best Practices.
Вообще-то, первое, что нужно сделать при вызове асинхронной функции из UI — это вызывать ее на контексте, отличном от UI-ного. Не надо надеяться, что функция внутри сделает это сама.
vitaliy91
То-есть всегда писать так?
mayorovp
Где я писал про "всегда"?
lair
Если этот код внутри UI-обработчика, и вы хотите полностью отзывчивый UI, то да. Но зачем у вас обращение к HTTP-клиенту внутри UI-обработчика?
mayorovp
Кстати, я бы этот код написал чуть проще:
lair
Это все же рекомендация, а не обязательство.
… вот вы и усложнили код безо всякой на то необходимости.
lair
Вопрос не в том, когда один будет проигрывать другому, а в том, когда потери будут заметны.
Это зависит от того, какой интерфейс у следующего слоя. Если уже асинхронный, то нет, не чище.
Lofer
Недавно искал причину, как раз порождающуюю такую проблему.
очень корректное высказывание.Так что
По нашим тестам получилось что «цена» асинхронности может составлять до 30%, или давать выйгрыш в 10% относительно «эталонного» синхронного исполнения. -10% +30% времени.
Основная проблема — соотношение создания «новых» задач со временем ожидания результата от уже запущенных. В худшем сценарии просто выжирался пул потоков, а после плавно возвращались результаты, но долго :) Наиболее оптимальный сценарий оказался, если скорость создания задач была +- равна скорости получения результатов, тогда количество потоков равномерно.
Что в принципе и так написано в любой книжке: выравнивайте нагрузку
lair
Я не очень понимаю, каким образом асинхронная машина может давать накладные расходы, пропорциональные времени запроса (а не количеству точек асинхронии).
Lofer
А вот это другой очень и очень интересный вопрос. Есть пару подозрений, как освобожусь — проверю.
Там уже выходит за границу нашего приложения. а где-то «на границе» происходит что-то любопытное