Concurrencia y paralelismo en web scraping

Publicado:

16 min de lectura

Acar Diveroli
Autor: Acar Diveroli
Una sola columna async con un reloj junto a tres columnas paralelas de chips de procesador

Quien quiere acelerar un scraper suele pensar primero en «más núcleos»: repartir el trabajo entre procesos, contratar un servidor más grande. Pero en un trabajo de scraping típico, el procesador está ocioso la mayor parte del tiempo. El programa espera segundos a la respuesta del servidor de destino, al handshake TLS y a que el proxy abra su túnel. La forma de aprovechar ese tiempo de espera se llama concurrencia, la de aprovechar la potencia del procesador se llama paralelismo, y cada una resuelve un problema distinto.

En este artículo explicamos la diferencia entre ambos conceptos, por qué el scraping es sobre todo un trabajo intensivo en entrada/salida (I/O) y cuándo son útiles asyncio, los hilos y los procesos de Python. También vemos el event loop de Node.js, qué limita realmente la velocidad y cómo decidir el valor de concurrencia. Ejecutamos el código de ejemplo contra un servidor local con latencia artificial, y compartimos un esqueleto de medición para que midas tu propio trabajo.

¿Qué es la concurrencia?

La concurrencia es la capacidad de varias tareas de avanzar en el mismo periodo. Las tareas no tienen que ejecutarse en el mismo instante; mientras una espera, otra se ejecuta. Un cocinero que corta la ensalada mientras espera a que hierva el agua de la pasta hace concurrencia: una persona termina dos trabajos en el mismo tiempo.

En programación, esto se consigue cuando una tarea cede el control a otra en el momento en que espera una respuesta. asyncio de Python y el event loop de Node.js funcionan así. Un solo núcleo de procesador y un solo hilo pueden mantener «abiertas» cientos de peticiones de red a la vez, porque ninguna usa el procesador mientras espera.

¿Qué es el paralelismo?

El paralelismo es ejecutar varias tareas físicamente a la vez, en distintos núcleos del procesador. En el ejemplo de la cocina, son dos cocineros preparando dos platos distintos al mismo tiempo. La velocidad del trabajo crece directamente con el número de trabajadores, pero cada uno necesita su propia encimera e ingredientes.

En programación, el paralelismo se consigue normalmente con varios procesos o con hilos que realmente pueden ejecutarse a la vez. Su beneficio aparece cuando el trabajo usa mucho el procesador: analizar un documento HTML grande, procesar imágenes, comprimir datos.

CriterioConcurrenciaParalelismo
Idea centralSolapar los tiempos de esperaRepartir el trabajo entre núcleos
Núcleos necesariosBasta con unoVarios
Trabajo adecuadoIntensivo en I/O: red, disco, base de datosIntensivo en CPU: análisis, cálculo
Herramientas en Pythonasyncio, hilosmultiprocessing, ProcessPoolExecutor
Herramientas en Node.jsEvent loop, Promiseworker_threads, varios procesos
Costo de memoriaBajo, pequeño por tareaAlto, memoria propia por proceso
Papel en el scrapingEnviar peticiones y recibir respuestasAnalizar páginas, ejecutar navegadores headless
Error típicoSaturar el destino con concurrencia ilimitadaRepartir trabajo de I/O entre procesos y desperdiciar recursos

Los dos conceptos no son alternativas. Un sistema de scraping grande suele usar ambos: cada proceso gestiona cientos de peticiones de forma concurrente y los procesos se ejecutan en paralelo en distintos núcleos.

¿Por qué el scraping es intensivo en I/O?

Mirar las etapas de una sola petición de página muestra lo poco que trabaja el procesador:

  1. Resolución DNS. Convertir el nombre de dominio en una dirección IP. Una consulta y una respuesta por la red.
  2. Conexión al proxy y túnel. Una conexión TCP con el proxy, una petición CONNECT y la espera hasta que el proxy se conecta al destino.
  3. Handshake TLS. Una conexión cifrada con el servidor de destino. Requiere varios viajes de ida y vuelta.
  4. Enviar la petición y esperar la respuesta. El servidor de destino prepara la página; en páginas con consultas a bases de datos tarda más.
  5. Descargar la respuesta. Depende del tamaño de la página y del ancho de banda.
  6. Análisis. Extraer datos del HTML. Es el único paso que usa el procesador.

En los cinco primeros pasos, el programa espera bytes de la red. En un script que descarga páginas una tras otra, el análisis es una parte pequeña del total; el script pasa la mayor parte del tiempo esperando mientras el procesador está ocioso.

