Concorrência e paralelismo no web scraping

Publicado:

16 min de leitura

Acar Diveroli
Autor: Acar Diveroli
Uma única coluna async com um relógio ao lado de três colunas paralelas de chips de processador

Quem quer acelerar um scraper geralmente pensa primeiro em "mais núcleos": dividir o trabalho entre processos, contratar um servidor maior. Mas, em um trabalho de scraping típico, o processador fica ocioso a maior parte do tempo. O programa espera segundos pela resposta do servidor de destino, pelo handshake TLS e pelo proxy abrir o túnel. O jeito de aproveitar esse tempo de espera se chama concorrência, o jeito de aproveitar a potência do processador se chama paralelismo, e os dois resolvem problemas diferentes.

Neste artigo, explicamos a diferença entre os dois conceitos, por que o scraping é principalmente um trabalho intensivo em entrada/saída (I/O) e quando asyncio, threads e processos do Python são úteis. Também vemos o event loop do Node.js, o que realmente limita a velocidade e como decidir o valor de concorrência. Rodamos os exemplos contra um servidor local com latência artificial e compartilhamos um esqueleto de medição para você medir o seu próprio trabalho.

O que é concorrência?

Concorrência é a capacidade de várias tarefas avançarem no mesmo período. As tarefas não precisam rodar no mesmo instante; enquanto uma espera, outra roda. Um cozinheiro que corta a salada enquanto espera a água do macarrão ferver está fazendo concorrência: uma pessoa conclui dois trabalhos no mesmo tempo.

Na programação, isso é feito quando uma tarefa cede o controle a outra no momento em que espera uma resposta. O asyncio do Python e o event loop do Node.js funcionam assim. Um único núcleo de processador e uma única thread conseguem manter centenas de requisições de rede "abertas" ao mesmo tempo, porque nenhuma delas usa o processador enquanto espera.

O que é paralelismo?

Paralelismo é rodar várias tarefas fisicamente ao mesmo tempo, em núcleos diferentes do processador. No exemplo da cozinha, são dois cozinheiros preparando dois pratos diferentes ao mesmo tempo. A velocidade do trabalho cresce diretamente com o número de trabalhadores, mas cada um precisa da própria bancada e dos próprios ingredientes.

Na programação, o paralelismo geralmente é obtido com vários processos ou com threads que realmente podem rodar ao mesmo tempo. O benefício aparece quando o trabalho usa muito o processador: analisar um documento HTML grande, processar imagens, comprimir dados.

CritérioConcorrênciaParalelismo
Ideia centralSobrepor os tempos de esperaDividir o trabalho entre núcleos
Núcleos necessáriosUm núcleo bastaVários núcleos
Trabalho adequadoIntensivo em I/O: rede, disco, banco de dadosIntensivo em CPU: análise, cálculo
Ferramentas no Pythonasyncio, threadsmultiprocessing, ProcessPoolExecutor
Ferramentas no Node.jsEvent loop, Promiseworker_threads, vários processos
Custo de memóriaBaixo, pequeno por tarefaAlto, memória própria por processo
Papel no scrapingEnviar requisições e receber respostasAnalisar páginas, rodar navegadores headless
Erro típicoSobrecarregar o destino com concorrência ilimitadaDividir trabalho de I/O entre processos e desperdiçar recursos

Os dois conceitos não são alternativas. Um sistema grande de scraping geralmente usa os dois: cada processo gerencia centenas de requisições de forma concorrente, e os processos rodam em paralelo em núcleos diferentes.

Por que o scraping é intensivo em I/O?

Olhar as etapas de uma única requisição de página mostra como o processador trabalha pouco:

  1. Resolução DNS. Transformar o nome de domínio em endereço IP. Uma consulta e uma resposta pela rede.
  2. Conexão com o proxy e o túnel. Uma conexão TCP com o proxy, uma requisição CONNECT e a espera até o proxy se conectar ao destino.
  3. Handshake TLS. Uma conexão criptografada com o servidor de destino. Leva algumas idas e voltas.
  4. Enviar a requisição e esperar a resposta. O servidor de destino prepara a página; em páginas com consultas a banco de dados, isso demora mais.
  5. Baixar a resposta. Depende do tamanho da página e da banda.
  6. Análise. Extrair dados do HTML. É a única etapa que usa o processador.

