Как распараллелить функцию, отправляющую запросы к сервисам для каждого аргумента в Python
Что проверяют
Понимаете ли вы, что запросы к сервисам это IO-bound работа и её ускоряет конкурентность, а не дополнительные ядра. Почти сразу следом спрашивают, как ограничить число одновременных запросов и что делать, если часть из них упала.
Как отвечать
Выбор зависит от того, какой HTTP-клиент уже в коде:
- асинхронный вариант:
asyncioплюсhttpx.AsyncClientилиaiohttp. Одна сессия на все запросы, задачи запускаются черезasyncio.gatherилиasyncio.TaskGroup(с Python 3.11). Число одновременных запросов ограничиваетasyncio.Semaphore; - синхронный код: если функция написана на
requestsи переписывать её нельзя, берутThreadPoolExecutorиas_completed. Поток на ожидании сети отпускает GIL, поэтому это реально работает. Для отдельных блокирующих вызовов внутри async-кода естьasyncio.to_thread; - процессы здесь не нужны: они полезны для вычислений, а на сети дают только лишние накладные расходы.
Дальше то, что отличает рабочий код от учебного:
- таймауты на каждый запрос (
asyncio.timeoutили параметр клиента), иначе один зависший сервис держит всю пачку; - обработка ошибок:
gather(..., return_exceptions=True)вернёт исключения в списке результатов, аTaskGroupпри первой ошибке отменит остальные задачи. Надо выбрать, какое поведение нужно; - ретраи с экспоненциальной задержкой только для временных ошибок (таймаут, 429, 503), например через
tenacity; - лимиты сервиса: если у API квота запросов в секунду, семафора мало, нужен ограничитель частоты.
Где спотыкаются
Запускают gather на десять тысяч аргументов без семафора. Сервис отвечает 429, заканчиваются сокеты, хотя локально на трёх URL всё выглядело быстро.
Вызывают requests.get внутри async def. Вызов блокирует цикл событий, и «асинхронный» код выполняет запросы строго по одному.
Создают новую ClientSession или клиент на каждый запрос. Пул соединений не переиспользуется, на каждый вызов заново идут TCP и TLS рукопожатия, и выигрыш от параллельности съедается.