2025, Sep 24 13:17

Как работает планирование задач в asyncio: цикл событий и жадные задачи

Разбираем, когда и почему задачи asyncio начинают выполняться: create_task, await, цикл событий, порядок запуска. Плюс жадный режим в Python 3.12+

Понимание того, когда задачи asyncio на самом деле начинают выполняться, — это та самая деталь, из‑за которой конкурентный код может казаться непредсказуемым. Давайте разберём минимальный пример, посмотрим, что делает цикл событий, и проясним, почему первым вы видите сообщение “Do something with 1...”, хотя сначала вы ожидаете задачу 2.

Постановка задачи

Рассмотрим эту программу. Она поднимает две задачи, затем ожидает вторую, а уже после — первую. Наблюдаемое поведение и вызывает путаницу.

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))
    out_b = await job_b
    print("Task 2 fully completed")
    out_a = await job_a
    print("Task 1 fully completed")
    return [out_a, out_b]

outputs = asyncio.run(orchestrate())
print(outputs)

Результат выполнения:

Do something with 1...
Do something with 2...
Done with 1
Done with 2
Task 2 fully completed
Task 1 fully completed
['Result of 1', 'Result of 2']

На первый взгляд можно ожидать, что “Do something with 2...” появится раньше любых сообщений про 1, ведь await стоит на второй задаче. Но планирование в цикле событий объясняет, почему первой «выступает» задача 1.

Что происходит на самом деле

Когда вы вызываете asyncio.create_task(get_payload(1)) и asyncio.create_task(get_payload(2)), обе задачи создаются и готовы к планированию. Они не вытесняют текущую корутину. Коруртина orchestrate продолжает выполняться, пока не дойдёт до await или не завершится. Лишь после этого цикл событий сможет запустить другие задачи.

Ключевой момент — строка out_b = await job_b. Этот await отдаёт управление обратно циклу событий и позволяет выполнить другие готовые задачи. Следующей к запуску становится та, что была создана раньше. Поэтому сначала начинает выполняться job_a и идёт до своего первого await: печатает “Do something with 1...” и затем ждёт asyncio.sleep(1). Пока job_a приостановлена, цикл планирует job_b, которая печатает “Do something with 2...” и ждёт asyncio.sleep(2). Далее каждая задача возобновится по завершении своего sleep — поэтому “Done with 1” вы увидите раньше “Done with 2”.

Небольшой трюк для наглядности

Чтобы отчётливо увидеть момент планирования, вставьте строку после создания обеих задач и до первого await:

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))
    print('tasks created')
    out_b = await job_b
    print("Task 2 fully completed")
    out_a = await job_a
    print("Task 1 fully completed")
    return [out_a, out_b]

outputs = asyncio.run(orchestrate())
print(outputs)

Самая первая строка будет:

tasks created

Причина в том, что создание задач не прерывает текущую корутину. Только когда orchestrate доходит до await, управление возвращается циклу событий, и он начинает выполнять другие задачи по одной. Поскольку job_a была создана первой, она и планируется первой, и вы получите такую последовательность:

tasks created
Do something with 1...
Do something with 2...
Done with 1
Done with 2
Task 2 fully completed
Task 1 fully completed
['Result of 1', 'Result of 2']

«Но я не делал await, а они всё равно печатали»

Ещё одно частое наблюдение: простого создания задач будто бы достаточно, чтобы корутины начали выполняться, даже если вы их никогда не ожидаете:

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))

outputs = asyncio.run(orchestrate())
print(outputs)

Вывод:

Do something with 1...
Do something with 2...
None

Это укладывается в те же правила планирования. Новые задачи не могут выполняться, пока идёт orchestrate. Как только orchestrate завершается, управление возвращается циклу событий, который теперь может запустить готовые задачи. Начальные принты появляются сразу при старте каждой задачи. Сообщения после sleep не выводятся, потому что вы не ждали завершения задач. Итоговое значение — None, поскольку orchestrate ничего не возвращает.

Жадные задачи (eager) в Python 3.12+

В Python 3.12 и новее задачи можно создавать в «жадном» режиме: они стартуют сразу при создании:

import asyncio

async def get_payload(delay):
    print(f"Do something with {delay}...")
    await asyncio.sleep(delay)
    print(f"Done with {delay}")
    return f"Result of {delay}"

async def orchestrate():
    running = asyncio.get_running_loop()
    running.set_task_factory(asyncio.eager_task_factory)

    job_a = asyncio.create_task(get_payload(1))
    job_b = asyncio.create_task(get_payload(2))
    print('tasks created')
    out_b = await job_b
    print("Task 2 fully completed")
    out_a = await job_a
    print("Task 1 fully completed")
    return [out_a, out_b]

outputs = asyncio.run(orchestrate())
print(outputs)

В этом случае вы увидите:

Do something with 1...
Do something with 2...
tasks created
Done with 1
Done with 2
Task 2 fully completed
Task 1 fully completed
['Result of 1', 'Result of 2']

Разница в том, что фабрика задач меняет момент, когда задачи начинают выполняться.

Почему это важно

Точки передачи управления циклу событий определяют конкурентность в asyncio. Как только вы отдаёте управление (yield), могут выполниться другие задачи, а их порядок зависит от того, когда они были созданы или запланированы. Понимание этого избавляет от неверных ожиданий о том, какая корутина стартует первой, и помогает избегать тонких багов, когда задачи создаются, но никогда не ожидаются, — в результате работа выполняется лишь частично.

Выводы

Создавайте задачи с asyncio.create_task, когда вам нужна конкурентность, но помните: они не начнут выполняться, пока ваш код не отдаст управление или не завершится — если только вы явно не включите «жадное» поведение в Python 3.12+. Если нужны результаты, ожидайте созданные задачи. Если вы лишь создаёте задачи и сразу возвращаетесь, вы увидите только мгновенные побочные эффекты до первого await внутри этих задач, но не получите завершение и не увидите выводов после await.

Статья основана на вопросе на StackOverflow от M a m a D и ответе Booboo.