Задачи: 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 — способ дождаться и получить то же самое.
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 перед «Курсами» появится только после «Каталога»:
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] # или общий список задач
Когда задач много, владельцем назначают коллекцию: список 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
Схема точки входа из урока 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())
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 вернул результат. Что ещё можно было вызвать для того же значения?
Повтори рекорд урока: две загрузки — «Каталог» за 2 секунды и «Курсы» за 1 — запусти задачами create_task, дождись обеих await'ом и напечатай общее время. Каждая загрузка печатает «имя: готово на t=N» с таймстампом от общего старта, финальная строка — «Всего секунд: 2».
Что делает 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
asyncio · Урок 5
Два await подряд: последовательность, не конкурентность
Поставил два await — и ожидаешь ускорения? Секундомер говорит: 2 + 2 = 4 секунды. Разбираем, почему await сам по себе не создаёт конкурентность, и замеряем всё честно.
asyncio · Урок 7
asyncio.gather: пачка конкурентных операций
Три create_task — три строки, десять страниц — десять. gather собирает всю пачку одним вызовом: время по самой долгой операции, результаты в порядке аргументов.
asyncio · Урок 4
Coroutine never awaited: главная ошибка новичка
Программа завершилась успешно и ничего не сделала: классика потерянного await. Разбираем корутин-призраков, честно смотрим на RuntimeWarning и выучиваем три лекарства.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
openpyxl · Урок 19
openpyxl и pandas: read_excel и ExcelWriter под капотом
pd.read_excel и df.to_excel всю дорогу пользуются openpyxl: разбираем связку под капотом, ExcelWriter с несколькими листами и правило выбора инструмента.
pandas to_excel index false
NumPy · Урок 8
Булева индексация и маски: фильтрация данных в NumPy
Достаём из массива только нужное: булевы маски, операторы & | ~ вместо and/or, np.where в двух ролях, argmax и честная чистка выбросов в данных датчика.
numpy задачи с решениями
asyncio · Урок 10
Отмена задач: cancel и CancelledError
Задача больше не нужна — cancel() просит её остановиться. Отмена кооперативная: CancelledError возникает внутри корутины на её текущем await, а убирается задача сама — в finally.
asyncio cancel задачаasyncio CancelledError