Tutorials

ThreadPoolExecutor em Python para resolver CAPTCHA em paralelo

Se o seu script resolve CAPTCHAs um de cada vez, o gargalo não é a CaptchaAI — é o time.sleep esperando cada resposta antes da próxima. A correção não é migrar tudo para asyncio: é trocar o laço sequencial por um ThreadPoolExecutor, sobre o código síncrono que você já tem.

Por que o ThreadPoolExecutor é a escolha certa para CAPTCHA

Resolver CAPTCHA é I/O-bound — o tempo é gasto esperando respostas HTTP, não processando dados. Threads em Python liberam o GIL durante I/O, então o ThreadPoolExecutor entrega paralelismo real sem a complexidade de reescrever o projeto em async:

  • Sequencial — zero complexidade, nenhum paralelismo.
  • ThreadPoolExecutor — baixa complexidade, encaixa em código síncrono, bom paralelismo para I/O.
  • asyncio — exige reescrever a cadeia de chamadas em async; paralelismo máximo.
  • multiprocessing — overhead desnecessário para uma carga que é só espera de rede.

Implementação básica: enviar e consultar em paralelo

import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


def solve_captcha(sitekey, pageurl):
    """Synchronous CAPTCHA solve — submit and poll."""
    # Submit
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        raise RuntimeError(data.get("request", "Submit failed"))

    captcha_id = data["request"]

    # Poll for result
    for _ in range(60):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": captcha_id,
            "json": 1
        }).json()

        if result.get("status") == 1:
            return result["request"]
        if result.get("request") != "CAPCHA_NOT_READY":
            raise RuntimeError(result.get("request", "Unknown error"))

    raise TimeoutError("Solve timeout after 300s")


# Batch solve with ThreadPoolExecutor
tasks = [
    {"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-", "pageurl": f"https://example.com/page/{i}"}
    for i in range(20)
]

start = time.time()

with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {
        executor.submit(solve_captcha, t["sitekey"], t["pageurl"]): t
        for t in tasks
    }

    solved = 0
    failed = 0

    for future in as_completed(futures):
        task = futures[future]
        try:
            solution = future.result()
            solved += 1
            print(f"[OK] {task['pageurl']}: {solution[:30]}...")
        except Exception as e:
            failed += 1
            print(f"[ERR] {task['pageurl']}: {e}")

elapsed = time.time() - start
print(f"\nDone: {solved} solved, {failed} failed in {elapsed:.1f}s")

O que muda em relação a um laço for comum

solve_captcha é uma função síncrona comum — sem async def, sem await. Quem orquestra as 20 tarefas em 10 threads é o with ThreadPoolExecutor(...).

Por que enviar tudo antes de ler os resultados

executor.submit(...) dispara as 20 chamadas de imediato — é o que gera o paralelismo. Só depois o segundo laço lê cada future conforme termina.

Sessões por thread para reaproveitar conexões

Abrir uma conexão TCP nova a cada requisição desperdiça tempo com handshake. Compartilhe um requests.Session por thread — não uma sessão global, nem uma nova por chamada:

import threading

# Thread-local storage for sessions
thread_local = threading.local()


def get_session():
    """Get or create a thread-local session."""
    if not hasattr(thread_local, "session"):
        thread_local.session = requests.Session()
        # Configure connection pooling
        adapter = requests.adapters.HTTPAdapter(
            pool_connections=10,
            pool_maxsize=10,
            max_retries=2
        )
        thread_local.session.mount("https://", adapter)
    return thread_local.session


def solve_captcha_pooled(sitekey, pageurl):
    """Solve using thread-local connection pooling."""
    session = get_session()

    resp = session.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        raise RuntimeError(data.get("request"))

    captcha_id = data["request"]

    for _ in range(60):
        time.sleep(5)
        result = session.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": captcha_id,
            "json": 1
        }).json()

        if result.get("status") == 1:
            return result["request"]
        if result.get("request") != "CAPCHA_NOT_READY":
            raise RuntimeError(result.get("request"))

    raise TimeoutError("Solve timeout")

pool_maxsize deve acompanhar max_workers, ou você cria fila interna e perde parte do ganho.

map() para lotes simples, sem tratar erro tarefa a tarefa

Sem lógica de retentativa individual, executor.map() é mais direto que as_completed:

def solve_task(task):
    """Wrapper that returns result dict."""
    try:
        solution = solve_captcha_pooled(task["sitekey"], task["pageurl"])
        return {"url": task["pageurl"], "solution": solution, "error": None}
    except Exception as e:
        return {"url": task["pageurl"], "solution": None, "error": str(e)}


with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(solve_task, tasks))

solved = [r for r in results if r["solution"]]
failed = [r for r in results if r["error"]]
print(f"Solved: {len(solved)}, Failed: {len(failed)}")

