Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 6 из 20 Средний 35 мин 130 XP

Задачи: asyncio.create_task

Одна строка — и два await из прошлого урока бегут одновременно: 4 секунды становятся 2. Знакомимся с задачами, их done() и result() и правилом о ссылке на задачу.

Редакция Питоники

Прошлый урок закончился обещанием: те же две загрузки по две секунды — за две вместо четырёх. Выполняем его одной функцией: asyncio.create_task — способ запустить корутину немедленно, не дожидаясь её в этой строке. Это главная функция asyncio для «сделай сейчас, ответ заберу позже» — фоновые сохранения, параллельные запросы, таймеры ботов: всё это задачи.

create_task: поставить в план немедленно

Код почти тот же, что в уроке 5: та же load, те же две страницы по две секунды. Разница в двух строках — вместо await load(...) по очереди обе корутины уходят в create_task, а await появляется потом, по делу:

те же две загрузки — за две секунды
import asyncio
import time

START = time.monotonic()

async def load(name, delay):
    await asyncio.sleep(delay)         # имитация сетевой задержки
    t = round(time.monotonic() - START)
    print(f"{name}: готово на t={t}")
    return name

async def main():
    first = asyncio.create_task(load("Каталог", 2))
    second = asyncio.create_task(load("Курсы", 1))

    await first
    await second

    print("Всего секунд:", round(time.monotonic() - START))

asyncio.run(main())
Вывод
Курсы: готово на t=1
Каталог: готово на t=2
Всего секунд: 2

Смотри на таймстампы: обе загрузки стартовали на t=0 — create_task записал обе корутины в план цикла сразу, не дожидаясь их в момент вызова. Дальше цикл сам разбирал паузы: чей таймер прозвенел, та и продолжилась, — «Курсы» успели раньше. Итог — максимум (2), а не сумма (3). Это и есть конкурентность из первого урока, только собранная руками.

Разница с уроком 5 видна построчно. Там await load("Каталог", 2) означало «скачай и жди», и вторая загрузка простаивала в очереди. Здесь create_task(load("Каталог", 2)) означает «начни скачивать сейчас», а await сдвинулся ниже и уже не мешает соседям: к моменту await first обе задачи давно крутятся, и ожидание первой проходит в тени работы второй.

Задача как объект: done и result

create_task возвращает не результат, а задачу — объект-управленец над корутиной. У него есть собственные ручки: task.done() отвечает, закончилась ли работа, task.result() возвращает результат (только после завершения), а await task — способ дождаться и получить то же самое.

done() до и после await
import asyncio
import time

async def load(name, delay):
    await asyncio.sleep(delay)         # имитация сетевой задержки
    return f"{name}: готово"

async def main():
    task = asyncio.create_task(load("Отчёт", 1))

    print("Сразу после create_task:")
    print("  done():", task.done())

    result = await task

    print("После await:")
    print("  done():", task.done())
    print("  result():", task.result())

asyncio.run(main())
Вывод
Сразу после create_task:
  done(): False
После await:
  done(): True
  result(): Отчёт: готово

Обрати внимание на первый done(): False: create_task не выполняет тело мгновенно, а лишь записывает корутину в план — тело стартует на следующем обороте цикла, а мы спрашиваем про задачу в той же строке. Это отвечает и на вопрос «а можно task.result() до await?»: можно, но только когда done() стал True — раньше задача честно ответит, что результат ещё не готов.

две задачи, результаты по очереди
import asyncio
import time

async def load(name, delay):
    await asyncio.sleep(delay)         # имитация сетевой задержки
    return f"{name}: готово"

async def main():
    first = asyncio.create_task(load("Каталог", 1))
    second = asyncio.create_task(load("Курсы", 2))

    r1 = await first
    r2 = await second
    print(r1)
    print(r2)

asyncio.run(main())
Вывод
Каталог: готово
Курсы: готово

Работа шла одновременно, а результаты забираются по очереди — в том порядке, в котором дожидаешься задач await'ом. Это удобно, когда каждой задаче дальше по коду нужна своя переменная: запустил пачку, собрал ответы по именам. Для пачек «собери всё разом» есть короче путь — gather, и он ждёт в уроке 7.

Маленький бонус, о котором стоит знать: await над уже завершённой задачей бесплатен и безопасен. Можно дожидать задачу дважды, трижды, из разных мест — каждый раз вернётся тот же результат, и ничего не запустится повторно. Задача исполняется один раз, а await лишь читает её итог. Этим пользуются, когда один и тот же результат нужен нескольким частям программы.

Почему секунды перестали складываться

Соберём механику урока 1 в одну картинку. У цикла были папки «готовые» и «спящие» — теперь их наполнили двое. Обе задачи легли спать на t=0: «Каталог» до t=2, «Курсы» до t=1. Цикл дремлет до ближайшего срока, будит «Курсы», тот дорабатывает и уходит; дремлет дальше, будит «Каталог». Пока одна задача спала, цикл работал над другой — именно этого не могло случиться в уроке 5, где соседей у паузы не было.

