Если что такое параллелизм более-менее все разработчики понимают, то объяснение асинхронности через аналогии с кассирами/поварами не ложно, но, как мне кажется, вредно, так как вводит в очень большое заблуждение.
В данной статье я разберу эту проблему на примерах Python и Go и попробую дать свою правильную аналогию.
TL;DR
Асинхронность и многопоточка решают одну и ту же задачу для IO-bound операций — конкурентность, — но отличаются только синтаксисом, экосистемой и производительностью.
Для IO-bound!
Для CPU-bound кода выгоды в асинхронности нет, параллелизм достигается на потоках.
Вопрос?
Как-то раз в интернете я наткнулся на опросник, который используют HR при первом контакте с кандидатом на позицию гофера.
Скрытый текст
Очень надеюсь, что этот опросник — фейк и его никто никогда не использовал
Один из вопросов в нем звучал так:
«Если к функциям дописать ключевое слово go, они будут работать асинхронно?»
Для не гоферов
Ключевое слово go вызывает функцию в отдельной горутине, очень грубо говоря, как питоновское asyncio.create_task
Странная формулировка, подумал я, но чисто технически ответ же — «да»? Может вопрос с подвохом и горутину надо заджойнить (добавить sync.WaitGroup)?
Или вопрос про GOMAXPROCS? Если разрешить использовать только 1 OS поток, то горутина, конечно, будет работать асинхронно, но если 2 — может и параллельно.
Или вопрос про то, что будет с горутинами, что еще не завершили работу, но основная горутина (main) завершилась?
Оказывается, ответ таков:
«во-первых нет, в Go нет асинхронности, во-вторых нужна синхронизация»
Отвратительность этого ответа (и вопроса) я даже не хочу обсуждать, но давайте остановимся на асинхронности. Разве ее нет в Go? Да вроде есть, если определять ее как «переключение между лёгкими задачами без блокировки OS потоков», то в Go, конечно, она есть. Иначе как у нас могла бы быть конкурентность при GOMAXPROCS=1? На OS потоках с блокировками что ли, хахаха?
Скорее всего авторы имели в виду синтаксис JS/Python с async/await/promise, его и правда в Go нет, но причем тут синхронизация и почему кандидат должен её упомянуть — я так и не понял 🙂
Объяснение?
Если вы попробуете загуглить, что такое асинхронность и чем она отличается от мультипоточности, то слоп-машина гугла ответит вам так:
Звучит, вроде, корректно, но что-то тут не так. Прямой лжи тут нет.
А потом нам дается аналогия:
Вроде тоже все правильно, но….
Точно!
По этим аналогиям может сложиться впечатление, будто в многопоточности, если вы варите суп, то должны сначала его полностью доварить и не можете переключиться на другую задачу. Да и необходимость синхронизации корутин никто не отменял.
А я напомню, что в доисторические времена (25 лет назад) частенько встречались ЭВМ всего лишь с 1 ядром, при этом про корутины/горутины никто и не слышал. Как же тогда работал код, как машина не сгорала, если одновременно открывалась ICQ, Warcraft 3, Skype и IE?
Скрытый текст
Старые слабенькие компьютеры, конечно, не потянули бы сразу все приложения, но какая-то многозадачность-то все равно же была!
Да через те же самые потоки! Кто вообще сказал, что потоки могут параллелить только CPU-bound? Кто сказал, что нельзя запустить 100 потоков на 1 ядре и переключать IO-bound задачи, «вы ставите чайник на плиту, …, не блокируетесь и начинаете резать овощи».
Вы можете зайти в top/Activity Monitor/диспетчер задач и посмотреть, сколько какой процесс насоздавал потоков.
Например, на моем 8-ядерном маке у одного процесса вообще 524 потока 🙂
При этом так объясняются различия почти всегда и везде!
Тианголо в документации к FastAPI также объясняет asyncio, только через бургеры, а не супы.
asycnioГде-то в интернетах есть еще такая картинка:

Опять же, она не противоречит реальности, внутри одного потока и правда в моменте может выполняться только 1 задача, корутины же позволяют переключаться между задачами внутри одного потока.
Но визуализация, почему-то, игнорирует тот факт, что OS самостоятельно переключает потоки, при этом OS также следит за блокировками (IO-bound операциями) и также паркует потоки.
Более корректной можно было бы визуализировать многопоточку так:
Потоки могут работать как в параллель, OS может кидать их с ядра процессора на ядро, при этом они могут и переключаться!
А как в реальности?
Термины
Процесс
Это сущность на уровне OS, имеет свою область памяти, в которую не могут ходить другие процессы и, собственно, код, который выполняется в процессе. Каждый процесс имеет минимум 1 поток на котором и работает код.
Дополнение
Конечно, процессы имеют и больше свойств: разрешения, файловые дескрипторы, идентификатор и тд, но в данной статье это не так важно.
Поток/тред
Это просто последовательность инструкций, которые нужно выполнить процессору. Если поток работает слишком долго или натыкается на IO-bound операцию, то OS может его остановить, выгрузить и запустить другой поток.
Корутина
Сущность на уровне языка программирования. Архитектура, реализация и наименование дрейфуют от языка к языку. Например, в Go это горутины, в Джаве — виртуальные треды и тд и тп. Но логика почти всегда одна и та же — корутины это те же самые потоки, но легковесные. Иногда корутинами называют останавливаемыми функциями (в Python), которые планировщик/event loop паркует и запускает, по сути как те же потоки.
Параллелизм
Свойство программы выполнять код одновременно. Не переключаться между задачами, а именно одновременно работать. Параллелизм достигается за счет многоядерности процессора и потоков: чтобы запустить N задач параллельно надо иметь хотя бы N-ядерный процессор и N потоков.
Асинхронность
Метод выполнения задач, при котором IO bound операции не блокируют основной поток. Иными словами в современном мире это просто выполнение задач в корутинах/горутинах/виртуальных потоках.
Конкурентность
Самый непонятный, как мне кажется, термин. По сути это свойство программы выполнять задачи с переключениями, при этом не обязательно через корутины/горутины. Получается, что код на Python будет конкурентным как на корутинах, так и на потоках.
Конкретнее
Разберем на питоне.
Реальность такова, что корутины выполняют ту же функцию, что и OS потоки — конкурентное выполнение кода. Иными словами, через них можно запустить 2 задачи и переключаться между ними.
GIL
В Python из-за GIL параллельности, конечно, не получится достичь на потоках или корутинах, но вот переключение есть в обоих решениях. В Python 3.14 GIL можно отключить, но это уже совсем другая история.
Но в чем же отличие корутин от OS потоков? Зачем их использовать, если и то, и то дает один и тот же результат?
Корутины весят мало — 1-5 KB ОЗУ.
Так мало?
Имеется в виду сам объект корутины именно в Python. Со стеком, задачей, контекстом и всем остальным она, конечно, будет больше, но все равно намного меньше потока, который занимает мегабайты.
Переключение контекста (то есть остановка выполнения одной корутины на ядре и запуск другой на том же ядре) в корутинах быстрее — так как корутины управляются рантаймом языка, а не OS, переключение происходит в User Space, а не Kernel Space, что, банально, требует меньше операций.
Очень важно: сам факт того, что корутина не блокирует поток, в котором она выполняется, нам важен только потому, что мы не хотим лишних переключений контекстов, потому что любая блокировка может вызвать переключение в Kernel Space. При этом сами корутины, так же как и OS потоки, умеют блокироваться и переключаться 🙂
Интересный нюанс — то, что было описано выше, релевантно и для других языков программирования. Горутины в Go также весят мало (2-4 KB), переключаются быстро и также используют kqueue/epoll для неблокирующих обращений к OS. Отличий, конечно, тоже много, например, горутины умеют и в параллелизм.
Нюанс
В Go в принципе нет доступа к управлению OS потоками, по сути разработчик может создавать только горутины, а они умеют как в параллельность, так и в асинхронность.
Важно внести небольшую архитектурную ясность: любой процесс всегда запускает хотя бы один поток. При этом параллелизм, то есть единомоментное выполнение кода на N ядрах, возможен только при создании нескольких потоков. Поэтому, например, если вы в Go запустите 10 CPU-bound функций в 10 горутинах в системе с 10 ядрами CPU, то у вас будет задействовано 10 OS потоков и эти 10 горутин будут работать на своих потоках.
Уточнение
Теоретически, конечно, может создаться больше потоков, например, при syscall, CGO-вызовах и тд.
Если вернуться к Python, то я хочу позволить себе очень громкий тезис:
Асинхронность и мультипоточка в Python решают одну и ту же задачу, но отличаются только синтаксисом, экосистемой и производительностью.
Что?
Предположим, вы пишете бекенд на FastAPI (веб-фреймворк) и ходите в Redis через redis-py и Postgres через psycopg3. Все 3 библиотеки, что я описал, умеют как в asyncio, так и в многопоточку. Вы можете написать функционально идентичный код, при этом синтаксически вам нужно будет лишь в нескольких местах поменять конфиги и проставить async и await в нужных местах. Флоу самого кода же будет идентичным.
# На потоках@app.post("/save")def save(kv: KV) -> None: redis_client.set(kv.key, kv.value) # На корутинах@app.post("/asave")async def asave(kv: KV) -> None: await redis_aclient.set(kv.key, kv.value)
Обе функции save и asave конкурентны: если 10 пользователей отправят единомоментно 10 запросов POST /save, FastAPI возьмёт 10 OS потоков и обработает запросы. Аналогично с POST /asave, только FastAPI запустит 10 Python корутин.
Внимательный читатель
Внимательный читатель, конечно, может предъявить, что, например, в asyncio есть cancel, а в threading — нет, а еще исключения автоматически в asyncio не пробрасываются. Но это как раз синтаксическое отличие asyncio от threading.
Также можно сказать, что asyncio красит функции, из sync функции просто не вызовещь async функцию, а sync функцию может заблокировать поток, если её неправильно вызывать из asynс. Но это и есть экосистемность.
А в других языках?
Как я уже сказал выше, в Go в принципе нет доступа к OS потокам, при этом в других языках, типа C#, Java, Kotlin, данный тезис на удивление верен (частично). Каких-то выгод, кроме производительности, корутины дают редко.
Аналогия
Давайте попробуем придумать более корректную аналогию.
Одноядерный процессор
Представьте: есть ресторан, в котором работает один сотрудник (это у нас аналог ядра процессора), при этом он немного глупенький сотрудник, который сам не умеет переключаться между задачами (потоками).
Вы заказываете борщ, а он:
-
Берет заказ
-
Ставит бульон на готовку
-
Ждет приготовления бульона: пока тот готовится — просто смотрит
-
Снимает бульон
-
Нарезает овощи
-
Нарезает хлеб
-
Подает еду
Иными словами — делает все последовательно, без переключений и очень неэффективно!
Одноядерный процессор с OS
Добавим ему менеджера.
Теперь у него есть начальник (планировщик OS), который постоянно висит у него над душой и раз в минуту может заставить его делать другую задачу:
«Поставил готовить бульон и больше делать нечего (заблокировался)? Сходи подмети пол!»
Уже лучше. Это обычный мультитрединг на одном ядре.
Пояснение про время
Только в Linux планировщик переключает потоки, конечно, чаще, чем раз в минуту. Значения могут разниться в зависимости от настроек, версий и тд и тп, но будет порядка ~5 мс. То есть имея 10 потоков на одном ядре, каждый из которых выполняет CPU-bound операции, переключение будет происходить каждые 5 мс. Опять же, это число — не константа и есть шедулеры, что выставляют его динамически, например, исходя из приоритетов.
Многоядерный процессор с OS
А теперь нанимаем много сотрудников (многоядерный процессор), и пусть менеджер также следит за всеми: кто-то пошел еду разнести, кто-то бульон варит, кто-то убирается.
Но наш менеджер тоже немного глупый, он не знает, что дорого официантов постоянно переключать на задачи поваров, потому что тогда им приходится менять одежду, мыть руки, а вспоминать как и что готовить — медленно!
Многоядерный процессор с OS и корутинами
Поэтому мы самим сотрудникам даем маленькие быстрые задачки, внутри которых можно переключаться.
Возьмем несколько сотрудников и выдадим им метазадачу: быть поварами (поток). И внутри этой большой задачи есть много маленьких (корутины):
-
Принять заказ
-
Сделать заготовки
-
Приготовить бульон
-
Нарезать хлеб
-
И тд и тп
Пусть официанты теперь разносят еду, принимают заказы и взимают плату, а повара — только готовят. При этом в готовке они сами переключаются между задачами, отдельный менеджер им не нужен! (асинхронность)
Таким образом, мы сняли загрузку с менеджера (OS), ускорили переключение между задачами (корутинами) и уменьшили когнитивную загрузку работников (RAM) — им больше не нужно думать вообще про весь ресторан, только про свою зону ответственности.
Конечно, у этой аналогии есть свои изъяны, но она хотя бы показывает разницу между потоками и корутинами — и то и то работает хорошо, утилизирует все доступные ресурсы, позволяет одному исполнителю (ядру) выполнять несколько задач, переключаясь между ними, но корутины просто экономнее.
Почему?
Почему же наши коллеги так часто объясняют асинхронность как-то неправильно?
По опыту прохождения собесов и обсуждений с коллегами, мне кажется, что причина кроется в количестве терминов. Корутины в разных языках работают по-разному, где-то вообще вместо них горутины, а где-то виртуальные потоки, которые вроде те же самые лайттреды, но другие! Да еще и слово «конкурентность» какое-то непонятное, вроде часто используется для описания асинхронности, но, как мы выяснили, конкурентность можно реализовать и на потоках и даже на процессах (этого, конечно, делать не надо).
Спасибо за внимание, всех зову поспорить в комменты 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/1033914/