Меня раздражает, как объясняют асинхронность

от автора

Если что такое параллелизм более-менее все разработчики понимают, то объяснение асинхронности через аналогии с кассирами/поварами не ложно, но, как мне кажется, вредно, так как вводит в очень большое заблуждение.
В данной статье я разберу эту проблему на примерах 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 нет, но причем тут синхронизация и почему кандидат должен её упомянуть — я так и не понял 🙂

Объяснение?

Если вы попробуете загуглить, что такое асинхронность и чем она отличается от мультипоточности, то слоп-машина гугла ответит вам так:

Ответ AI Overview гугла на "асинхронность и многопоточность"

Ответ AI Overview гугла на «асинхронность и многопоточность»

Звучит, вроде, корректно, но что-то тут не так. Прямой лжи тут нет.
А потом нам дается аналогия:

Аналогия от AI Overview

Аналогия от AI Overview

Вроде тоже все правильно, но….
Точно!

По этим аналогиям может сложиться впечатление, будто в многопоточности, если вы варите суп, то должны сначала его полностью доварить и не можете переключиться на другую задачу. Да и необходимость синхронизации корутин никто не отменял.
А я напомню, что в доисторические времена (25 лет назад) частенько встречались ЭВМ всего лишь с 1 ядром, при этом про корутины/горутины никто и не слышал. Как же тогда работал код, как машина не сгорала, если одновременно открывалась ICQ, Warcraft 3, Skype и IE?

Скрытый текст

Старые слабенькие компьютеры, конечно, не потянули бы сразу все приложения, но какая-то многозадачность-то все равно же была!

Да через те же самые потоки! Кто вообще сказал, что потоки могут параллелить только CPU-bound? Кто сказал, что нельзя запустить 100 потоков на 1 ядре и переключать IO-bound задачи, «вы ставите чайник на плиту, …, не блокируетесь и начинаете резать овощи».
Вы можете зайти в top/Activity Monitor/диспетчер задач и посмотреть, сколько какой процесс насоздавал потоков.
Например, на моем 8-ядерном маке у одного процесса вообще 524 потока 🙂

Мой Activity Monitor

Мой Activity Monitor

При этом так объясняются различия почти всегда и везде!
Тианголо в документации к FastAPI также объясняет asyncio, только через бургеры, а не супы.

Скриншот из статьи про asycnio

Скриншот из статьи про 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/