Nas cinco primeiras etapas, o programa espera bytes da rede. Em um script que baixa páginas uma após a outra, a análise é uma parte pequena do total; o script passa a maior parte do tempo esperando, com o processador ocioso.

Mas essa proporção não é fixa. Em um servidor local que atrasa cada resposta em 300 milissegundos, baixamos e analisamos 40 páginas com 400 cards de produto cada. Aumentar a concorrência de 1 para 5 e 20 reduziu o tempo de download em mais de dez vezes. Com concorrência 20, analisar essas mesmas páginas em um único núcleo levou mais tempo do que baixá-las; passar para um pool de processos cortou essa etapa mais ou menos pela metade. Ou seja, conforme a concorrência sobe, o gargalo pode passar da rede para o processador. Com os seus destinos, os números serão outros; recomendamos medir o seu próprio trabalho com o esqueleto de medição abaixo.

Qual é a diferença entre asyncio, threads e processos no Python?

O Python tem três ferramentas, e a chave para escolher entre elas é o GIL (Global Interpreter Lock). No interpretador padrão CPython, o GIL permite que só uma thread rode código Python por vez. Mas uma thread libera o GIL enquanto espera dados da rede. Como resultado:

  • asyncio: um event loop que roda em uma única thread. As tarefas cedem o controle umas às outras nos pontos de await. É a opção mais leve para centenas ou até milhares de requisições de rede concorrentes. A documentação do asyncio descreve as peças desse modelo. A biblioteca também precisa ser assíncrona: o AsyncClient do HTTPX ou o AIOHTTP.
  • Threads (ThreadPoolExecutor): como o GIL é liberado durante a espera de rede, as threads dão ganho real de velocidade em trabalho de I/O. São o jeito mais fácil de adicionar concorrência com bibliotecas síncronas como o Requests. Uma thread custa mais memória que uma tarefa do asyncio, então a eficiência cai depois de algumas centenas de requisições concorrentes.
  • Processos (ProcessPoolExecutor, multiprocessing): cada processo roda com o próprio interpretador e o próprio GIL, então dão paralelismo real em trabalho intensivo em CPU. Iniciar processos e mover dados entre eles é caro. A documentação do concurrent.futures define a interface comum dos pools de threads e de processos.

O Python 3.13 também trouxe uma versão experimental que pode rodar sem GIL; mas, como a compatibilidade das bibliotecas ainda é limitada, a versão padrão e as três ferramentas acima continuam comuns em trabalhos de scraping em produção.

FerramentaDá paralelismo?Trabalho adequadoUso no scraping
asyncio + HTTPX/AIOHTTPNão, uma única threadMuitas requisições de redeBaixar páginas
ThreadPoolExecutor + RequestsNa prática sim, durante I/ONúmero moderado de requisições, código síncronoAcelerar código Requests existente
ProcessPoolExecutorSimTrabalho intensivo em CPUAnalisar páginas grandes

Comparamos o suporte a concorrência das bibliotecas Python em HTTPX, Requests ou AIOHTTP.

Exemplo 1: concorrência limitada com asyncio.Semaphore

O código abaixo baixa páginas de forma concorrente, mas limita com o valor limit o número de requisições abertas ao mesmo tempo. Sem o Semaphore, o asyncio.gather inicia todas as requisições de uma vez, o que sobrecarrega tanto o seu próprio pool de conexões quanto o site de destino.

python
import asyncio

import httpx


async def fetch(client, sem, url):
    async with sem:
        response = await client.get(url)
        response.raise_for_status()
        return response.text


async def fetch_all(urls, limit=10, proxy=None):
    sem = asyncio.Semaphore(limit)
    async with httpx.AsyncClient(proxy=proxy, timeout=30) as client:
        return await asyncio.gather(*(fetch(client, sem, u) for u in urls), return_exceptions=True)


