Скачайте 100 URL конкурентно, но не более 10 одновременно, с обработкой ошибок отдельных запросов.
Короткий ответ
- asyncio.Semaphore(10) ограничивает одновременные запросы
- Каждый URL — корутина, захватывающая семафор через async with
- gather с return_exceptions=True не роняет всё из-за одного URL
- Один AsyncClient на все запросы — переиспользование соединений
- Таймауты обязательны, иначе задача может висеть вечно
- Альтернатива: TaskGroup или очередь с пулом воркеров
Семафор внутри корутины ограничивает параллелизм, gather с return_exceptions собирает результаты и ошибки, а общий клиент и таймауты делают решение производственным.
Как сказать вслух
пример ответаЯ создаю семафор на десять разрешений и одну корутину на URL: она захватывает семафор через async with, делает запрос и возвращает результат. Все сто задач запускаю разом через gather — реально одновременно будут работать не больше десяти. Флаг return_exceptions нужен, чтобы один упавший URL не отменил остальные: ошибки вернутся в общем списке, и я их разберу. Обязательно один общий HTTP-клиент и таймауты на каждый запрос.
Подробный ответ
Основной ответ
Структура решения: asyncio.Semaphore(10) как разделяемый лимитер; корутина fetch(sem, client, url) оборачивает запрос в async with sem — лишние задачи ждут свободного слота без занятых потоков. Все корутины передаются в asyncio.gather(..., return_exceptions=True): порядок результатов соответствует порядку URL, а исключения отдельных запросов возвращаются объектами вместо отмены остальных задач. Один httpx.AsyncClient на всю пачку переиспользует соединения (keep-alive, пулы) — создавать клиент на каждый запрос дорого и неправильно. Таймаут задаётся на клиенте или запросе. Продакшен-усиления: ретраи с backoff на сетевые ошибки, разбор результатов на успехи/ошибки, в 3.11+ — asyncio.TaskGroup для структурированной отмены, либо очередь с фиксированным числом воркеров.
Ключевые моменты
- Семафор, а не нарезка. Деление на пачки по 10 с ожиданием каждой — медленнее: семафор держит ровно 10 активных всегда.
- return_exceptions=True. Без него первое исключение всплывает из gather, остальные результаты теряются; с ним ошибки — обычные элементы списка.
- Общий клиент. AsyncClient в async with снаружи всех задач: пул соединений, DNS-кэш, корректное закрытие.
- Таймауты и отмена. httpx.Timeout на запрос и asyncio.timeout/TaskGroup сверху защищают от вечно висящих задач.
Практический контекст
Классическая senior-задача на секции по конкурентности: краулеры, массовые проверки вебхуков, прогрев кэшей, интеграции с внешними API под rate limit. Интервьюер смотрит, догадается ли кандидат ограничить параллелизм и обработать частичные сбои, а не просто вызвать gather. Разговор обычно продолжается про ретраи, rate limiting по времени и отличие семафора от пула воркеров.
Пример кода
import asyncio, httpx
async def fetch(sem, client, url):
async with sem:
r = await client.get(url, timeout=10.0)
r.raise_for_status()
return url, len(r.content)
async def main(urls):
sem = asyncio.Semaphore(10)
async with httpx.AsyncClient() as client:
results = await asyncio.gather(
*(fetch(sem, client, u) for u in urls),
return_exceptions=True,
)
ok = [r for r in results if not isinstance(r, Exception)]
failed = [r for r in results if isinstance(r, Exception)]
print(f"ok={len(ok)} failed={len(failed)}")
asyncio.run(main([f"https://example.com/{i}" for i in range(100)]))Частые ошибки
- Запускают все 100 запросов без лимита и получают бан или исчерпание сокетов
- Забывают return_exceptions=True, и одна ошибка прячет 99 успешных ответов
- Создают новый HTTP-клиент в каждой корутине вместо общего с пулом соединений