Aiohttp против requests: когда асинхронность реально ускоряет
Скрипт с requests опрашивает 200 URL за 90 секунд, переписанный на aiohttp — за 4. А иногда наоборот: asyncio добавляет накладных расходов и не даёт ничего. Разбираем, где проходит граница.

Есть задача: опросить 200 HTTP-эндпоинтов и собрать ответы. На requests цикл отрабатывает за 90 секунд — каждый запрос ждёт ответа сервера, прежде чем начать следующий. Переписанный на aiohttp тот же скрипт укладывается в 4 секунды: пока один запрос ждёт сеть, стартуют остальные. Разница в 20 раз выглядит как аргумент переписать всё подряд. Но это ловушка: на других задачах asyncio не даёт ничего или проигрывает. Разберём, где именно проходит граница и по каким признакам её видно заранее.
Версии: Python 3.11+, aiohttp 3.9.x, requests 2.31.x. Поведение event loop и asyncio описано для 3.11, в 3.12 существенных изменений в API, о котором пойдёт речь, нет.
Почему requests блокирует, а aiohttp — нет
Сетевой запрос — это в основном ожидание. Ваш код отправил байты, а дальше процессор простаивает: пакеты идут до сервера, тот считает ответ, пакеты возвращаются. На это уходят десятки и сотни миллисекунд, в течение которых CPU не делает ничего полезного.
requests синхронна. Вызов requests.get(url) блокирует поток до получения ответа. Пока идёт ожидание, следующий запрос не начнётся. Десять запросов по 200 мс подряд — это два секунды, хотя чистого счёта тут нет вовсе.
aiohttp построена на asyncio. Когда корутина доходит до await session.get(url) и упирается в ожидание сети, управление возвращается в event loop, и тот запускает другую корутину. Все запросы «висят» в ожидании одновременно, а общее время близко к времени самого медленного из них, а не к их сумме.
Асинхронность не ускоряет вычисления. Она устраняет простой во время ожидания. Если ждать нечего — выигрыша нет, есть только накладные расходы на событийный цикл.
Что такое I/O-bound и CPU-bound
Задача I/O-bound, если её время тратится на ожидание ввода-вывода: сеть, диск, база данных. Здесь asyncio раскрывается. Задача CPU-bound, если время уходит на счёт: парсинг, шифрование, обработка изображений. Здесь один event loop не поможет — корутины выполняются в одном потоке по очереди, и asyncio только добавит overhead.*
* Event loop — цикл, который отслеживает готовность операций ввода-вывода и передаёт управление тем корутинам, чьё ожидание завершилось. В одном потоке крутится один loop.
Замер на конкретной задаче
Опрашиваем 100 раз один локальный эндпоинт, который отвечает с искусственной задержкой 100 мс (имитация медленного сервиса). Замер на ноутбуке, Python 3.11, один процесс, сеть — localhost, так что чистое влияние модели, без флуктуаций внешней сети.
Синхронный вариант на requests:
import time
import requests
URL = "http://localhost:8080/slow" # эндпоинт отвечает через 100 мс
start = time.perf_counter()
for _ in range(100):
response = requests.get(URL)
response.raise_for_status()
print(f"requests: {time.perf_counter() - start:.2f} c")
# requests: ~10.3 c — 100 запросов по 100 мс подрядАсинхронный вариант на aiohttp — все запросы стартуют почти одновременно:
import asyncio
import time
import aiohttp
URL = "http://localhost:8080/slow"
async def fetch(session, url):
async with session.get(url) as response:
response.raise_for_status()
return await response.text()
async def main():
# одна сессия на все запросы — переиспользует пул соединений
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, URL) for _ in range(100)]
await asyncio.gather(*tasks)
start = time.perf_counter()
asyncio.run(main())
print(f"aiohttp: {time.perf_counter() - start:.2f} c")
# aiohttp: ~0.2 c — все 100 ждут параллельноРазница 10.3 против 0.2 секунды. Но обратите внимание на условие: 100 независимых запросов, каждый в основном ждёт. Уберите ожидание — и картина развернётся.
Где asyncio проигрывает
Возьмём CPU-bound задачу: посчитать SHA-256 для сотни крупных строк в цикле. Тут нет ни одного await, к которому мог бы прицепиться event loop. Корутины выполнятся строго последовательно в том же единственном потоке, а вы заплатите за обёртку в задачи и работу планировщика. asyncio здесь либо равен синхронному коду, либо чуть медленнее. Для параллельного счёта нужен multiprocessing или concurrent.futures.ProcessPoolExecutor, а не asyncio.
Три сценария и что выбрать
| Сценарий | Природа | Что брать |
|---|---|---|
| 1–2 запроса подряд, простой скрипт | I/O, но нечего распараллеливать | requests — проще и читаемее |
| Сотни независимых HTTP-запросов | I/O-bound | aiohttp — выигрыш кратный |
| Много запросов, но код уже синхронный | I/O-bound | requests + ThreadPoolExecutor |
| Тяжёлый счёт, парсинг, шифрование | CPU-bound | ни то ни другое — процессы |
Строка про потоки важна. Если проект синхронный и переписывать его на async дорого, ThreadPoolExecutor с requests даёт похожий выигрыш на I/O почти без переделки логики: потоки Python освобождают GIL на время ожидания сети.
from concurrent.futures import ThreadPoolExecutor
import requests
URL = "http://localhost:8080/slow"
def fetch(url):
return requests.get(url).text
# 20 потоков параллельно обслуживают ожидание сети
with ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(lambda _: fetch(URL), range(100)))Когда что выбирать — критерий, а не «зависит»
- Запросов единицы, скрипт разовый — requests. Асинхронность добавит сложности без отдачи.
- Запросов десятки-сотни, проект пишется с нуля — aiohttp. Экосистема async (FastAPI, asyncpg) сомкнётся вокруг него.
- Запросов много, но кодовая база синхронная — requests в ThreadPoolExecutor. Меньше риска, чем частичный переход на async.
- Смешивание async и sync в одном потоке — не делайте: синхронный
requests.getвнутри корутины заблокирует весь event loop, и асинхронность превратится в тыкву.
Что ломается при переходе на aiohttp
Кратный выигрыш из первого замера — идеальный случай. На практике вылезают грабли:
- Одна сессия на запрос. Создавать
ClientSessionвнутри цикла на каждый URL — частая ошибка. Пул соединений не переиспользуется, а на закрытие сессий уходит время. Одна сессия на пачку запросов, как в примере выше. - Отсутствие лимита параллельности.
asyncio.gatherна 10 000 задач разом откроет тысячи соединений и утопит и ваш процесс, и сервер на той стороне (который вернёт 429 или разорвёт связь). Ограничивайте черезasyncio.Semaphore. - Блокирующий вызов внутри корутины. Любая синхронная операция —
time.sleep, тяжёлыйjson.loadsна мегабайтах, обращение к синхронной БД — замораживает event loop целиком. Один блокирующий вызов сводит на нет всю асинхронность. - Таймауты по умолчанию. Без явного
ClientTimeoutзависший сервер может держать корутину неопределённо долго. Задавайте таймаут на сессии или запросе.
Пример с ограничением параллельности — обязательный паттерн для больших объёмов:
import asyncio
import aiohttp
async def fetch(session, url, semaphore):
async with semaphore: # не больше N одновременных запросов
async with session.get(url) as response:
return await response.text()
async def main(urls):
semaphore = asyncio.Semaphore(20) # потолок параллельности
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
tasks = [fetch(session, url, semaphore) for url in urls]
return await asyncio.gather(*tasks)Что делать дальше
Прежде чем переписывать проект, замерьте, где уходит время. Профилировщик или простой time.perf_counter вокруг блоков покажет, I/O у вас или CPU. Асинхронность лечит только первое.
Если решились на aiohttp — начните с Semaphore и ClientTimeout с первого дня, а не когда прод ляжет под нагрузкой. В проде смотрите на число одновременных соединений и долю 429/timeout от целевых сервисов: рост этих цифр значит, что потолок параллельности выставлен слишком высоко. Ретраи с backoff — отдельная тема, здесь их не разбираем, но в реальном клиенте они нужны.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: aiohttp — Client Quickstart (официальная документация), Python asyncio — Coroutines and Tasks
Частые вопросы
У меня Python 3.8, примеры сработают?
Скрипт на aiohttp завис и ничего не отдаёт — почему?
Дают ли потоки с requests тот же выигрыш, что aiohttp?
Можно ли смешивать requests и aiohttp в одном сервисе?
Почему asyncio.gather на десятках тысяч задач падает или тормозит?
aiohttp быстрее requests на одном запросе?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.