urls = [f"https://example.com/product/{i}" for i in range(100)]
pages = asyncio.run(fetch_all(urls, limit=10, proxy="http://user:pass@pr.proxynet.io:8000"))

Um único AsyncClient é compartilhado entre todas as requisições. Assim as conexões são reutilizadas e evita-se um novo handshake TLS a cada requisição.

Exemplo 2: download concorrente, análise em paralelo

Se as páginas são grandes e a análise leva um tempo perceptível, faz sentido combinar os dois modelos: baixar com asyncio e analisar em um pool de processos.

python
import asyncio
from concurrent.futures import ProcessPoolExecutor

from bs4 import BeautifulSoup


def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    return [(p.h2.get_text(strip=True), p.select_one(".price").get_text(strip=True)) for p in soup.select(".product")]


async def scrape(urls, limit=10, proxy=None):
    pages = await fetch_all(urls, limit=limit, proxy=proxy)
    html_pages = [p for p in pages if isinstance(p, str)]
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        return await asyncio.gather(*(loop.run_in_executor(pool, parse, html) for html in html_pages))


if __name__ == "__main__":
    results = asyncio.run(scrape(urls, limit=10))

A linha if __name__ == "__main__": é obrigatória no Windows e no macOS: o pool de processos inicia processos novos carregando o módulo desde o início, e, sem essa proteção, o código roda a si mesmo repetidamente.

É importante saber que essa configuração nem sempre acelera. Iniciar processos e mover HTML entre eles tem custo; se as páginas são pequenas e poucas, esse custo pode superar a própria análise. No teste acima, o pool de processos encurtou claramente a análise de páginas com 400 cards, mas o custo de inicialização dos processos se somou ao tempo total de download e análise. Meça com as suas próprias páginas antes de decidir.

Como funciona o event loop do Node.js?

O Node.js roda o código JavaScript em uma única thread e gerencia as operações de rede com um event loop. Quando você chama fetch, o Node.js entrega a requisição ao sistema operacional e o código JavaScript segue; quando a resposta chega, o callback correspondente entra na fila. O guia do event loop do Node.js explica as fases desse loop em detalhe.

Para o scraping, isso tem três consequências:

  • Requisições de rede são naturalmente concorrentes. Cada requisição baseada em Promise não bloqueia as outras enquanto espera. É parecido com o modelo asyncio do Python.
  • Código intensivo em CPU trava o loop inteiro. Enquanto uma função síncrona analisa um documento HTML grande, nenhuma outra resposta pode ser processada. Para esse tipo de trabalho, use threads separadas com o módulo worker_threads ou processos separados.
  • A resolução de nomes de domínio pode ser um gargalo escondido. A função padrão dns.lookup do Node.js roda o resolvedor do sistema operacional no pequeno pool de threads do libuv. Em um script que se conecta a muitos domínios diferentes ao mesmo tempo, esse pool pode lotar. Quando você usa proxy, os domínios de destino são resolvidos do lado do proxy, o que reduz o efeito.

Você não precisa de uma biblioteca extra para limitar a concorrência; um limitador simples basta:

javascript
function limiter(max) {
  let active = 0;
  const queue = [];
  const next = () => {
    if (active >= max || queue.length === 0) return;
    active++;
    const { task, resolve, reject } = queue.shift();
    task().then(resolve, reject).finally(() => {
      active--;
      next();
    });
  };
  return (task) => new Promise((resolve, reject) => {
    queue.push({ task, resolve, reject });
    next();
  });
}

const limit = limiter(10);
const results = await Promise.allSettled(
  urls.map((url) => limit(() => fetch(url).then((r) => r.text()))),
);

Como passar um proxy no Node.js, com um exemplo de rotação e novas tentativas, está em Como usar proxy no Node.js.

O que realmente limita a velocidade?

