Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Async в Python: asyncio-практика и частые ошибки

Async в Python: когда asyncio действительно нужен и как не наступить на грабли

Практическое руководство по asyncio: event loop и await, конкурентность через gather/TaskGroup, блокирующие вызовы и run_in_executor, отмена и таймауты, типичные ошибки начинающих.

Прямой ответ

asyncio даёт выигрыш в одном сценарии: много одновременных операций ожидания ввода-вывода (HTTP-запросы, БД, сеть) в одном потоке — тысячи соединений без тысяч потоков. Для CPU-задач он бесполезен (GIL никто не отменял — см. статью про многопроцессность), а для «пары запросов к API» обычный синхронный код проще и быстрее пишется. Три правила, которые покрывают 90% практики: не блокировать event loop (никаких time.sleep/requests внутри async), использовать современные примитивы (TaskGroup и timeout вместо ручных gather/loop), и проектировать отмену (любой await может быть прерван — ресурсы должны закрываться).

Модель: event loop, корутины, таски

Корутина (async def) — не выполняется, пока не await или не обёрнута в Task; await — точка, где управление может вернуться в loop и заняться другими задачами. Отсюда главный инсайт: конкурентность появляется только там, где есть несколько независимых задач, запущенных одновременно (asyncio.gather(coro1, coro2) или async with asyncio.TaskGroup() as tg: tg.create_task(...)), а последовательные await в одном теле — это просто поочерёдное ожидание. TaskGroup (3.11+) предпочтительнее gather в новом коде: структурная конкурентность — при падении одной задачи отменяются все, и исключения не теряются. Одиночный asyncio.run(main()) — входная точка; крутить свой loop руками почти никогда не нужно.

Главный грабель: блокирующие вызовы

Event loop однопоточен, и любой синхронный вызов внутри корутины останавливает все задачи: time.sleep(1) вместо await asyncio.sleep(1), синхронный HTTP-клиент вместо асинхронного (выбор клиентов — в статье про requests и httpx), тяжёлый парсинг/хэширование, и особенно — синхронные драйверы БД в async-фреймворке. Лечение трёх видов: асинхронная версия библиотеки (httpx, asyncpg, aiofiles); для CPU-тяжёлого и legacy-кода — await asyncio.to_thread(blocking_fn, ...) (вызов в отдельном потоке, 3.9+); для CPU-тяжёлого — ProcessPoolExecutor. Детектор — режим отладки PYTHONASYNCIODEBUG=1 (warnит о медленных коллбэках) и «тормозящий» сервис под нагрузкой при формально низкой загрузке CPU.

Отмена, таймауты и корректность ресурсов

Отмена — часть дизайна asyncio, а не исключительная ситуация: CancelledError прилетает в любую точку await при таймауте/остановке, и код обязан отпускать ресурсы (отсюда async with для всего, что открывается, и осторожность с except Exception без пере-raise отмены — он её проглатывает). Таймауты — обязательны на каждый внешний вызов: async with asyncio.timeout(5): (3.11+) или asyncio.wait_for(coro, 5); «вечное ожидание» одного зависшего соединения — классика продакшн-инцидентов. Отдельная дисциплина — graceful shutdown: сбор незавершённых тасков, отмена, ожидание с ограничением; фреймворки (FastAPI/uvicorn, aiohttp) делают это за вас — самописные loop'ы требуют ручной работы.

Конкурентность с ограничением и фоновые задачи

«Выстрелить 10 000 задач сразу» — путь к исчерпанию файловых дескрипторов и бану по rate limit: ограничивайте конкурентность asyncio.Semaphore(N) или батчами. Фоновые задачи («запусти и забудь») через create_task без ссылки — задача может быть собрана GC, а исключение молча потеряно: держите ссылку и добавляйте done-callback с логированием, либо используйте TaskGroup/очереди. Очереди (asyncio.Queue) — основной паттерн producer/consumer внутри процесса; между процессами — внешние брокеры (Celery/Redis — см. статью про фоновые задачи).

Асинхронные тесты и отладка

Тесты — через pytest-asyncio (@pytest.mark.asyncio и асинхронные фикстуры; настройка — в статье про pytest-фикстуры), мокирование async-кода — AsyncMock. Отладка: asyncio.run(main(), debug=True) ловит незаawait-анные корутины («coroutine was never awaited» — частая ошибка: корутина создана, но не запущена, результат — тихий no-op) и медленные коллбэки; трассировки тасков — через task.get_coro() и логирование имён (asyncio.current_task().get_name()). Правило гигиены: предупреждение «never awaited» в логах — всегда баг, а не шум.

Практический сценарий: конкурентные вызовы внешнего API

Задача: опросить триста карточек во внешнем API, лимит — десять одновременных соединений, у каждого вызова таймаут пять секунд, при сетевой ошибке — две повторные попытки. Сборка на asyncio: семафор на десять внутри функции-обёртки (каждая задача приобретает его перед запросом — лимит соблюдается независимо от числа задач), httpx.AsyncClient создаётся один на весь прогон (пул соединений переиспользуется, а не собирается на каждый запрос), внутри — asyncio.timeout(5) вокруг вызова и ручной цикл ретраев с возрастающей паузой только на сетевые исключения и ответы 5xx. Триста задач заводятся через TaskGroup — при исключении в одной (например, 404 после всех ретраев) группа отменяет остальные, и прогон падает быстро и с понятной ошибкой, а не продолжает молотить впустую. Результаты собираются списком по мере готовности. Такой прогон занимает примерно время самого медленного вызова, умноженное на число «волн» (300/10 × ~1 с ≈ 30 с), тогда как последовательная версия — те же триста вызовов по очереди — шла бы пять минут. И главное отличие от «просто gather на триста задач» — семафор не даёт упереться в лимит файлового дескриптора и не провоцирует бан со стороны API.

FAQ

Стоит ли переводить синхронный сервис на async? Только если профиль нагрузки — много медленного I/O (внешние API, долгие запросы) и метрики это подтверждают. Для CPU-нагрузки, очередей и внутренних сервисов выигрыша нет, а сложность (драйверы, отладка, библиотеки) вырастет.

asyncio или потоки? Для тысяч соединений — asyncio (поток на соединение не масштабируется). Для десятков параллельных блокирующих операций потоки (ThreadPoolExecutor) проще и не требуют переписывания стека под async.

Почему мой async-код не быстрее синхронного? Почти всегда: задачи выполняются последовательно (нет gather/TaskGroup) или внутри блокирующие вызовы. Проверка — режим debug и честный подсчёт одновременных задач.

Нужен ли async в CLI-скриптах и batch-джобах? Обычно нет: скрипту достаточно to_thread/пула потоков или просто последовательности с ретраями. async оправдан, когда скрипт держит сотни соединений (краулер, мониторинг).

Источники: документация asyncio (обзор, tasks, debug mode) — ссылки в шапке.