Pero esa proporción no es fija. En un servidor local que retrasa cada respuesta 300 milisegundos, descargamos y analizamos 40 páginas con 400 tarjetas de producto cada una. Subir la concurrencia de 1 a 5 y a 20 redujo el tiempo de descarga más de diez veces. Con una concurrencia de 20, analizar esas mismas páginas en un solo núcleo tardó más que descargarlas; pasar a un pool de procesos redujo ese paso aproximadamente a la mitad. Es decir, al subir la concurrencia, el cuello de botella puede pasar de la red al procesador. Con tus destinos las cifras serán otras; te recomendamos medir tu propio trabajo con el esqueleto de medición de más abajo.

¿Qué diferencia hay entre asyncio, hilos y procesos en Python?

Python tiene tres herramientas, y la clave para elegir entre ellas es el GIL (Global Interpreter Lock). En el intérprete estándar CPython, el GIL permite que solo un hilo ejecute código Python a la vez. Pero un hilo libera el GIL mientras espera datos de la red. En consecuencia:

  • asyncio: un event loop que funciona en un solo hilo. Las tareas se ceden el control en los puntos await. Es la opción más ligera para cientos o incluso miles de peticiones de red concurrentes. La documentación de asyncio describe las piezas de este modelo. La librería también debe ser asíncrona: el AsyncClient de HTTPX o AIOHTTP.
  • Hilos (ThreadPoolExecutor): como el GIL se libera durante la espera de red, los hilos aceleran de verdad el trabajo de I/O. Son la forma más sencilla de añadir concurrencia con librerías síncronas como Requests. Un hilo cuesta más memoria que una tarea de asyncio, así que la eficiencia baja a partir de unos cientos de peticiones concurrentes.
  • Procesos (ProcessPoolExecutor, multiprocessing): cada proceso funciona con su propio intérprete y su propio GIL, así que dan paralelismo real en trabajo intensivo en CPU. Iniciar procesos y mover datos entre ellos es caro. La documentación de concurrent.futures define la interfaz común de los pools de hilos y de procesos.

Python 3.13 también introdujo una versión experimental que puede funcionar sin GIL; pero como la compatibilidad de las librerías aún es limitada, la versión estándar y las tres herramientas anteriores siguen siendo habituales en trabajos de scraping en producción.

Herramienta¿Da paralelismo?Trabajo adecuadoUso en scraping
asyncio + HTTPX/AIOHTTPNo, un solo hiloMuchas peticiones de redDescargar páginas
ThreadPoolExecutor + RequestsEn la práctica sí durante la I/OUn número moderado de peticiones, código síncronoAcelerar código Requests existente
ProcessPoolExecutorTrabajo intensivo en CPUAnalizar páginas grandes

Ejemplo 1: concurrencia limitada con asyncio.Semaphore

El código siguiente descarga páginas de forma concurrente, pero limita con el valor limit el número de peticiones abiertas a la vez. Sin el Semaphore, asyncio.gather inicia todas las peticiones de golpe, lo que satura tanto tu propio pool de conexiones como el sitio 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"))

Se comparte un único AsyncClient entre todas las peticiones. Así se reutilizan las conexiones y se evita un handshake TLS nuevo en cada petición.

Ejemplo 2: descarga concurrente, análisis en paralelo

Si las páginas son grandes y el análisis tarda un tiempo apreciable, tiene sentido combinar ambos modelos: descargar con asyncio y analizar en un pool de procesos.

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))

La línea if __name__ == "__main__": es obligatoria en Windows y macOS: el pool de procesos inicia procesos nuevos cargando el módulo desde el principio, y sin esta protección el código se ejecuta a sí mismo una y otra vez.

Conviene saber que este esquema no siempre acelera. Iniciar procesos y mover HTML entre ellos tiene un costo; si las páginas son pequeñas y pocas, ese costo puede superar al propio análisis. En la prueba anterior, el pool de procesos acortó claramente el análisis de páginas con 400 tarjetas, pero el costo de arranque de los procesos se sumó al tiempo total de descarga y análisis. Mide con tus propias páginas antes de decidir.

¿Cómo funciona el event loop de Node.js?

Node.js ejecuta el código JavaScript en un solo hilo y gestiona las operaciones de red con un event loop. Cuando llamas a fetch, Node.js entrega la petición al sistema operativo y el código JavaScript sigue adelante; cuando llega la respuesta, se encola el callback correspondiente. La guía del event loop de Node.js explica las fases de este bucle en detalle.

