Как работает асинхронный код в Python: конкурентный или параллельный, в одном или нескольких потоках?
Что проверяют
Понимаете ли вы, что asyncio даёт конкурентность, а не параллелизм, и где заканчиваются его возможности. Следом обычно показывают код с двумя await подряд и спрашивают, сколько он будет выполняться, или просят объяснить, что случится, если внутри корутины вызвать requests.get.
Как отвечать
Короткий ответ: конкурентно, в одном потоке, на одном цикле событий.
- цикл событий: один на поток. Держит очередь готовых задач и через
selectors(epoll в Linux, kqueue в macOS) ждёт готовности сокетов. На Windows по умолчанию работает Proactor поверх IOCP; - кооперативная многозадачность: задача отдаёт управление только в
awaitна том, что реально приостанавливается. Между двумя такими точками код выполняется непрерывно, остальные задачи ждут; - конкурентность: несколько задач продвигаются, пока другие ждут ввода-вывода, но в каждый момент исполняется только одна;
- параллелизм: для CPU нужны процессы,
ProcessPoolExecutorчерезloop.run_in_executor. Потоки упираются в GIL, исключение это free-threaded сборка CPython, которая в 3.14 перестала быть экспериментальной, но остаётся отдельной опцией; - мост к блокирующему коду:
asyncio.to_thread(с 3.9) уносит синхронный вызов в пул потоков. Этим же пулом asyncio по умолчанию пользуется дляgetaddrinfo, так что «строго один поток» не совсем правда.
Покажите, как получить конкурентность на практике: await a(); await b() выполнится последовательно, а asyncio.gather(a(), b()) или asyncio.TaskGroup (с 3.11) запустят обе задачи сразу, и общее время будет равно самой долгой.
Где спотыкаются
Пишут await подряд и уверены, что получили параллельную загрузку. Корутина начинает работать, только когда её дождались или обернули в задачу.
Вызывают внутри корутины блокирующий код: time.sleep, requests, синхронный драйвер базы, тяжёлый разбор JSON. Замирает весь цикл. Режим отладки asyncio.run(main(), debug=True) предупреждает о колбэках дольше 100 мс.
Вызывают asyncio.create_task и не сохраняют ссылку на задачу. Цикл держит только слабую ссылку, и задачу может забрать сборщик мусора до завершения, документация прямо об этом предупреждает.