Protegendo o pool contra tempo limite

Sem limite explícito, uma thread travada pode segurar o pool inteiro. Combine timeout global e por tarefa:

from concurrent.futures import TimeoutError as FuturesTimeout

with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {
        executor.submit(solve_captcha_pooled, t["sitekey"], t["pageurl"]): t
        for t in tasks
    }

    for future in as_completed(futures, timeout=600):  # 10 min global timeout
        task = futures[future]
        try:
            solution = future.result(timeout=120)  # 2 min per task
            print(f"[OK] {task['pageurl']}")
        except FuturesTimeout:
            print(f"[TIMEOUT] {task['pageurl']}")
        except Exception as e:
            print(f"[ERR] {task['pageurl']}: {e}")

Acompanhando o progresso em tempo real

Em pipelines com dezenas de tarefas, um contador evita a sensação de "travou":

import threading

progress_lock = threading.Lock()
progress = {"done": 0, "total": 0}


def solve_with_progress(task):
    result = solve_task(task)
    with progress_lock:
        progress["done"] += 1
        pct = progress["done"] / progress["total"] * 100
        print(f'\r  Progress: {progress["done"]}/{progress["total"]} ({pct:.0f}%)', end="")
    return result


progress["total"] = len(tasks)

with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(solve_with_progress, tasks))

print()  # Newline after progress

Quantos workers usar

Depende do plano contratado e do volume da fila:

  • 5 workers — overhead muito baixo; lotes pequenos, uso conservador.
  • 10 workers — overhead baixo; bom padrão geral.
  • 25 workers — overhead moderado; pipelines de alto volume.
  • 50 workers — overhead maior; só compensa em throughput máximo.

Mais workers = mais conexões simultâneas — cada plano define quantas threads você tem (BASIC inclui 5 threads por US$ 15/mês). Comece em max_workers=10 e suba conforme o plano permitir.

ThreadPoolExecutor ou asyncio: qual escolher

# ThreadPoolExecutor — drop into existing sync code
with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(solve_task, tasks))

# asyncio — requires async function chain
async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [solve_async(session, t) for t in task_list]
        results = await asyncio.gather(*tasks)
  • Use ThreadPoolExecutor com base síncrona, libs sem async (Selenium, alguns ORMs) ou para paralelismo rápido sem reestruturar.
  • Use asyncio em projetos novos, quando eficiência máxima importa, ou já dentro de um framework async (FastAPI, aiohttp).

E se os workers rodarem fora dos EUA?

Rodando de uma região como AWS sa-east-1 (São Paulo), a latência até o endpoint da CaptchaAI se soma ao tempo total — mas não muda o cálculo de max_workers, já que a thread libera o GIL nessa espera também. Meça o RTT antes de fixar um timeout agressivo por tarefa.

Problemas comuns e como resolver

  • Threads parecem travadas — cada uma está em time.sleep aguardando o polling; é esperado, o GIL é liberado durante o sleep.
  • Picos de ConnectionError — conexões simultâneas demais para o pool; reduza max_workers ou aumente pool_maxsize.
  • Resultados fora de ordemas_completed retorna por conclusão, não por envio; use map() para ordem preservada.
  • Memória crescendo — resultados grandes acumulados nos futures; processe cada item dentro do laço as_completed.

Perguntas frequentes

O GIL realmente permite paralelismo aqui, ou é ilusão?

É real. Durante chamadas de rede e time.sleep, o Python libera o GIL, e as threads avançam ao mesmo tempo. O GIL só vira gargalo em trabalho vinculado a CPU — não é o caso aqui.

Quantas threads devo configurar no ThreadPoolExecutor?

Comece em max_workers=10 e ajuste conforme o plano e a taxa de erro. Configurar mais workers do que threads o plano permite não ajuda — o excesso só fica na fila.

O ThreadPoolExecutor funciona com Selenium e outras libs sem suporte a async?

Sim — é a vantagem sobre asyncio. Sem API assíncrona nativa no Selenium, rodar várias instâncias dentro de um ThreadPoolExecutor paraleliza automação de navegador junto com a resolução de CAPTCHA.

Quantos CAPTCHAs dá para resolver por hora com esse padrão?

Com 10 workers e 15 segundos de tempo médio de resolução: cerca de 2.400 por hora. Com 25 workers: cerca de 6.000 por hora. O limite é o tempo de resolução por tipo de desafio, não o threading do Python.

Como começar agora

Pare de resolver CAPTCHA um por vez — obtenha sua chave de API da CaptchaAI e coloque o ThreadPoolExecutor no seu pipeline hoje mesmo.

Guias relacionados:

Os comentários estão desativados para este artigo.