Сколько потоков использует `asyncio` для выполнения корутин?

Ответ

В asyncio, стандартной библиотеке Python для асинхронного программирования, все корутины по умолчанию выполняются в одном потоке — основном потоке цикла событий (event loop).

Почему так: Корутины — это не потоки операционной системы, а легковесные сопрограммы. Они реализуют кооперативную многозадачность: вместо того чтобы операционная система переключала контекст между потоками, корутины явно "отдают" управление циклу событий, когда встречают оператор await (например, при ожидании завершения I/O операции). Это позволяет эффективно обрабатывать множество параллельных I/O-bound операций без накладных расходов на создание и переключение между реальными потоками.

Пример: В данном примере две корутины task("A", 2) и task("B", 1) будут выполняться в одном потоке. asyncio.sleep является асинхронной операцией, которая не блокирует поток, позволяя циклу событий переключиться на другую корутину.

import asyncio
import time

async def task(name, delay):
    print(f"[{time.time():.2f}] Корутина '{name}' запущена.")
    await asyncio.sleep(delay) # Не блокирует поток, отдает управление event loop
    print(f"[{time.time():.2f}] Корутина '{name}' завершена.")

async def main():
    print(f"[{time.time():.2f}] Запуск main.")
    await asyncio.gather(
        task("A", 2),
        task("B", 1),
    )
    print(f"[{time.time():.2f}] Завершение main.")

if __name__ == "__main__":
    asyncio.run(main())

Ожидаемый вывод (пример):

[1678886400.00] Запуск main.
[1678886400.00] Корутина 'A' запущена.
[1678886400.00] Корутина 'B' запущена.
[1678886401.00] Корутина 'B' завершена.
[1678886402.00] Корутина 'A' завершена.
[1678886402.00] Завершение main.

Видно, что задачи запускаются почти одновременно и завершаются по мере готовности, не блокируя друг друга.

Когда могут использоваться дополнительные потоки/процессы:

  • loop.run_in_executor(): Для выполнения блокирующих или CPU-bound операций (например, сложных вычислений, работы с диском, вызовов синхронных библиотек), которые могли бы заблокировать весь цикл событий. run_in_executor позволяет вынести такую работу в отдельный поток (используя ThreadPoolExecutor) или процесс (используя ProcessPoolExecutor), не нарушая асинхронность основного цикла.
  • Низкоуровневые операции: В некоторых случаях, особенно на определенных ОС (например, файловый ввод-вывод в Windows), даже асинхронные операции могут внутренне использовать блокирующие вызовы, которые могут быть обернуты в потоки. Однако это детали реализации, обычно скрытые от пользователя.

Вывод: asyncio по своей природе однопоточен и предназначен для эффективной обработки I/O-bound задач. Для CPU-bound или других блокирующих операций следует явно использовать run_in_executor для делегирования их выполнения в отдельные потоки или процессы.

Ответ 18+ 🔞

Так, слушай, про эту асинхронную хуйню в питоне. Есть один фундаментальный пиздец, который всех новичков вгоняет в ступор.

Все эти твои async def, корутины, await — они по умолчанию в одном потоке, блядь, работают! В одном! В основном потоке этого самого цикла событий, event loop'а, ёпта.

А почему? Да потому что это не потоки операционки, это лёгкие сопрограммы, хитрая жопа. Это кооперативная многозадачность, а не та, где система тебя дёргает за яйца. Ты сам, встретив await, говоришь: "Так, я тут посплю, иди, другую корутину покрути". И цикл событий переключается. Всё ради того, чтобы кучу I/O-операций гонять без овердохуища накладных расходов на переключение контекста.

Смотри, вот тебе живой пример, чтоб понятно было, как это всё в одном потоке, но не блокирует друг друга:

import asyncio
import time

async def task(name, delay):
    print(f"[{time.time():.2f}] Корутина '{name}' запущена.")
    await asyncio.sleep(delay) # Не блокирует поток, отдает управление event loop
    print(f"[{time.time():.2f}] Корутина '{name}' завершена.")

async def main():
    print(f"[{time.time():.2f}] Запуск main.")
    await asyncio.gather(
        task("A", 2),
        task("B", 1),
    )
    print(f"[{time.time():.2f}] Завершение main.")

if __name__ == "__main__":
    asyncio.run(main())

Что на выходе будет, ёпта:

[1678886400.00] Запуск main.
[1678886400.00] Корутина 'A' запущена.
[1678886400.00] Корутина 'B' запущена.
[1678886401.00] Корутина 'B' завершена.
[1678886402.00] Корутина 'A' завершена.
[1678886402.00] Завершение main.

Видишь? Обе запустились сразу, 'B' отстрелялась через секунду, 'A' — через две. И всё это в одном потоке, без всяких threading. Красота, да? Пока одна спит на await, другая работает. Волшебство, блядь.

Но! Есть же подводные ебучие камни. Если ты в корутину сунешь какую-нибудь CPU-bound жесть, которая будет циклы гонять, или вызов блокирующей синхронной библиотеки — ты весь event loop нахуй заморозишь! Все остальные корутины встанут колом и будут ждать, пока твой код не отсосёт все ресурсы.

Что делать? А вот для этого и придумали loop.run_in_executor(). Это такой спасательный круг. Хочешь тяжёлые вычисления или работу с диском, которая всё заблокирует? Выноси это в отдельный поток (ThreadPoolExecutor) или, если совсем ядрёно, в отдельный процесс (ProcessPoolExecutor). Цикл событий будет дальше спокойно свою работу делать, а результат тебе потом прилетит.

Так что запомни: asyncio — это для I/O, чтобы тысячи соединений не напрягаясь держать. А если надо что-то посчитать или синхронный говнокод вызвать — run_in_executor, и никаких проблем. Всё просто, как три копейки, если не лезть в дебри с бубном.