Асинхронные представления в Django: поведение под нагрузкой
Django поддерживает async views с версии 3.1, но переход на них не всегда приводит к приросту производительности. Разбираем, где асинхронные представления действительно снимают нагрузку, где создают новые проблемы и какие цифры стоит снять до принятия решения.

Откуда ожидания и где они ломаются
Типичный сценарий: Django-приложение на gunicorn с четырьмя синхронными воркерами обслуживает API. На 200 одновременных запросах всё работает, на 400 — latency p95 уходит за 2 секунды, а на 600 появляются 502. Команда решает переписать представления на async def, рассчитывая получить десятикратный прирост пропускной способности. Через неделю выясняется, что на тех же 600 запросах стало не лучше, а иногда хуже. Проблема не в async как таковом, а в том, что узкое место было совсем в другом месте.
Асинхронные представления в Django — не серебряная пуля. Они помогают ровно в одном классе задач: когда воркер простаивает, ожидая ввода-вывода, и вы хотите занять это время обработкой других запросов. Если большая часть времени уходит на CPU-bound код или на синхронный ORM-запрос, async view не ускоряет ничего — он лишь добавляет накладные расходы на event loop.
Как Django обрабатывает async views
Начиная с версии 3.1 Django умеет принимать представления, определённые через async def. Но внутренний механизм зависит от того, каким сервером вы запускаете приложение.
ASGI-сервер: нативный путь
При запуске под ASGI-сервером (uvicorn, daphne, hypercorn) async view выполняется внутри event loop без дополнительных обёрток. Каждое await отдаёт управление циклу, и сервер может обрабатывать другие запросы в том же потоке. Это и есть тот выигрыш, ради которого затевается переход.
WSGI-сервер: скрытый sync_to_async
Если вы оставили gunicorn с WSGI и просто поменяли def на async def, Django создаёт новый event loop для каждого запроса, запускает в нём корутину и ждёт результата синхронно. Вы получаете overhead на создание loop, но не получаете конкурентности. Это распространённая ошибка, которую сложно заметить по функциональным тестам — они проходят, но прироста нет.
Async view под WSGI — это async-синтаксис без async-семантики. Нагрузку он не снижает, а увеличивает на стоимость создания event loop при каждом запросе.
ORM и sync_to_async
Django ORM остаётся синхронным по умолчанию. В версии 4.1 появились асинхронные методы queryset (aget, afilter, acount и другие), но внутри они оборачивают синхронный вызов через sync_to_async, который перебрасывает работу в пул потоков. Это значит, что каждый ORM-запрос из async view порождает переключение контекста между event loop и thread pool. При высокой конкурентности пул потоков становится узким местом, и вы получаете ту же очередь, только в другом месте.
Сноска: sync_to_async — адаптер из пакета asgiref, входящего в зависимости Django. Он запускает синхронную функцию в потоке из ThreadPoolExecutor и возвращает awaitable. Размер пула по умолчанию — количество CPU × 5, но точное значение зависит от версии Python и asyncio.
Где async views дают реальный выигрыш
Выигрыш появляется, когда представление тратит основное время на ожидание внешних сервисов, а не на собственные вычисления или ORM.
- Прокси-запросы к внешним API. View делает два-три HTTP-запроса к сторонним сервисам, собирает ответы, отдаёт клиенту. При синхронном подходе воркер заблокирован на всё время ожидания. При async — ждёт несколько запросов параллельно через
asyncio.gather, а event loop в это время обслуживает другие соединения. - WebSocket и SSE. Долгоживущие соединения, где один воркер держит сотни подписчиков. Здесь sync-подход физически невозможен без отдельного сервиса.
- Fan-out на микросервисы. Агрегатор, который опрашивает пять-десять внутренних сервисов и склеивает ответ. Синхронно — 5 × latency, асинхронно — max(latency).
Где async views не помогают или вредят
- Тяжёлый ORM. Если представление делает сложный JOIN на PostgreSQL, время уходит на выполнение запроса в базе, а не на ожидание сети. Async view не ускорит базу, но добавит переключение между event loop и thread pool.
- CPU-bound обработка. Генерация PDF, ресайз изображений, сериализация больших JSON. Event loop блокируется, другие корутины стоят. Хуже, чем с sync, потому что вы замораживаете весь поток event loop.
- Middleware и зависимости без async-поддержки. Если хотя бы одна middleware в цепочке синхронная, Django оборачивает вызов через
sync_to_async. Это нивелирует часть выигрыша и создаёт неочевидное поведение при отладке.
Сравнение: sync vs async на разных профилях нагрузки
Таблица ниже показывает не абсолютные цифры (они зависят от железа, базы, сети), а направление эффекта. Условия: Django 5.x, Python 3.12, uvicorn для async, gunicorn (4 воркера) для sync, PostgreSQL 16, нагрузка через locust.
| Профиль представления | sync (gunicorn) | async (uvicorn) | Комментарий |
|---|---|---|---|
| Один ORM-запрос, ответ 1 КБ | Базовая линия | Примерно то же или чуть хуже | ORM идёт через sync_to_async, overhead съедает выигрыш |
| 3 HTTP-запроса к внешним API последовательно | RPS ≈ воркеры / суммарный latency | RPS растёт в 2–4 раза | Главный кейс для async: ожидание I/O параллелизуется |
| CPU-bound (генерация отчёта ~200 мс) | Базовая линия | Хуже: event loop блокируется | Async противопоказан без выноса в executor |
| WebSocket, 500 подключений | 500 воркеров или отдельный сервис | 1 процесс | Единственный рабочий вариант без сторонних решений |
| Смешанный: ORM + 1 внешний запрос | Базовая линия | Прирост 10–30 % | Выигрыш есть, но скромный из-за ORM через thread pool |
Что ломается первым
Исчерпание thread pool
Каждый ORM-вызов из async view занимает поток. При 200 одновременных корутинах, каждая из которых делает два ORM-запроса, вам нужно 400 потоков в пуле. Стандартный ThreadPoolExecutor в Python создаёт по умолчанию min(32, os.cpu_count() + 4) потоков. Корутины встают в очередь, latency растёт, а вы видите в метриках не перегрузку базы, а «медленные запросы». Размер пула можно увеличить, но каждый поток — это память и контекстные переключения ОС.
Блокирующий код внутри корутины
Любой вызов, который не является awaitable, блокирует event loop: запись в файл через стандартный open(), вызов библиотеки без async-интерфейса, даже time.sleep() вместо asyncio.sleep(). Один такой вызов на 50 мс при 100 RPS означает, что event loop «встаёт» на 5 секунд каждые 100 запросов.
Утечки соединений к базе
Django закрывает соединения с базой в конце запроса через сигнал request_finished. В async-контексте, если вы вручную работаете с курсорами или используете sync_to_async(thread_sensitive=False), соединение может остаться открытым в потоке, который не обслуживает следующий запрос. Под нагрузкой это приводит к исчерпанию пула соединений PostgreSQL.
Как измерять, а не угадывать
Прежде чем переписывать views на async, снимите профиль текущей нагрузки. Вот что стоит замерить:
- Разбивка времени запроса. Сколько миллисекунд уходит на ORM, сколько на внешние HTTP-вызовы, сколько на сериализацию. Django Debug Toolbar или django-silk дают эту картину. Если 80 % времени — база, async не поможет.
- Утилизация воркеров. Какой процент времени воркер gunicorn занят, а какой — ждёт I/O. Метрики gunicorn через statsd или prometheus-клиент покажут это.
- Конкурентность. Сколько одновременных запросов реально обрабатывается. Если число близко к количеству воркеров и основное время — ожидание I/O, async имеет смысл.
- Нагрузочный тест до и после. Один и тот же сценарий, одно и то же железо, одни и те же данные. Сравнивайте не средний latency, а p95 и p99 — именно там видны проблемы с thread pool и блокировками.
Граничные случаи
- Миграция частичная. Часть views — async, часть — sync. Django справляется с этим корректно в рамках одного ASGI-приложения: синхронные views исполняются через
sync_to_async. Но это значит, что для каждого sync view создаётся поток — то есть вы получаете thread pool overhead даже для тех views, которые не трогали. - Сторонние пакеты. DRF (Django REST Framework) до версии 3.15 не поддерживал async views. Если вы используете DRF, проверьте версию и статус поддержки — попытка сделать
async defв APIView без поддержки фреймворка приведёт к тому, что DRF вызовет представление синхронно. - Celery и фоновые задачи. Отправка задачи в Celery из async view требует
sync_to_async, потому что клиент Celery синхронный. Это мелочь, но при высокой частоте отправок она превращается в ещё один потребитель thread pool.
Что делать дальше
Если ваш профиль — внешние HTTP-вызовы и вы готовы перейти на ASGI-сервер, async views дадут ощутимый прирост пропускной способности. Начните с одного-двух представлений, которые проводят больше всего времени в ожидании I/O, и замерьте разницу. Следите за размером thread pool и количеством открытых соединений к базе через мониторинг (pg_stat_activity для PostgreSQL). Если профиль — ORM-heavy, выигрыш будет минимальным; в этом случае эффективнее добавлять воркеры gunicorn, оптимизировать запросы или вынести тяжёлые операции в Celery. Полностью асинхронный ORM — тема, над которой команда Django работает, но на момент Django 5.x он остаётся обёрткой над синхронным ядром, и это стоит учитывать при планировании архитектуры.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Django Documentation — Asynchronous support, Django Documentation — How to deploy with ASGI
Частые вопросы
Можно ли использовать async views с gunicorn без перехода на ASGI?
Правда ли, что async ORM-методы (aget, afilter) ускоряют запросы к базе?
Что будет, если в async view вызвать синхронную библиотеку без await?
У меня Django 4.0, async views уже работают?
Как понять, что thread pool стал узким местом?
Поддерживает ли DRF async views?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.