Параллельный парсинг в Python: как делать правильно
Один поток парсит тысячу страниц за 40 минут, потому что 99% времени ждёт ответа сервера. Разбираем, когда брать asyncio, когда потоки, а когда процессы — и чем каждый вариант платит за скорость.

Вы написали скрипт, который обходит тысячу страниц каталога: requests.get, парсинг через BeautifulSoup, запись в CSV. Работает, но час на тысячу страниц — это неприемлемо, когда страниц миллион. Профилировщик показывает: 95% времени процесс не делает ничего, кроме ожидания ответа сервера. CPU простаивает, сеть недозагружена. Это классическая I/O-bound задача, и способов её ускорить в Python ровно три — asyncio, потоки и процессы. Выбор между ними определяет, упрётесь вы в GIL, в лимиты сервера или в собственный код.
Статья про то, как выбрать модель параллелизма под парсинг и не выстрелить себе в ногу. Инвалидация кеша, обход капчи и работа с прокси-пулами — отдельные темы, здесь их не касаемся. Примеры на Python 3.12, библиотеки httpx 0.27 и aiohttp 3.9.
Почему один поток медленный, и почему это хорошая новость
При синхронном парсинге программа отправляет запрос и блокируется, пока не придёт ответ. Сетевой round-trip до сервера и обратно — это десятки, а иногда сотни миллисекунд. Всё это время процессор ничего не делает. Отсюда правило: если ваша задача — ждать (а парсинг именно такой), параллелизм даёт кратный выигрыш, потому что вы ждёте много ответов одновременно.
Обратная ситуация — CPU-bound. Если вы парсите гигантский HTML тяжёлыми регулярками или гоняете lxml по сложным XPath, узкое место смещается с сети на процессор. Тогда потоки и asyncio не помогут: их упрёт GIL1. Прежде чем выбирать модель, замерьте, где реально уходит время.
Сначала профилируйте, потом распараллеливайте. Ускорять сеть процессами, а парсинг — корутинами — верный способ получить сложный код без прироста скорости.
Три подхода и чем каждый платит
asyncio + aiohttp/httpx — для чистого I/O
Асинхронная модель — эталон для сетевого парсинга. Один поток, событийный цикл, тысячи одновременных запросов без накладных расходов на переключение потоков ОС. Цена: весь стек должен быть асинхронным. Смешаете синхронный requests внутри корутины — заблокируете весь цикл, и параллелизм схлопнется в последовательное выполнение.
# Python 3.12, aiohttp 3.9
import asyncio
import aiohttp
# Ограничиваем число одновременных запросов, чтобы не завалить сервер
SEMAPHORE = asyncio.Semaphore(20)
async def fetch(session: aiohttp.ClientSession, url: str) -> str:
async with SEMAPHORE:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
resp.raise_for_status()
return await resp.text()
async def parse_all(urls: list[str]) -> list[str]:
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
# return_exceptions=True — одна упавшая страница не роняет весь батч
return await asyncio.gather(*tasks, return_exceptions=True)
if __name__ == "__main__":
pages = [f"https://example.com/page/{i}" for i in range(1, 1001)]
results = asyncio.run(parse_all(pages))
ok = [r for r in results if isinstance(r, str)]
print(f"Успешно: {len(ok)} из {len(pages)}")
Semaphore здесь — не украшение. Без него gather отправит все 1000 запросов одновременно, и вежливый сервер ответит 429, а невежливый забанит IP.
ThreadPoolExecutor — когда стек синхронный
Если у вас уже есть проверенный синхронный код на requests, переписывать его на async ради скорости — дорого. Пул потоков даёт параллелизм I/O без переписывания: GIL освобождается на время сетевого ожидания, поэтому потоки реально ждут ответы одновременно.
# Python 3.12, requests 2.31
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
def fetch(url: str) -> tuple[str, str | None]:
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
return url, resp.text
except requests.RequestException:
return url, None
def parse_all(urls: list[str], workers: int = 20) -> dict[str, str]:
pages = {}
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(fetch, url): url for url in urls}
for future in as_completed(futures):
url, html = future.result()
if html is not None:
pages[url] = html
return pages
Цена потоков: каждый поток ОС стоит памяти (стек в мегабайтах) и упирается в переключения контекста. Тысячи потоков не поднять — практический потолок десятки, максимум сотни. Для десятков тысяч URL asyncio масштабируется лучше.
ProcessPoolExecutor — только для тяжёлого парсинга
Процессы обходят GIL полностью, каждый со своим интерпретатором. Но межпроцессное взаимодействие сериализует данные через pickle, а это накладные расходы. Для сетевых запросов процессы — почти всегда лишнее: вы платите за изоляцию, которая не нужна для ожидания. Процессы оправданы, если обработка скачанного — тяжёлый CPU: разбор больших документов, вычисления, ресайз изображений.
Что выбрать под конкретную задачу
| Подход | Когда брать | Потолок параллелизма | Цена |
|---|---|---|---|
| asyncio + aiohttp | Много запросов, весь стек можно сделать async | Тысячи одновременных | Весь код должен быть асинхронным |
| ThreadPoolExecutor | Есть готовый синхронный код на requests | Десятки–сотни | Память на потоки, переключения контекста |
| ProcessPoolExecutor | Тяжёлый парсинг/обработка после скачивания | По числу ядер CPU | Сериализация через pickle, память |
Практическое правило: I/O ждём — берём asyncio или потоки; CPU считаем — процессы. Гибрид тоже встречается: async качает страницы, а тяжёлый разбор уходит в ProcessPoolExecutor через loop.run_in_executor.
Что ломается на реальных проектах
- Синхронный вызов в корутине. Один
requests.getилиtime.sleepвнутри async-функции блокирует весь event loop. Вместоtime.sleep—await asyncio.sleep, вместоrequests—aiohttpилиhttpx.AsyncClient. - Нет ограничения на число запросов. Отправите 5000 запросов разом — получите 429 Too Many Requests или бан по IP. Семафор или очередь обязательны.
- Одна ошибка роняет весь батч. Без
return_exceptions=Trueвgatherпервое же исключение прервёт всё. Обрабатывайте ошибки по каждому URL отдельно. - Нет таймаута. Один зависший сервер держит воркер бесконечно. Всегда ставьте
timeoutна запрос. - Общее изменяемое состояние в потоках. Запись в один список или словарь из нескольких потоков без блокировки — гонка. Собирайте результаты через
as_completedв главном потоке или используйтеqueue.Queue.
Вежливость к серверу — не опция
Параллельный парсер легко превращается в DDoS для чужого сайта. Проверьте robots.txt, соблюдайте Crawl-delay, ставьте разумный User-Agent, ограничивайте частоту. Агрессивный парсинг ведёт не только к бану IP — это ещё и юридические риски и нагрузка на чужую инфраструктуру, за которую платит владелец сайта.
1 GIL (Global Interpreter Lock) — глобальная блокировка интерпретатора CPython: в один момент байткод Python исполняет только один поток. На I/O-операциях GIL освобождается, поэтому потоки полезны для сети, но бесполезны для чистых вычислений. В Python 3.13 появился экспериментальный сборка без GIL — до продакшена массово ещё не дошло.
Что делать дальше
Прогоните свой текущий парсер под профилировщиком (cProfile или простой замер времени сети против времени обработки) и определите, I/O у вас или CPU. Если I/O — начните с httpx.AsyncClient, у него единый API для sync и async, миграция мягче. Настройте ограничение параллелизма с запасом (начните с 10–20), измерьте RPS и долю ошибок, дальше поднимайте до первого 429. В проде следите за долей неуспешных запросов и распределением времени ответа — рост хвоста задержек сигнализирует, что сервер начал вас притормаживать.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: asyncio — Asynchronous I/O, официальная документация Python, aiohttp — Client Quickstart
Частые вопросы
У меня Python 3.11, а не 3.12 — примеры подойдут?
asyncio быстрее потоков — почему не всегда его брать?
Сервер начал отдавать 429 при параллельном парсинге — что делать?
Можно ли комбинировать asyncio и процессы?
Сколько воркеров или одновременных запросов ставить?
Почему мой async-парсер работает как последовательный?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.