И о масштабе: разница «сумма против максимума» растёт с каждой задачей. Пять загрузок по две секунды — восемь секунд экономии; пятьдесят — уже девяносто восемь. Руками завести пятьдесят create_task можно, но радости мало: gather из следующего урока примет всю пачку одной строкой, сохранив ту же механику.

Урок 5: await столбикомУрок 6: create_task
Старт второй загрузкипосле завершения первойсразу, на t=0
Чем занята пауза первойничем: соседей нетработой второй задачи
Итоговое времясумма задержек (4 сек)максимум задержек (2 сек)

А вот демонстрация ценности в чистом виде: одна задача крутится фоном, пока main занята своим await. «Курсы» и «Каталог» идут одновременно, хотя await перед «Курсами» появится только после «Каталога»:

пока один await — вторая работает
import asyncio
import time

START = time.monotonic()

async def load(name, delay):
    await asyncio.sleep(delay)         # имитация сетевой задержки
    t = round(time.monotonic() - START)
    print(f"{name}: готово на t={t}")

async def main():
    task = asyncio.create_task(load("Курсы", 1))
    await load("Каталог", 2)           # ждём Каталог - Курсы доделались сама
    await task

asyncio.run(main())
Вывод
Курсы: готово на t=1
Каталог: готово на t=2

Это и есть рабочий смысл задач: пока корутина стоит в честном await над долгой операцией, фоновая задача использует её паузы. Боты делают ровно так: основной await ждёт апдейты, а фоновые задачи рассылают, напоминают и чистят кэш.

фоновое напоминание: паттерн бота
import asyncio
import time

START = time.monotonic()

async def remind(label, delay):
    await asyncio.sleep(delay)         # имитация сетевой задержки
    t = round(time.monotonic() - START)
    print(f"напоминание «{label}» на t={t}")

async def main():
    background = asyncio.create_task(remind("проверить почту", 2))
    await asyncio.sleep(1)             # main занята своим ожиданием
    print("main закончила своё дело на t =", round(time.monotonic() - START))
    await background                   # дождали фоновую задачу

asyncio.run(main())
Вывод
main закончила своё дело на t = 1
напоминание «проверить почту» на t=2

Схема телеграм-бота в миниатюре: фоновая задача заведена в начале, main занята своим долгим ожиданием — и напоминание срабатывает внутри чужой паузы, без отдельного потока. Ссылка background сохранена до дожидания: правило из следующего раздела соблюдено ещё до того, как о нём объявлено.

Ссылку на задачу надо сохранять

У задач есть правило безопасности, о котором документация предупреждает прямо: цикл держит запущенные задачи слабо — лишь настолько, чтобы довести их до конца, пока на них кто-то смотрит. Если ссылки на задачу нет (вызвал create_task в воздухе), единственный владелец задачи — сам цикл, и в один не самый удачный момент сборщик мусора вправе решить, что задача никому не нужна, и собрать её посреди работы. Задача-призрак исчезает молча: без ошибки, без результата, иногда воспроизводясь раз на сотню запусков — худший вид бага.

так нельзя: ссылка потеряна
# ПЛОХО: задача без владельца
asyncio.create_task(load("Каталог", 2))   # ссылку никто не сохранил

# ХОРОШО: у задачи есть хозяин
task = asyncio.create_task(load("Каталог", 2))
background = [task]                       # или общий список задач
Фрагмент для чтения: load здесь не определена. Смысл — в комментарии: вызов create_task в воздухе оставляет задачу без сильной ссылки.

Когда задач много, владельцем назначают коллекцию: список tasks в main — самый простой реестр. Запустили пачку — сложили в список, потом дождали каждую:

список задач как реестр
import asyncio
import time

START = time.monotonic()

async def load(name, delay):
    await asyncio.sleep(delay)         # имитация сетевой задержки
    t = round(time.monotonic() - START)
    print(f"{name}: готово на t={t}")
    return name

async def main():
    tasks = [
        asyncio.create_task(load("Каталог", 2)),
        asyncio.create_task(load("Курсы", 1)),
        asyncio.create_task(load("Отчёт", 3)),
    ]
    for task in tasks:
        await task
    print("Всего секунд:", round(time.monotonic() - START))

asyncio.run(main())
Вывод
Курсы: готово на t=1
Каталог: готово на t=2
Отчёт: готово на t=3
Всего секунд: 3

Три страницы по 2 + 1 + 3 секунды — за 3: сумма снова превратилась в максимум. await в цикле по списку здесь не последовательность, как в уроке 5, а дожидание уже работающих задач: к моменту первого await все три давно крутятся, и очередь из await лишь собирает урожай.

create_task требует работающий цикл