Para el scraping, esto tiene tres consecuencias:

  • Las peticiones de red son concurrentes por naturaleza. Cada petición basada en Promise no bloquea a las demás mientras espera. Es parecido al modelo asyncio de Python.
  • El código intensivo en CPU detiene todo el bucle. Mientras una función síncrona analiza un documento HTML grande, no se puede procesar ninguna otra respuesta. Para ese tipo de trabajo, usa hilos aparte con el módulo worker_threads o procesos separados.
  • La resolución de nombres de dominio puede ser un cuello de botella oculto. La función dns.lookup por defecto de Node.js ejecuta el resolvedor del sistema operativo en el pequeño pool de hilos de libuv. En un script que se conecta a muchos dominios distintos a la vez, ese pool puede llenarse. Cuando usas un proxy, los dominios de destino se resuelven en el lado del proxy, lo que reduce el efecto.

No necesitas una librería adicional para limitar la concurrencia; basta con un limitador sencillo:

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()))),
);

Cómo pasar un proxy en Node.js, con un ejemplo de rotación y reintentos, se explica en Cómo usar un proxy en Node.js.

¿Qué limita realmente la velocidad?

Subir la concurrencia aumenta la velocidad hasta cierto punto; a partir de ahí no tiene efecto o ralentiza el trabajo. En el scraping, el techo no suele ponerlo el procesador, sino estos factores:

  • El límite de velocidad del sitio de destino. Si el sitio permite cierto número de peticiones por minuto desde la misma fuente, subir la concurrencia solo produce más respuestas 429. Cómo gestionar estos códigos se trata en Códigos de estado HTTP en web scraping.
  • La capacidad del servidor de destino. Cuando el servidor de un sitio pequeño se ralentiza, tu concurrencia alarga sus tiempos de respuesta; se ralentizan tanto tu trabajo como los visitantes reales del sitio.
  • La capacidad del proxy. El límite de conexiones simultáneas del plan, el número de IP del pool y los tiempos de respuesta de los puntos de salida. Con un Proxies rotativos, que ofrece muchas IP de salida a través de una sola dirección, las peticiones se reparten entre direcciones distintas, pero el límite de conexiones del plan sigue aplicándose.
  • Ancho de banda. Al descargar páginas grandes y respuestas con imágenes, entra en juego el límite de tu conexión o del tráfico del proxy.
  • Costo de establecer conexiones. Si en cada petición se abre una conexión nueva con un handshake TLS nuevo, el tiempo aumenta. Compartir el cliente y reutilizar conexiones reduce ese costo.
  • Distancia. La latencia entre el punto de salida del proxy y el servidor de destino se suma a cada viaje de ida y vuelta. Con direcciones Proxies residenciales, que salen por conexiones domésticas reales, este tiempo varía según la ubicación y la línea.
  • El procesador. Solo pasa a ser decisivo al ejecutar navegadores headless, analizar documentos muy grandes o procesar muchos datos en la misma máquina.

Tratamos otras formas de ajustar la velocidad sin sufrir bloqueos ni perjudicar al sitio en Web scraping sin bloqueos.

¿Qué valor de concurrencia conviene?

No hay un número único para todos los trabajos; el valor correcto se encuentra midiendo. El método que recomendamos:

  1. Empieza con un valor bajo. Unas pocas peticiones simultáneas para un solo sitio de destino.
  2. Mide tres cosas. Páginas completadas por minuto, tiempo medio de respuesta y tasa de errores (429, 503, timeouts).
  3. Sube el valor poco a poco. Repite la misma medición en cada paso.
  4. Encuentra el punto de inflexión. Cuando las páginas completadas dejan de aumentar y el tiempo de respuesta o la tasa de errores empiezan a subir, vuelve al valor anterior.
  5. Fija un límite por sitio. Si rastreas varios sitios a la vez, la concurrencia total puede ser alta, pero la parte de cada sitio debe seguir siendo pequeña.
  6. Vuelve a medir de vez en cuando. La infraestructura y los límites de velocidad de los sitios de destino cambian con el tiempo.

Un esqueleto sencillo 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"  errores: {errors} / {len(pages)}")

time.perf_counter() es un contador de alta resolución que no se ve afectado por los cambios del reloj del sistema, y es más adecuado que time.time() para medir duraciones. Haz la medición con una lista pequeña de URL que no cargue el sitio de destino y, si es posible, en horas en que el sitio tenga poco tráfico.

Casos de uso

  • Pocas páginas de un solo sitio: Requests síncrono con pausas entre peticiones. No hace falta concurrencia.
  • Recogida de precios y catálogos de muchos sitios: concurrencia con un límite pequeño por sitio usando asyncio, y un proxy rotativo para repartir. La configuración general está en nuestra página de solución de extracción de datos.
  • Rastreo a gran escala: varios procesos, cada uno con asyncio; una cola por dominio. La parte del escalado está en nuestra página de solución de web crawler.
  • Acelerar un script de Requests existente: con ThreadPoolExecutor, sin reescribir el código. Para un esquema rotativo con Requests, consulta Cómo rotar proxies en Python.
  • Páginas cargadas con JavaScript: como un navegador headless usa mucha CPU y memoria, el número de navegadores simultáneos lo limitan los núcleos y la memoria.