Aumentar a concorrência acelera até certo ponto; depois disso, não faz efeito ou deixa o trabalho mais lento. No scraping, o teto geralmente não é o processador, e sim estes fatores:

  • O limite de taxa do site de destino. Se o site permite um certo número de requisições por minuto da mesma fonte, aumentar a concorrência só gera mais respostas 429. Como lidar com esses códigos está em Códigos de status HTTP no web scraping.
  • A capacidade do servidor de destino. Quando o servidor de um site pequeno fica lento, a sua concorrência aumenta os tempos de resposta dele; tanto o seu trabalho quanto os visitantes reais do site ficam mais lentos.
  • A capacidade do proxy. O limite de conexões simultâneas do plano, o número de IPs do pool e os tempos de resposta dos pontos de saída. Com um Proxies rotativos, que oferece muitos IPs de saída por um único endereço, as requisições são distribuídas entre endereços diferentes, mas o limite de conexões do plano continua valendo.
  • Banda. Ao baixar páginas grandes e respostas com imagens, entra em jogo o limite da sua conexão ou do tráfego do proxy.
  • Custo de abrir conexões. Se cada requisição abre uma conexão nova com um handshake TLS novo, o tempo aumenta. Compartilhar o cliente e reutilizar conexões reduz esse custo.
  • Distância. A latência entre o ponto de saída do proxy e o servidor de destino se soma a cada ida e volta. Com endereços Proxies residenciais, que saem por conexões domésticas reais, esse tempo varia com a localização e a linha.
  • O processador. Só passa a ser decisivo ao rodar navegadores headless, analisar documentos muito grandes ou processar muitos dados na mesma máquina.

Tratamos de outras formas de ajustar a velocidade sem ser bloqueado nem prejudicar o site em Como fazer web scraping sem ser bloqueado.

Qual deve ser o valor de concorrência?

Não existe um número único para todo trabalho; o valor certo se encontra medindo. O método que recomendamos:

  1. Comece com um valor baixo. Algumas requisições simultâneas para um único site de destino.
  2. Meça três coisas. Páginas concluídas por minuto, tempo médio de resposta e taxa de erros (429, 503, timeouts).
  3. Aumente o valor aos poucos. Repita a mesma medição a cada passo.
  4. Encontre o ponto de virada. Quando as páginas concluídas param de aumentar e o tempo de resposta ou a taxa de erros começam a subir, volte ao valor anterior.
  5. Defina um limite por site. Se você rastreia vários sites ao mesmo tempo, a concorrência total pode ser alta, mas a fatia de cada site deve continuar pequena.
  6. Meça de novo periodicamente. A infraestrutura e os limites de taxa dos sites de destino mudam com o tempo.

Um esqueleto simples para medir:

python
import asyncio
import time


def measure(label, fn):
    start = time.perf_counter()
    result = fn()
    elapsed = time.perf_counter() - start
    print(f"{label}: {elapsed:.2f} s")
    return result


for limit in (1, 5, 10, 20):
    pages = measure(f"limit={limit}", lambda: asyncio.run(fetch_all(urls, limit=limit)))
    errors = sum(isinstance(p, Exception) for p in pages)
    print(f"  erros: {errors} / {len(pages)}")

time.perf_counter() é um contador de alta resolução que não é afetado por mudanças no relógio do sistema e é mais adequado que time.time() para medir durações. Faça a medição com uma lista pequena de URLs que não sobrecarregue o site de destino e, se possível, em horários de pouco movimento no site.

Casos de uso

  • Poucas páginas de um único site: Requests síncrono com pausas entre as requisições. Não precisa de concorrência.
  • Coleta de preços e catálogos de muitos sites: concorrência com limite pequeno por site usando asyncio, e um proxy rotativo para distribuir. A configuração geral está na nossa página de solução de extração de dados.
  • Rastreamento em grande escala: vários processos, cada um rodando asyncio; uma fila por domínio. O lado da escala está na nossa página de solução de web crawler.
  • Acelerar um script Requests existente: com ThreadPoolExecutor, sem reescrever o código. Para uma configuração rotativa com Requests, veja Como rotacionar proxies em Python.
  • Páginas carregadas com JavaScript: como um navegador headless usa muita CPU e memória, o número de navegadores simultâneos é limitado por núcleos e memória.