Планировать может только тот, кто управляет циклом: create_task живёт внутри асинхронного кода — в main, в корутинах, в задачах. Снаружи, в синхронном мире, планировщика нет, и asyncio отвечает отказом:

задача без цикла не создаётся
import asyncio

async def load():
    return "готово"

cr = load()
try:
    asyncio.create_task(cr)            # цикла нет
except RuntimeError as e:
    print("RuntimeError:", e)
cr.close()
Вывод
RuntimeError: no running event loop
Ошибку здесь ловим и печатаем, а корутину закрываем руками, чтобы Python не печатал предупреждение о не запущенной корутине. Место create_task — внутри asyncio.run.

Схема точки входа из урока 3 складывается окончательно: asyncio.run(main()) поднимает цикл, main и её потомки создают задачи, цикл крутит их всех. Всё, что должно работать в фоне, стартует внутри — не снаружи.

Что дальше

Задачи появились в арсенале: create_task запускает корутину немедленно, сумма превратилась в максимум, у фоновых работ появился законный дом — при условии, что у задачи есть хозяин со ссылкой. Следующий урок автоматизирует ручной труд: asyncio.gather соберёт пачку корутин одним вызовом, вернёт результаты в порядке аргументов и посчитает время по-нашему — по максимуму.

create_task планирует корутину немедленно: обе загрузки стартуют на нулевой секунде, и время становится максимумом, а не суммой.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import asyncio
import time

START = time.monotonic()

async def job(name, delay):
    await asyncio.sleep(delay)
    t = round(time.monotonic() - START)
    print(f"{name} на t={t}")

async def main():
    a = asyncio.create_task(job("А", 2))
    b = asyncio.create_task(job("Б", 1))
    await a
    await b

asyncio.run(main())
import asyncio

async def work():
    await asyncio.sleep(0)
    return "ответ"

async def main():
    task = asyncio.create_task(work())
    print(task.done())
    result = await task
    print(result)

asyncio.run(main())
import asyncio
import time

async def main():
    start = time.monotonic()
    await asyncio.sleep(1)
    await asyncio.sleep(2)
    seq = round(time.monotonic() - start)

    start = time.monotonic()
    a = asyncio.create_task(asyncio.sleep(1))
    b = asyncio.create_task(asyncio.sleep(2))
    await a
    await b
    par = round(time.monotonic() - start)

    print(seq, par)

asyncio.run(main())
Проверь себя
0 / 6

1. Что делает asyncio.create_task(load(...))?

2. Две загрузки по 2 секунды запущены create_task. Сколько займёт await обеих?

3. Почему task.done() возвращает False сразу после create_task?

4. Почему ссылку на задачу надо сохранять?

5. Что произойдёт при вызове asyncio.create_task в синхронном коде, вне asyncio.run?

6. await task вернул результат. Что ещё можно было вызвать для того же значения?

Карточки терминов
Запомнено: 0 / 6
Практика

Повтори рекорд урока: две загрузки — «Каталог» за 2 секунды и «Курсы» за 1 — запусти задачами create_task, дождись обеих await'ом и напечатай общее время. Каждая загрузка печатает «имя: готово на t=N» с таймстампом от общего старта, финальная строка — «Всего секунд: 2».

practice.py
Вопросы и ответы по уроку

Что делает asyncio.create_task и когда его применять?

Он планирует корутину немедленно: тело стартует на следующем обороте цикла, и задача работает параллельно с твоим кодом. Применение — всё, что должно идти фоном или одновременно: параллельные запросы, фоновые рассылки, таймеры. Результат задачи забирают await'ом, когда он понадобится. Против столбика await create_task сжимает время с суммы задержек до максимума.

Почему задачу нужно сохранять в переменную?

Цикл держит запущенные задачи только слабой ссылкой: если ты не сохранил задачу, её единственный владелец — сборщик мусора, и он может уничтожить задачу посреди выполнения. Баг при этом редкий и невоспроизводимый. Документация asyncio прямо рекомендует хранить ссылки: в переменную или в список-реестр задач.

Чем task.result() отличается от await task?

Итог один — результат корутины, но правила разные. await task всегда законен: он дожидается завершения, если задача ещё работает. task.result() можно звать только после завершения (task.done() == True) — раньше он ответит ошибкой о незавершённой задаче. В обычном коде используй await; result() нужен, когда результат читает код, который не может ждать.

create_task можно вызывать вне asyncio.run?

Нет: планировщик существует только внутри работающего цикла. В синхронном коде create_task отвечает RuntimeError: no running event loop. Точка создания задач — внутри main и других корутин, то есть внутри asyncio.run; в этом смысле задачи подчиняются той же точке входа, что и весь асинхронный код.

Понравился урок? Сошлитесь на него

«create_task планирует корутину немедленно: обе загрузки стартуют на нулевой секунде, и время становится максимумом, а не суммой.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.