Если что такое параллелизм более‑менее все разработчики понимают, то объяснение асинхронности через аналогии с кассирами/поварами не ложно, но, как мне кажется, вредно, так как вводит в очень большое заблуждение.
В данной статье я разберу эту проблему на примерах 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) — им больше не нужно думать вообще про весь ресторан, только про свою зону ответственности.
Конечно, у этой аналогии есть свои изъяны, но она хотя бы показывает разницу между потоками и корутинами — и то и то работает хорошо, утилизирует все доступные ресурсы, позволяет одному исполнителю (ядру) выполнять несколько задач, переключаясь между ними, но корутины просто экономнее.
Почему?
Почему же наши коллеги так часто объясняют асинхронность как‑то неправильно?
По опыту прохождения собесов и обсуждений с коллегами, мне кажется, что причина кроется в количестве терминов. Корутины в разных языках работают по‑разному, где‑то вообще вместо них горутины, а где‑то виртуальные потоки, которые вроде те же самые лайттреды, но другие! Да еще и слово «конкурентность» какое‑то непонятное, вроде часто используется для описания асинхронности, но, как мы выяснили, конкурентность можно реализовать и на потоках и даже на процессах (этого, конечно, делать не надо).
Спасибо за внимание, всех зову поспорить в комменты:)