Erros comuns

  • Dividir trabalho intensivo em I/O entre processos. Você paga o custo de inicialização e a memória dos processos, mas a velocidade não melhora, porque o gargalo é a rede.
  • Usar uma biblioteca síncrona dentro do asyncio. Uma chamada requests.get() dentro de uma async def trava o event loop, e todas as tarefas rodam uma após a outra.
  • gather ou Promise.all sem limite. Iniciar milhares de requisições de uma vez leva a erros de conexão e limites de taxa no site de destino.
  • Criar um cliente novo para cada requisição. As conexões não são reutilizadas e cada requisição faz um handshake TLS novo.
  • Olhar o tempo total, mas não a taxa de erros. Um trabalho que fica mais rápido enquanto a taxa de 429 sobe coleta menos dados.
  • Analisar na thread principal do Node.js. Com documentos grandes, o event loop trava e as respostas em espera podem dar timeout.

Guia de decisão

Sua situaçãoRecomendação
Muitas páginas, HTML pequenoasyncio + HTTPX/AIOHTTP, limitado com Semaphore
Código Requests síncrono existenteThreadPoolExecutor
Páginas grandes, análise pesadaBaixar com asyncio, analisar com ProcessPoolExecutor
Baixar páginas com Node.jsPromise + limitador de concorrência
Análise pesada no Node.jsworker_threads
Navegador headlessPoucos navegadores simultâneos, conforme núcleos e memória
A taxa de 429 está subindoReduzir a concorrência, definir limite por site
Sem ganho de velocidade e sem errosMedir o gargalo: proxy, banda, servidor de destino

Perguntas frequentes

Concorrência e paralelismo são a mesma coisa?

Não. Concorrência é as tarefas avançarem no mesmo período e pode acontecer em um único núcleo. Paralelismo é as tarefas rodarem de fato ao mesmo tempo em vários núcleos. Todo sistema paralelo é concorrente, mas nem todo sistema concorrente é paralelo.

asyncio ou threads para scraping?

Se você está escrevendo do zero e vai enviar muitas requisições, o asyncio gerencia mais requisições concorrentes com menos recursos. Se o seu código existente usa Requests e as requisições concorrentes não vão passar de algumas centenas, o ThreadPoolExecutor é o jeito mais fácil de acelerar sem reescrever o código.

O GIL deixa o scraping mais lento?

Como o GIL é liberado durante as requisições de rede, na prática ele não deixa o download de páginas mais lento. Em etapas intensivas em CPU, como a análise, as threads não conseguem rodar código Python ao mesmo tempo; para essas etapas usa-se um pool de processos.

Mais IPs de proxy aumentam a velocidade?

Se o site de destino conta o limite de taxa por IP, distribuir a carga pode permitir chegar a mais páginas no mesmo tempo. Mas, se o site aplica o limite por outros critérios, ou se o gargalo é a banda ou a capacidade do servidor de destino, o número de IPs não muda o resultado. Não prejudicar o site é uma responsabilidade que vale independentemente de quantos IPs você tem.

O que acontece se eu colocar a concorrência alta demais?

Do seu lado, surgem limites de pool de conexões e de descritores de arquivo; do lado do destino, respostas 429, timeouts e um site mais lento. A partir de certo ponto, as páginas concluídas param de aumentar e os erros aumentam.

Como ajustar a concorrência com navegadores headless?

Cada instância de navegador usa bastante memória e CPU, então o número de páginas simultâneas é limitado pelos recursos da máquina, não pela rede. Comece com poucas instâncias e aumente observando o uso de memória e CPU. Quando um navegador headless é realmente necessário está explicado em Páginas estáticas e dinâmicas.

Em resumo

A concorrência aproveita o tempo de espera e o paralelismo, os núcleos do processador. Como no scraping a maior parte do tempo vai para esperar respostas de rede, a velocidade aumenta principalmente com concorrência limitada, construída com asyncio, threads ou o event loop do Node.js; um pool de processos só é necessário em etapas intensivas em CPU, como análise pesada e navegadores headless. O teto costuma ser o limite de taxa do site de destino, a capacidade do proxy e a banda. Defina a concorrência medindo, não chutando, e estabeleça um limite por site. Para coletar dados em escala, conheça os nossos serviços de proxy.