← все задачи
Async Python · задача 8 из 10
Фоновая задача по расписанию
Средний
15–20 минут
create_taskжизненный циклустойчивость
Условие
Нужно каждые 30 секунд обновлять кэш курсов валют в фоне, не мешая обработке запросов. Задача должна переживать ошибки и корректно останавливаться при завершении приложения.
Что требуется
- Запускается при старте приложения, останавливается при завершении
- Ошибка в одной итерации не прекращает работу цикла
- Остановка не оставляет висящих задач и предупреждений
Пример
@asynccontextmanager
async def lifespan(app):
task = asyncio.create_task(refresh_loop(30))
yield
task.cancel()
Сначала уточните
Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.
- Интервал между запусками или строго «раз в 30 секунд» независимо от длительности работы?
- Сколько инстансов приложения — если несколько, обновлять кэш будет каждый, нужен ли лидер?
- Что делать, если обновление не успевает за интервал?
Показать решение Скрыть решение
Решение
import asyncio
import logging
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
async def refresh_loop(interval, refresh):
while True:
try:
await refresh()
except asyncio.CancelledError:
logger.info("фоновое обновление остановлено")
raise
except Exception:
# одна неудача не должна убивать цикл навсегда
logger.exception("не удалось обновить кэш")
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app):
task = asyncio.create_task(refresh_loop(30, refresh_rates))
try:
yield
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
Почему так
Почему except Exception внутри цикла
- Исключение, вылетевшее из корутины задачи, завершает её — обновление просто перестанет происходить
- Заметить это тяжело: приложение работает, кэш тихо устаревает, и никто не получает ошибку
- Ловим и логируем внутри итерации, чтобы цикл дожил до следующего раза
Почему ссылку на задачу нужно хранить
- create_task не удерживает задачу: цикл событий хранит только слабую ссылку, и сборщик мусора может забрать её на середине
- Поэтому task кладут в переменную или в множество на всё время жизни — это известная ловушка из документации
- Хранение ссылки заодно даёт возможность отменить задачу при остановке
Почему отмена и gather при завершении
- Без cancel задача останется висеть, а интерпретатор при выходе напечатает «Task was destroyed but it is pending»
- gather после cancel даёт задаче завершиться: отработают finally и закроются соединения
- return_exceptions=True нужен, чтобы ожидаемый CancelledError не всплыл при штатной остановке
Что спросят дальше
- Спросят про несколько инстансов: каждый будет обновлять своё — нужен лидер, блокировка в Redis или вынос в Celery beat
- Спросят про «строго раз в 30 секунд»: интервал между запусками плывёт на длительность работы, для точности считают следующий момент от начала
- Спросят, чем это хуже Celery beat: нет персистентности, при рестарте расписание начинается заново
Следующая задача
Асинхронный итератор по страницам API — Обёртка над постраничным API так, чтобы снаружи это был обычный async for.