Destino Dev

Blog

Downloader concorrente em Go: goroutines, channels, cancelamento e progresso

Por John Pires · 16 de março de 2026

Por que aprender isso em 2026

Contexto do problema

Em 2026 aplicações continuam exigindo operações de I/O eficientes: baixar múltiplos arquivos, consumir várias APIs ou processar dados em streams. Enquanto linguagens tradicionais dependem de threads pesadas ou bibliotecas externas, Go oferece primitivas nativas para concorrência que tornam implementações de download concorrente diretas, performáticas e seguras.

Para quem está começando, entender goroutines, channels e cancelamento é essencial porque esses conceitos aparecem em servidores HTTP, CLIs e pipelines de dados. Aprender hoje reduz fricção ao integrar código concorrente em projetos reais.

Benefícios práticos

Com as técnicas certas você consegue:

  • Maximizar uso de banda sem sobrecarregar CPU.
  • Gerenciar cancelamento limpo (Ctrl+C) com contexto.
  • Expor progresso granular para UI/CLI e logs.

O restante deste artigo descreve passo a passo do básico ao prático, com exemplos claros e executáveis em Go.

Configuração do ambiente

Instalando Go

Instale a versão estável do Go diretamente do site oficial: baixe o binário para seu sistema e siga as instruções. Verifique com go version que você tem uma versão >= 1.20. Configure GOPATH ou use módulos (recomendado) com go mod init.

Em 2026 a maioria dos projetos usa módulos Go, então trabalhe com um diretório limpo e inicialize um módulo com nome apropriado para o projeto.

Editor e ferramentas

Use um editor com suporte a goimports/gofmt para formatação automática. Instale gopls para autocompletar e análises estáticas. Tenha também delve se quiser debugar goroutines e goroutine stacks.

Para testar os exemplos, crie uma pasta, execute go mod init exemplo/downloader e cole os códigos abaixo em arquivos .go. Use go run para executar.

Hello World progressivo

Primeira goroutine e channels

Esta seção mostra uma versão simplificada de workers usando goroutines e channels. O objetivo é entender como enviar jobs para workers e receber resultados usando canais tipados. O exemplo funciona sem dependências e demonstra paralelismo cooperativo.

O código abaixo cria um pool de 3 workers que processa 5 jobs. Cada worker lê do canal jobs e envia resultado para results. Observe que goroutines são muito leves — você pode criar milhares delas em aplicações reais.

package main

import (
    "fmt"
    "time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("worker %d process job %d\n", id, j)
        time.Sleep(200 * time.Millisecond)
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 5)
    results := make(chan int, 5)

    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    for a := 1; a <= 5; a++ {
        fmt.Println(<-results)
    }
}

O que acontece: os workers consomem trabalhos de forma concorrente. Como os canais são tipados, o compilador ajuda a evitar erros de mix de tipos. Este padrão é a base para filas de download concorrente: um produtor coloca tarefas e vários consumidores as processam.

Progresso simples com canal

Além de enviar resultados, você pode emitir métricas de progresso por outro canal. O exemplo a seguir demonstra um goroutine que emite porcentagens em intervalos regulares e o loop principal que consome e imprime o progresso.

package main

import (
    "fmt"
    "time"
)

func main() {
    progress := make(chan int)
    go func() {
        for p := 0; p <= 100; p += 10 {
            progress <- p
            time.Sleep(100 * time.Millisecond)
        }
        close(progress)
    }()

    for p := range progress {
        fmt.Printf("progress: %d%%\n", p)
    }
}

O que acontece: o produtor envia atualizações de progresso e fecha o canal ao final. O consumidor lê até o fechamento, garantindo término limpo. Isso ilustra um padrão para relatar progresso de downloads.

Mini-projeto prático

Objetivo e comportamento

Construiremos um CLI simples que recebe URLs como argumentos, baixa os arquivos em paralelo, exibe progresso por arquivo e suporta cancelamento via Ctrl+C. O design prioriza clareza: sem frameworks externos, apenas stdlib Go.

Características:

  • Limitar número de downloads concorrentes com um semáforo.
  • Usar context.Context para cancelar requisições HTTP.
  • Relatar progresso de bytes por arquivo via canal.

Código completo do downloader

Este código é funcional. Salve em downloader.go e execute com go run downloader.go -parallel=4 https://example.com/file1 ....

package main

import (
    "context"
    "flag"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "os/signal"
    "path"
    "path/filepath"
    "sync"
    "time"
)

type Progress struct {
    URL       string
    Written   int64
    Total     int64
    Done      bool
    Err       error
}

type progressWriter struct {
    w     io.Writer
    url   string
    total int64
    ch    chan<- Progress
    mu    sync.Mutex
    written int64
}

func (pw *progressWriter) Write(p []byte) (int, error) {
    n, err := pw.w.Write(p)
    if n > 0 {
        pw.mu.Lock()
        pw.written += int64(n)
        written := pw.written
        pw.mu.Unlock()
        select {
        case pw.ch <- Progress{URL: pw.url, Written: written, Total: pw.total, Done: false}:
        default:
        }
    }
    return n, err
}

