Prática de Concorrência: Web Crawler
Prática de Concorrência: Web Crawler
Analogia
Imagine que você é um gerente de agência de viagens que precisa enviar múltiplas equipes para diferentes cidades simultaneamente para coletar informações de viagem:
- Limitação de taxa: A empresa tem veículos limitados, então no máximo 3 equipes podem ser despachadas por vez para evitar esgotamento de recursos
- Deduplicação: Equipes não revisitam lugares onde já estiveram
- Tentativa: Se uma equipe encontrar uma tempestade forte e não conseguir chegar ao destino, elas descansam e tentam novamente
- Desligamento gracioso: Quando é hora de fechar, equipes que já estão na estrada completam suas tarefas atuais antes de retornar, e nenhuma nova tarefa é atribuída
É exatamente assim que um web crawler concorrente funciona. Vamos implementá-lo em Go.
Requisitos do Projeto
Vamos desenvolver um web crawler concorrente com as seguintes capacidades:
- Rastreamento concorrente: Múltiplas goroutines rastreiam páginas web simultaneamente
- Limitação de taxa: Controla concorrência máxima para evitar sobrecarregar o servidor alvo
- Deduplicação: A mesma URL não é rastreada duas vezes
- Tentativa de erro: Tentativa automática em caso de falha de rastreamento com backoff exponencial
- Desligamento gracioso: Após receber sinal de interrupção, espera tarefas em execução completarem antes de sair
Design do Sistema
┌─────────────┐
│ URLs Semente │ Fila de URLs semente
└──────┬──────┘
▼
┌─────────────┐
│ Fila de URLs │ Channel de URLs a rastrear
│ (channel) │
└──────┬──────┘
▼
┌──────────────────────────────────────┐
│ Pool de Workers │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│ │Worker 1 │ │Worker 2 │ │Worker N│ │ Número limitado de workers
│ └────┬────┘ └────┬────┘ └───┬────┘ │
└───────┼───────────┼──────────┼───────┘
▼ ▼ ▼
┌─────────────────────────────────────┐
│ Channel de Resultados │ Agregação de resultados
│ ┌──────────┐ ┌──────────┐ │
│ │ Mapa de │ │ Tentativa│ │ Deduplicação + Tentativa
│ │ Dedup │ │ │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────┘
Exemplo 1: Código Completo
package main
import (
"context"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"os/signal"
"regexp"
"strings"
"sync"
"syscall"
"time"
)
// ============================================================
// Definições de Estruturas de Dados
// ============================================================
// CrawlTask representa uma tarefa de rastreamento
type CrawlTask struct {
URL string // URL alvo
Depth int // Profundidade atual
}
// CrawlResult representa o resultado de um rastreamento
type CrawlResult struct {
URL string // URL rastreada
Title string // Título da página (extração simplificada)
BodyLength int // Comprimento do conteúdo da página
Depth int // Profundidade de rastreamento
Err error // Informação de erro (se houver)
Latency time.Duration // Duração do rastreamento
}
// CrawlerConfig é a configuração do crawler
type CrawlerConfig struct {
MaxConcurrency int // Concorrência máxima
MaxDepth int // Profundidade máxima de rastreamento
MaxRetries int // Contagem máxima de tentativas
RequestTimeout time.Duration // Timeout por requisição
RateInterval time.Duration // Intervalo de limitação de taxa
}
// DefaultConfig retorna a configuração padrão
func DefaultConfig() CrawlerConfig {
return CrawlerConfig{
MaxConcurrency: 3,
MaxDepth: 2,
MaxRetries: 3,
RequestTimeout: 10 * time.Second,
RateInterval: 500 * time.Millisecond,
}
}
// ============================================================
// Deduplicador (conjunto de URLs visitadas thread-safe)
// ============================================================
// Deduplicator lida com deduplicação de URLs
type Deduplicator struct {
visited map[string]bool
mu sync.Mutex
}
// NewDeduplicator cria um deduplicador
func NewDeduplicator() *Deduplicator {
return &Deduplicator{
visited: make(map[string]bool),
}
}
// Mark marca uma URL como visitada; retorna true se for uma nova URL
func (d *Deduplicator) Mark(url string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.visited[url] {
return false // Já visitada
}
d.visited[url] = true
return true // Nova URL
}
// Count retorna o número de URLs visitadas
func (d *Deduplicator) Count() int {
d.mu.Lock()
defer d.mu.Unlock()
return len(d.visited)
}
// ============================================================
// Limitador de Taxa (controle de concorrência baseado em semáforo)
// ============================================================
// RateLimiter é um limitador de taxa baseado em semáforo de channel
type RateLimiter struct {
semaphore chan struct{}
interval time.Duration
}
// NewRateLimiter cria um limitador de taxa
// maxConcurrency: concorrência máxima
// interval: intervalo mínimo entre duas requisições
func NewRateLimiter(maxConcurrency int, interval time.Duration) *RateLimiter {
return &RateLimiter{
semaphore: make(chan struct{}, maxConcurrency),
interval: interval,
}
}
// Acquire adquire uma permissão de execução
func (rl *RateLimiter) Acquire() {
rl.semaphore <- struct{}{} // Envia para channel com buffer; bloqueia quando cheio
}
// Release libera uma permissão de execução
func (rl *RateLimiter) Release() {
time.Sleep(rl.interval) // Limitação de taxa: mantém intervalo mínimo
<-rl.semaphore // Recebe do channel com buffer, liberando espaço
}
// ============================================================
// Buscador Web
// ============================================================
// Fetcher lida com requisições HTTP reais
type Fetcher struct {
client *http.Client
}
// NewFetcher cria um buscador
func NewFetcher(timeout time.Duration) *Fetcher {
return &Fetcher{
client: &http.Client{
Timeout: timeout,
},
}
}
// Fetch busca a URL especificada e retorna o título e comprimento do conteúdo
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (title string, bodyLen int, err error) {
// Cria uma requisição com contexto para suporte a cancelamento
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", 0, fmt.Errorf("falha ao criar requisição: %w", err)
}
// Define User-Agent para simular um navegador
req.Header.Set("User-Agent", "GoCrawler/1.0")
resp, err := f.client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("requisição falhou: %w", err)
}
defer resp.Body.Close()
// Verifica código de status HTTP
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("código de status HTTP: %d", resp.StatusCode)
}
// Lê corpo da resposta (limita tamanho para prevenir estouro de memória)
const maxSize = 1 << 20 // 1MB
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
if err != nil {
return "", 0, fmt.Errorf("falha ao ler resposta: %w", err)
}
// Extração simples do conteúdo da tag <title>
title = extractTitle(string(body))
return title, len(body), nil
}
// extractTitle extrai o título do HTML
func extractTitle(html string) string {
re := regexp.MustCompile(`(?i)<title>(.*?)</title>`)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return "(sem título)"
}
// ============================================================
// Extrator de Links
// ============================================================
// ExtractLinks extrai todos os links do HTML (versão simplificada)
func ExtractLinks(body string, baseURL string) []string {
re := regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
matches := re.FindAllStringSubmatch(body, -1)
var links []string
base, err := url.Parse(baseURL)
if err != nil {
return links
}
for _, match := range matches {
if len(match) < 2 {
continue
}
href := match[1]
// Analisa URL relativa
parsed, err := url.Parse(href)
if err != nil {
continue
}
// Converte para URL absoluta
absolute := base.ResolveReference(parsed)
// Mantém apenas links HTTP/HTTPS
if absolute.Scheme == "http" || absolute.Scheme == "https" {
// Remove fragmento (#âncora)
absolute.Fragment = ""
links = append(links, absolute.String())
}
}
return links
}
// ============================================================
// Engine do Crawler
// ============================================================
// Crawler é o engine principal do crawler
type Crawler struct {
config CrawlerConfig
fetcher *Fetcher
dedup *Deduplicator
limiter *RateLimiter
taskCh chan CrawlTask // Fila de tarefas
resultCh chan CrawlResult // Channel de resultados
wg sync.WaitGroup // Espera todos os workers completarem
}
// NewCrawler cria um crawler
func NewCrawler(config CrawlerConfig) *Crawler {
return &Crawler{
config: config,
fetcher: NewFetcher(config.RequestTimeout),
dedup: NewDeduplicator(),
limiter: NewRateLimiter(config.MaxConcurrency, config.RateInterval),
taskCh: make(chan CrawlTask, 100),
resultCh: make(chan CrawlResult, 100),
}
}
// CrawlWithRetry realiza um rastreamento com tentativa (backoff exponencial)
func (c *Crawler) CrawlWithRetry(ctx context.Context, task CrawlTask) CrawlResult {
var lastErr error
for attempt := 0; attempt <= c.config.MaxRetries; attempt++ {
// Verifica se o contexto foi cancelado
select {
case <-ctx.Done():
return CrawlResult{
URL: task.URL,
Depth: task.Depth,
Err: ctx.Err(),
}
default:
}
// Nenhuma espera necessária para a primeira tentativa
if attempt > 0 {
// Backoff exponencial: 1s, 2s, 4s ...
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
fmt.Printf(" ↻ Tentativa %d/%d: %s (esperando %v)\n",
attempt, c.config.MaxRetries, task.URL, backoff)
select {
case <-time.After(backoff):
case <-ctx.Done():
return CrawlResult{
URL: task.URL,
Depth: task.Depth,
Err: ctx.Err(),
}
}
}
start := time.Now()
title, bodyLen, err := c.fetcher.Fetch(ctx, task.URL)
latency := time.Since(start)
if err == nil {
// Sucesso
return CrawlResult{
URL: task.URL,
Title: title,
BodyLength: bodyLen,
Depth: task.Depth,
Latency: latency,
}
}
lastErr = err
}
return CrawlResult{
URL: task.URL,
Depth: task.Depth,
Err: fmt.Errorf("falhou após %d tentativas: %w", c.config.MaxRetries, lastErr),
}
}
// Worker é a goroutine worker
func (c *Crawler) Worker(ctx context.Context, id int) {
defer c.wg.Done()
fmt.Printf("[Worker %d] Iniciado\n", id)
for task := range c.taskCh {
// Verifica contexto
select {
case <-ctx.Done():
fmt.Printf("[Worker %d] Sinal de saída recebido, parando\n", id)
return
default:
}
// Limitação de taxa: adquire permissão
c.limiter.Acquire()
fmt.Printf("[Worker %d] Rastreando: %s (profundidade %d)\n", id, task.URL, task.Depth)
// Executa rastreamento (com tentativa)
result := c.CrawlWithRetry(ctx, task)
// Libera permissão
c.limiter.Release()
// Envia resultado
select {
case c.resultCh <- result:
case <-ctx.Done():
return
}
}
fmt.Printf("[Worker %d] Saindo\n", id)
}
// Start inicia o crawler
func (c *Crawler) Start(ctx context.Context, seedURLs []string) {
// Inicia pool de workers
for i := 0; i < c.config.MaxConcurrency; i++ {
c.wg.Add(1)
go c.Worker(ctx, i)
}
// Submete URLs semente
go func() {
for _, rawURL := range seedURLs {
if c.dedup.Mark(rawURL) {
c.taskCh <- CrawlTask{URL: rawURL, Depth: 0}
}
}
}()
// Inicia goroutine de processamento de resultados e descoberta de links
go c.processResults(ctx)
}
// processResults processa resultados de rastreamento e descobre novos links
func (c *Crawler) processResults(ctx context.Context) {
for result := range c.resultCh {
if result.Err != nil {
fmt.Printf("✗ Falhou: %s — %v\n", result.URL, result.Err)
continue
}
fmt.Printf("✓ Sucesso: %s\n", result.URL)
fmt.Printf(" Título: %s\n", result.Title)
fmt.Printf(" Tamanho: %d bytes | Tempo: %v\n", result.BodyLength, result.Latency)
// Se não atingiu profundidade máxima, pode continuar descobrindo links
// (Simplificado aqui; em produção, você re-rastrearia a página para obter HTML para extração de links)
if result.Depth < c.config.MaxDepth {
// Demo: posta novos links na fila de tarefas
// Em produção, você extrairia links do HTML do resultado aqui
fmt.Printf(" Profundidade %d/%d, pode continuar descobrindo links filhos\n", result.Depth, c.config.MaxDepth)
}
}
}
// Wait espera todas as tarefas completarem
func (c *Crawler) Wait() {
// Fecha channel de tarefas, notifica workers que não há mais tarefas
close(c.taskCh)
// Espera todos os workers saírem
c.wg.Wait()
// Fecha channel de resultados
close(c.resultCh)
}
// Stats retorna estatísticas do crawler
func (c *Crawler) Stats() (visited int) {
return c.dedup.Count()
}
// ============================================================
// Programa Principal
// ============================================================
func main() {
fmt.Println("========================================")
fmt.Println(" Go Web Crawler Concorrente v1.0")
fmt.Println("========================================")
fmt.Println()
// Carrega configuração
config := DefaultConfig()
// Pode ser sobrescrito via argumentos de linha de comando ou arquivo de configuração
// config.MaxConcurrency = 5
// Cria um contexto cancelável
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Captura sinal de interrupção do sistema (Ctrl+C) para desligamento gracioso
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
fmt.Printf("\n⚠ Sinal recebido: %v, encerrando graciosamente...\n", sig)
cancel() // Cancela contexto, notifica todos os workers
}()
// Cria crawler
crawler := NewCrawler(config)
// Lista de URLs semente
seedURLs := []string{
"https://httpbin.org/html",
"https://httpbin.org/links/5",
"https://httpbin.org/range/100",
"https://httpbin.org/delay/1",
}
fmt.Printf("Configuração: concorrência=%d, profundidade=%d, tentativas=%d\n",
config.MaxConcurrency, config.MaxDepth, config.MaxRetries)
fmt.Printf("URLs semente: %d\n", len(seedURLs))
fmt.Println("----------------------------------------")
// Inicia crawler
crawler.Start(ctx, seedURLs)
// Desligamento automático após um tempo (em produção, use outras condições)
go func() {
time.Sleep(30 * time.Second)
fmt.Println("\n⏱ Timeout, acionando desligamento...")
cancel()
}()
// Espera todas as tarefas completarem
crawler.Wait()
// Exibe estatísticas
fmt.Println("----------------------------------------")
fmt.Printf("Rastreamento concluído! Visitou %d URLs\n", crawler.Stats())
}
Análise do Código
1. Limitação de Taxa: Channel de Semáforo
// Channel com buffer como semáforo
semaphore: make(chan struct{}, maxConcurrency)
// Adquire permissão: bloqueia quando channel está cheio
rl.semaphore <- struct{}{}
// Libera permissão
<-rl.semaphore
Um channel com tamanho de buffer N permite que no máximo N goroutines mantenham permissões simultaneamente; a goroutine N+1 será bloqueada. Isso controla a concorrência com mais precisão que time.Ticker.
2. Deduplicação: Mapa Protegido por Mutex
func (d *Deduplicator) Mark(url string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.visited[url] {
return false
}
d.visited[url] = true
return true
}
Múltiplas goroutines podem descobrir a mesma URL simultaneamente; o método Mark usa um mutex para garantir atomicidade — apenas uma goroutine pode "reivindicar" aquela URL.
3. Tentativa de Erro: Backoff Exponencial
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
| Tentativa # | Tempo de Espera |
|---|---|
| 1ª | 1 segundo |
| 2ª | 2 segundos |
| 3ª | 4 segundos |
Backoff exponencial previne tentativas em "estilo avalanche" durante falhas do servidor, dando tempo ao servidor para se recuperar.
4. Desligamento Gracioso: Context + Sinal
ctx, cancel := context.WithCancel(context.Background())
// Escuta sinais do sistema
go func() {
<-sigCh
cancel() // Cancela contexto
}()
// Worker verifica contexto
select {
case <-ctx.Done():
return // Sai
default:
// Continua trabalhando
}
cancel() notifica todas as goroutines escutando ctx.Done(), alcançando desligamento coordenado.
5. Fluxo Geral de Colaboração
main()
│
├─ Cria contexto (cancelável)
├─ Inicia N goroutines Worker
├─ Submete URLs semente ao taskCh
├─ Inicia goroutine processResults
│
├─ Espera...
│ ├─ Worker pega tarefas do taskCh
│ ├─ Worker limita taxa → rastreia → tenta novamente
│ ├─ Worker envia resultados ao resultCh
│ └─ processResults processa resultados, descobre novos links
│
└─ cancel() dispara → Todos os workers saem → Programa encerra
❓ Perguntas Frequentes
P1: Por que usar um channel em vez de sync.Mutex para controle de concorrência?
Channels não apenas fornecem exclusão mútua mas também comunicação. Um channel de semáforo combina naturalmente "limitação de concorrência" e "passagem de tarefas" — quando o channel está cheio, novas goroutines esperam automaticamente; quando uma goroutine libera, as que estão esperando são automaticamente acordadas. Isso é mais conciso do que usar Mutex + WaitGroup separadamente. Além disso, channels suportam select, facilitando combinar com context.Done() para bloqueio cancelável.
P2: Por que deduplicar no momento da submissão da tarefa em vez de antes do rastreamento?
Na verdade, ambos são necessários. Deduplicar na submissão da tarefa previne que tarefas duplicadas sejam inseridas no channel, economizando memória e tempo de processamento. Porém, se múltiplas goroutines descobrirem o mesmo novo link quase simultaneamente, a deduplicação apenas na submissão ainda pode ter "corridas" — então o método Mark deve ser thread-safe. Em crawlers de grande escala, um Bloom Filter é tipicamente usado para lidar eficientemente com deduplicação massiva de URLs.
P3: Por que não simplesmente matar o programa? Por que "desligamento gracioso"?
Matar forçadamente pode causar: arquivos corrompidos sendo escritos, conexões de banco de dados não liberadas, requisições incompletas alcançando o servidor alvo. Desligamento gracioso permite que cada worker complete sua tarefa atual antes de sair, garantindo consistência de dados. context.WithCancel é o padrão do Go para implementar desligamento gracioso.
P4: Este crawler pode lidar com páginas renderizadas por JavaScript?
Não. net/http apenas busca HTML bruto sem executar JavaScript. Se você precisa rastrear SPAs (Aplicações de Página Única), precisará integrar um navegador headless como Chromedp ou Rod. A arquitetura neste tutorial é extensível — basta substituir a implementação do Fetcher.
📖 Resumo
Esta seção aplicou abrangentemente múltiplos conceitos centrais de concorrência do Go através de um projeto completo de web crawler:
| Conceito | Aplicação | Código-chave |
|---|---|---|
| goroutine | Múltiplos workers trabalhando em paralelo | go c.Worker(ctx, i) |
| channel | Fila de tarefas, passagem de resultados, semáforo | taskCh, resultCh, semaphore |
| sync.Mutex | Protege segurança de concorrência do mapa de deduplicação | d.mu.Lock() |
| context | Propagação de cancelamento, desligamento gracioso | ctx.WithCancel |
| select | Multiplexação, controle de timeout | select { case <-ctx.Done() ... } |
| sync.WaitGroup | Espera todos os workers completarem | c.wg.Wait() |
Estes componentes trabalham juntos para formar um sistema concorrente robusto. Dominar este padrão de combinação "channel + context + WaitGroup" é a base para escrever programas Go concorrentes de nível de produção.
📝 Exercícios
Exercício 1: Adicionar Extração Profunda de Links
Atualmente, processResults apenas imprime informações de profundidade sem realmente extrair links filhos. Modifique o código para extrair links do HTML após um rastreamento bem-sucedido e postar novas URLs no taskCh.
Dica: Você precisará modificar o método Fetch para também retornar o conteúdo HTML, depois chamar ExtractLinks em processResults.
Exercício 2: Implementar Persistência de Resultados
Adicione uma struct Saver que escreve resultados de rastreamento em um arquivo em formato JSON. Requisitos:
- Use uma goroutine separada para ler do
resultChe escrever no arquivo - Um registro por linha (formato JSON Lines)
- Suporte a escrita concorrente segura
Exercício 3: Implementar Limitação de Taxa por Domínio
A limitação de taxa atual é global. Implemente um limitador de taxa agrupado por domínio que:
- Permita no máximo 2 requisições por segundo para o mesmo domínio (ex:
example.com) - Diferentes domínios não afetam uns aos outros
- Dica: mantenha um
map[string]*RateLimiter
Próxima Lição
Após completar este exercício prático, continue com Lição 19: Processamento de Strings para dominar os internos de strings do Go, operações comuns e técnicas de otimização de desempenho.



