Основы asyncio в Python

В предыдущей статье разбирали потоки и процессы. У них общая модель: ОС переключает между «исполнителями». asyncio идёт другим путём: один поток, кооперативная многозадачность. Программа сама помечает места, где можно «отложить» задачу. Эти места обозначаются ключевым словом await.

Для I/O-bound нагрузок asyncio даёт лучшее соотношение производительности и ресурсов: тысячи одновременных соединений на одном потоке без накладных расходов на потоки ОС.

Цикл событий и кооперативная модель

Сердце asyncio — event loop (цикл событий). Он держит список задач, выполняет одну из них, и когда задача доходит до await something_slow(), задача «уступает управление», event loop переключается на следующую готовую задачу. Когда something_slow() завершается, исходная задача снова становится готовой.

Иллюстрация: в центре Event Loop, вокруг три задачи (Task 1 — running, Task 2 — awaiting I/O, Task 3 — ready); стрелки между задачами и event loop показывают передачу управления

Важно: переключение происходит только на await. Никаких прерываний посередине вычисления. Это «кооперативная» многозадачность: задачи договариваются, когда уступать. У такого подхода есть последствие: если задача не делает await (например, считает что-то долго на CPU), весь event loop стоит.

async и await

В Python 3.5 появились ключевые слова для асинхронности:

  • async def — определяет корутину (асинхронную функцию)
  • await — внутри корутины: «дождись завершения этой операции, на время ожидания отпусти управление»
Python 3.13
import asyncio

async def say_hello():
    print("Привет...")
    await asyncio.sleep(1)        # не блокирует поток, отпускает event loop
    print("...мир")

Важный нюанс: вызов say_hello() не запускает корутину. Он создаёт объект-корутину:

Python 3.13
coro = say_hello()
print(type(coro))     # <class 'coroutine'>
# код корутины ещё не выполнен!

Чтобы запустить корутину, нужен event loop.

asyncio.run: точка входа

asyncio.run() запускает event loop, выполняет переданную корутину и закрывает loop:

Python 3.13
import asyncio

async def main():
    print("Запуск")
    await asyncio.sleep(1)
    print("Завершено через 1 секунду")

asyncio.run(main())

asyncio.run() — стандартный способ запустить async-программу из обычного синхронного кода. Внутри одной программы вызывается один раз на верхнем уровне.

Последовательно vs конкурентно

Если просто пишем await подряд, корутины выполняются последовательно, одна за другой:

Python 3.13
import asyncio
import time

async def slow_task(name, delay):
    await asyncio.sleep(delay)
    print(f"{name} готова за {delay}с")

async def main():
    start = time.time()
    await slow_task("A", 2)
    await slow_task("B", 1)
    await slow_task("C", 3)
    print(f"Всего: {time.time() - start:.1f}с")    # ~6с

asyncio.run(main())

Все три задачи могли бы идти параллельно (они только ждут), но мы заставили их идти по очереди: await ждёт завершения текущей. Чтобы запустить конкурентно, используем asyncio.gather():

Python 3.13
async def main():
    start = time.time()
    await asyncio.gather(
        slow_task("A", 2),
        slow_task("B", 1),
        slow_task("C", 3),
    )
    print(f"Всего: {time.time() - start:.1f}с")    # ~3с

asyncio.run(main())

gather() запускает все переданные корутины конкурентно и возвращает список результатов. Общее время равно самой долгой задаче, а не сумме всех. Это и есть суть asyncio для I/O.

Tasks: запуск корутин «в фоне»

Иногда нужно запустить корутину «прямо сейчас», не дожидаясь её, чтобы она работала параллельно с основной логикой. Для этого есть asyncio.create_task():

Python 3.13
import asyncio

async def background_log():
    while True:
        print("heartbeat")
        await asyncio.sleep(1)

async def main():
    task = asyncio.create_task(background_log())
    await asyncio.sleep(3)      # делаем что-то другое
    task.cancel()               # остановили фоновую корутину

asyncio.run(main())

create_task() сразу планирует корутину к выполнению. Возвращает объект Task, у которого есть методы cancel(), done(), result(). По сути Task — это корутина, которую event loop уже запустил и отслеживает: можно проверить статус, забрать результат или отменить её.

Главные правила

  1. Внутри async def любое долгое ожидание — через await. Обычный time.sleep(1) заблокирует весь event loop. Используйте await asyncio.sleep(1).
  2. Хочется конкурентно — asyncio.gather() или asyncio.create_task(). Просто await подряд = последовательно.
  3. CPU-bound в asyncio останавливает всё. Считаете долго? Выносите в run_in_executor (следующая статья) или в multiprocessing.

Проверка понимания

Что такое основная особенность асинхронного программирования в asyncio?


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