func download(ctx context.Context, client *http.Client, rawurl string, outDir string, ch chan<- Progress) {
    parsed, err := url.Parse(rawurl)
    if err != nil {
        ch <- Progress{URL: rawurl, Err: err, Done: true}
        return
    }

    req, _ := http.NewRequestWithContext(ctx, "GET", rawurl, nil)
    resp, err := client.Do(req)
    if err != nil {
        ch <- Progress{URL: rawurl, Err: err, Done: true}
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        ch <- Progress{URL: rawurl, Err: fmt.Errorf("status %d", resp.StatusCode), Done: true}
        return
    }

    filename := path.Base(parsed.Path)
    if filename == "" || filename == "/" {
        filename = "index.html"
    }
    outPath := filepath.Join(outDir, filename)
    f, err := os.Create(outPath)
    if err != nil {
        ch <- Progress{URL: rawurl, Err: err, Done: true}
        return
    }
    defer f.Close()

    pw := &progressWriter{w: f, url: rawurl, total: resp.ContentLength, ch: ch}
    _, err = io.Copy(pw, resp.Body)
    if err != nil {
        ch <- Progress{URL: rawurl, Err: err, Done: true}
        return
    }
    ch <- Progress{URL: rawurl, Written: pw.written, Total: pw.total, Done: true}
}

func main() {
    parallel := flag.Int("parallel", 4, "numero de downloads concorrentes")
    outDir := flag.String("out", ".", "diretorio de saida")
    flag.Parse()
    urls := flag.Args()

    if len(urls) == 0 {
        fmt.Println("uso: downloader -parallel=N url1 url2 ...")
        os.Exit(1)
    }

    os.MkdirAll(*outDir, 0755)

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt)
    go func() {
        <-sig
        fmt.Println("\ncancelando downloads...")
        cancel()
    }()

    client := &http.Client{Timeout: 0}
    sem := make(chan struct{}, *parallel)
    progressCh := make(chan Progress, 100)
    var wg sync.WaitGroup

    // Start a goroutine to print progress
    go func() {
        ticker := time.NewTicker(200 * time.Millisecond)
        defer ticker.Stop()
        latest := map[string]Progress{}
        for {
            select {
            case p := <-progressCh:
                if p.Err != nil {
                    fmt.Printf("%s: error: %v\n", p.URL, p.Err)
                    delete(latest, p.URL)
                    if p.Done {
                        // nothing
                    }
                } else {
                    latest[p.URL] = p
                }
                if p.Done {
                    fmt.Printf("%s: done (%d/%d)\n", p.URL, p.Written, p.Total)
                    delete(latest, p.URL)
                }
            case <-ticker.C:
                if len(latest) > 0 {
                    fmt.Println("status:")
                    for k, v := range latest {
                        var pct int64
                        if v.Total > 0 {
                            pct = v.Written * 100 / v.Total
                        }
                        fmt.Printf("  %s: %d/%d (%d%%)\n", k, v.Written, v.Total, pct)
                    }
                }
            case <-ctx.Done():
                return
            }
        }
    }()

    for _, u := range urls {
        select {
        case <-ctx.Done():
            break
        default:
        }
        wg.Add(1)
        sem <- struct{}{}
        go func(rawurl string) {
            defer wg.Done()
            defer func() { <-sem }()
            download(ctx, client, rawurl, *outDir, progressCh)
        }(u)
    }

    wg.Wait()
    // small sleep to let progress printer finish
    time.Sleep(300 * time.Millisecond)
}

O que acontece: o programa cria um semáforo (sem) para limitar concorrência, cada download é executado numa goroutine que respeita o context.Context. O writer customizado envia atualizações por canal de progresso. O loop principal imprime status periodicamente.

Como rodar e testar

Exemplo de execução rápida (substitua por URLs pequenos para teste):

  1. Salvar o arquivo downloader.go.
  2. Executar: go run downloader.go -parallel=3 https://speed.hetzner.de/100MB.bin https://speed.hetzner.de/10MB.bin.
  3. Pressionar Ctrl+C para testar cancelamento.

Quando cancelar, o contexto é propagado para requisições HTTP, que devem abortar se o servidor suportar cancelamento de conexão.

Erros comuns e armadilhas

Bloqueio por canal sem buffer

Programadores iniciantes frequentemente causam deadlocks ao escrever em canais sem consumidores. Use canais com buffer quando for aceitável perder mensagens antigas ou garanta que haja um consumidor sempre ativo.

Fugas de goroutine

Goroutines podem ficar presas se uma operação de escrita em canal bloquear eternamente. Em código de produção, prefira enviar mensagens não bloqueantes com select e caso default, ou feche canais corretamente.

Outra armadilha: esquecer de fechar o canal que sinaliza conclusão, o que provoca loops infinitos em consumidores que usam range.

Contexto e timeouts

Usar context.Background() sem timeout pode deixar downloads pendentes indefinidamente. Combine context.WithTimeout em operações onde tempo máximo seja conhecido. Para CLIs, cancelar com Ctrl+C e usar o contexto é a prática padrão.

Tip: prefira cancelar via context e deixar recursos (arquivos, conexões) serem fechados por defer. Evite usar variáveis globais para sinalizar cancelamento.

Recursos para continuar estudando

Documentação e tutoriais

Leia a documentação oficial de Go sobre goroutines, channels e context. Exemplos no site do Go e o Effective Go oferecem práticas recomendadas. Explore a net/http para entender cancelamento de requisições.

Projetos práticos

Pratique implementando variações: adicionar re-tentativas com backoff, limitar banda por download, integrar uma interface TUI para mostrar barras de progresso e escrever testes unitários simulando servidores HTTP.

Conclusão prática

Você aprendeu conceitos chave: goroutines para concorrência leve, channels para comunicação segura, semáforos implícitos com canais para limitar paralelismo e context para cancelamento. O mini-projeto mostra como juntar tudo num CLI funcional.

Próximo passo: adapte o downloader para suportar re-tentativas, limites de banda e logging mais robusto. Faça refatorações incrementais e escreva testes que simulem erros de rede para garantir resiliência.

Dominar esses blocos constrói a base para serviços concorrentes em Go, com comportamento previsível e controle de recursos — habilidades essenciais para produção.

Assuntos: Go, concurrency, goroutines, channels, cli

Leia também