Сколько потоков использует `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 для делегирования их выполнения в отдельные потоки или процессы.