Errores comunes

  • Repartir trabajo intensivo en I/O entre procesos. Pagas el costo de arranque y la memoria de los procesos, pero la velocidad no mejora porque el cuello de botella es la red.
  • Usar una librería síncrona dentro de asyncio. Una llamada requests.get() dentro de una async def detiene el event loop y todas las tareas se ejecutan una tras otra.
  • gather o Promise.all sin límite. Iniciar miles de peticiones a la vez provoca errores de conexión y límites de velocidad en el sitio de destino.
  • Crear un cliente nuevo para cada petición. No se reutilizan las conexiones y cada petición hace un handshake TLS nuevo.
  • Mirar el tiempo total pero no la tasa de errores. Un trabajo que se acelera mientras sube su tasa de 429 recoge menos datos.
  • Analizar en el hilo principal en Node.js. Con documentos grandes, el event loop se detiene y las respuestas en espera pueden dar timeout.

Guía de decisión

Tu situaciónRecomendación
Muchas páginas, HTML pequeñoasyncio + HTTPX/AIOHTTP, limitado con Semaphore
Código Requests síncrono existenteThreadPoolExecutor
Páginas grandes, análisis pesadoDescargar con asyncio, analizar con ProcessPoolExecutor
Descargar páginas con Node.jsPromise + limitador de concurrencia
Análisis pesado en Node.jsworker_threads
Navegador headlessPocos navegadores simultáneos según núcleos y memoria
La tasa de 429 subeBajar la concurrencia, fijar un límite por sitio
No hay mejora de velocidad ni erroresMedir el cuello de botella: proxy, ancho de banda, servidor de destino

Preguntas frecuentes

¿Concurrencia y paralelismo son lo mismo?

No. La concurrencia es que las tareas avancen en el mismo periodo y puede darse en un solo núcleo. El paralelismo es que las tareas se ejecuten realmente a la vez en varios núcleos. Todo sistema paralelo es concurrente, pero no todo sistema concurrente es paralelo.

¿asyncio o hilos para el scraping?

Si escribes desde cero y vas a enviar muchas peticiones, asyncio gestiona más peticiones concurrentes con menos recursos. Si tu código existente está escrito con Requests y las peticiones concurrentes no pasarán de unos cientos, ThreadPoolExecutor es la forma más sencilla de acelerarlo sin reescribir el código.

¿El GIL ralentiza el scraping?

Como el GIL se libera durante las peticiones de red, en la práctica no ralentiza la descarga de páginas. En pasos intensivos en CPU como el análisis, los hilos no pueden ejecutar código Python a la vez; para esos pasos se usa un pool de procesos.

¿Más IP de proxy aumentan la velocidad?

Si el sitio de destino cuenta su límite de velocidad por IP, repartir la carga puede permitirte llegar a más páginas en el mismo tiempo. Pero si el sitio aplica el límite con otros criterios, o el cuello de botella es el ancho de banda o la capacidad del servidor de destino, el número de IP no cambia el resultado. No perjudicar al sitio es una responsabilidad que se mantiene tengas las IP que tengas.

¿Qué pasa si pongo la concurrencia demasiado alta?

En tu lado aparecen límites del pool de conexiones y de descriptores de archivo; en el lado del destino, respuestas 429, timeouts y un sitio más lento. A partir de cierto punto, las páginas completadas dejan de aumentar y los errores aumentan.

¿Cómo se ajusta la concurrencia con navegadores headless?

Cada instancia de navegador usa bastante memoria y CPU, así que el número de páginas simultáneas lo limitan los recursos de la máquina y no la red. Empieza con pocas instancias de navegador y sube vigilando el uso de memoria y CPU. Cuándo hace falta realmente un navegador headless se explica en Páginas estáticas y dinámicas.

En resumen

La concurrencia aprovecha el tiempo de espera y el paralelismo, los núcleos del procesador. Como en el scraping la mayor parte del tiempo se va en esperar respuestas de red, la velocidad aumenta sobre todo con concurrencia limitada construida con asyncio, hilos o el event loop de Node.js; un pool de procesos solo hace falta para pasos intensivos en CPU, como el análisis pesado y los navegadores headless. El techo suelen ponerlo el límite de velocidad del sitio de destino, la capacidad del proxy y el ancho de banda. Fija la concurrencia midiendo, no adivinando, y pon un límite por sitio. Para recoger datos a escala, echa un vistazo a nuestros servicios de proxy.