API REST em Go: worker pool com backpressure, Context e desligamento gracioso
Por John Pires · 16 de março de 2026

API REST em Go: worker pool com backpressure, Context e desligamento gracioso
1. Motivacao e trade-offs
APIs REST recebem carga imprevisível. Um pico de requisições pode gerar explosão de goroutines, aumentar latência e saturar recursos (CPU, memória, I/O externo). A solução clássica em Go — "lançar uma goroutine por requisição" — é simples e muitas vezes funciona, mas não oferece controle sobre concorrência ou backpressure.
Neste artigo, faço code review: primeiro mostro o jeito errado (anti-patterns comuns), depois refatoro para o jeito certo, com um worker pool que aplica backpressure, usa context.Context corretamente e implementa desligamento gracioso em um servidor HTTP.
Trade-offs importantes:
- Complexidade vs controle: worker pool adiciona código e complexidade, mas evita sobrecarga e garante limites.
- Latência vs throughput: filas longas aumentam throughput mas penalizam latência; backpressure evita filas gigantes.
- Cancelamento cooperativo: Context facilita abortar trabalho em cascata, mas requer que o trabalho verifique o Context.
Quando não usar worker pool
Não use worker pool quando o trabalho por requisição é trivial, de baixa latência e o ambiente já tem escalonamento automático eficiente. Também evite se a implementação internaliza bloqueios que geram head-of-line blocking.
Quando usar
Use quando tarefas são custosas (I/O externo, disco, processamento), quando recursos são limitados, ou quando você precisa impor políticas de qualidade de serviço (reject, queue limit, timeouts).
Tip: backpressure é uma ferramenta de proteção. Se você rejeita trabalho agressivamente, combine com métricas e retrys exponenciais no cliente.
2. Fundamentos tecnicos
Conceitos que vamos aplicar e revisar:
- worker pool: um conjunto fixo/variável de workers consumindo tarefas de uma fila.
- bounded queue: canal com capacidade limitada para aplicar backpressure.
- Context: Propagação de cancelamento, deadlines e valores por requisição.
- graceful shutdown: parar de aceitar novas requisições, terminar trabalho em andamento ou abortar com timeout.
Backpressure via canais
Em Go, um canal bufferizado age como fila. Se o buffer enche, o produtor que tenta enviar fica bloqueado — esse é o ponto central do backpressure. Alternativa: envio não bloqueante com select e caso de fallback (reject ou responder 429).
Context e cancelamento
Context deve ser passado às operações de I/O e funções que realizam trabalho, para que possamos cancelar tarefas quando o cliente aborta ou o servidor está desligando. Não use context.Background() dentro do processamento; derive do context da requisição.
Exigimos que os handlers e workers cooperem com cancelamento: checando ctx.Done() ou usando funções que aceitam context.
3. Exemplo 1: caso simples
Neste primeiro exemplo mostro o anti-padrão: aceitar todas as requisições criando uma goroutine por requisição e sem backpressure nem uso de Context adequado. Vou comentar os problemas e então apresentar a versão corrigida.
Anti-padrão: uma goroutine por requisição
O código abaixo implementa um servidor que processa pedidos lançando uma goroutine por requisição. É simples, mas perigoso sob carga.
O que faz: cria servidor HTTP que, para cada POST, lança uma goroutine que faz trabalho simulado (sleep) e retorna imediatamente 202. Sem fila limitada, sem Context no trabalho e sem controle sobre shutdown.
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func workSimulated() {
// trabalho pesado simulado
time.Sleep(2 * time.Second)
// atualizaria DB, chamaria serviço externo, etc
}
func handler(w http.ResponseWriter, r *http.Request) {
// não checa r.Context(), cria goroutine que pode continuar além do request
go func() {
workSimulated()
fmt.Println("done")
}()
w.WriteHeader(http.StatusAccepted)
}
func main() {
http.HandleFunc("/task", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
O que acontece: sob carga, milhares de goroutines são criadas. Se o trabalho envolve I/O bloqueante, você pode exaurir conexões do sistema, memória ou o pool de file descriptors. Não há maneira de cancelar tarefas quando o servidor precisa desligar.
Versão corrigida: worker pool com backpressure e Context
A seguir uma implementação direta: servidor HTTP enfileira jobs num canal bufferizado; se o canal estiver cheio, devolve 429 (Too Many Requests). Os workers processam jobs respeitando o context do request e usamos WaitGroup para aguardar trabalho em andamento durante o shutdown.
O que faz: cria um pool fixe de workers, fila com capacidade limitada, envia jobs de forma bloqueante com timeout curto e usa request context para cancelar trabalho em andamento.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Job struct {
ID string `json:"id"`
Payload string `json:"payload"`
Ctx context.Context `json:"-"`
}
func worker(id int, jobs <-chan Job, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
select {
case <-job.Ctx.Done():
log.Printf("worker %d: job %s canceled\n", id, job.ID)
continue
default:
}
// trabalho que respeita o ctx
process(job)
}
}
func process(job Job) {
// Simula operação que respeita o Context via select
select {
case <-time.After(2 * time.Second):
log.Printf("processed %s\n", job.ID)
case <-job.Ctx.Done():
log.Printf("processing canceled %s\n", job.ID)
}
}
func main() {
jobs := make(chan Job, 100) // fila limitada
var wg sync.WaitGroup
workerCount := 10
wg.Add(workerCount)
for i := 0; i < workerCount; i++ {
go worker(i, jobs, &wg)
}
http.HandleFunc("/task", func(w http.ResponseWriter, r *http.Request) {
var payload struct{ ID, Payload string }
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
job := Job{ID: payload.ID, Payload: payload.Payload, Ctx: r.Context()}
// tentativa de enfileirar sem bloquear indefinidamente
select {
case jobs <- job:
w.WriteHeader(http.StatusAccepted)
case <-time.After(100 * time.Millisecond):
w.WriteHeader(http.StatusTooManyRequests)
}
})
// graceful shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-stop
log.Println("shutdown: stop accepting new requests")
// fecha o canal para sinalizar workers
close(jobs)
}()
log.Println("server running :8080")
http.ListenAndServe(":8080", nil)
// esperar workers terminarem
wg.Wait()
log.Println("shutdown complete")
}
O que acontece: requisições excedentes recebem 429. Cada job carrega o contexto da requisição, então se o cliente desconectar, o trabalho pode ser cancelado. No shutdown, fechamos o canal de jobs, interrompendo a aceitação de novos trabalhos, e esperamos os workers terminarem seu loop.
4. Exemplo 2: caso avancado do mundo real
Agora um caso avançado: uma API REST que recebe tarefas para consultar um serviço externo com taxa limitada (rate-limited API). Requisitos:
- Limitar concorrência total de requests simultâneas a 50.
- Aplicar backpressure: fila limitada de 200, rejeitando quando cheia.
- Cada job tem deadline de 10s e deve abortar se exceder.
- Desligamento gracioso com timeout global de 30s para terminar trabalhos em progresso.
Estratégia
Componentes:
- Servidor HTTP que enfileira jobs via canal bufferizado.
- Pool de workers que consome jobs, cada worker usa um context com timeout por job.
- Semáforo (canal vazio) para limitar concorrência total a 50.
- Mecanismo de shutdown que para de aceitar novas requisições e espera até 30s para terminar trabalho.
Abaixo um exemplo funcional simplificado. Ajuste timeouts, tratamento de erros, e logging conforme necessidade.
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Task struct{
ID string `json:"id"`
URL string `json:"url"`
}
func externalCall(ctx context.Context, url string) (string, error) {
// Simula chamada externa sensível a contexto
select {
case <-time.After(500 * time.Millisecond):
return "ok", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func worker(id int, jobs <-chan Task, sem chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for task := range jobs {
// pega semáforo para limitar concorrência total
sem <- struct{}{}
func(t Task) {
defer func(){ <-sem }()
// cada job tem deadline
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := externalCall(ctx, t.URL)
if err != nil {
log.Printf("worker %d: task %s failed: %v\n", id, t.ID, err)
return
}
log.Printf("worker %d: task %s result=%s\n", id, t.ID, res)
}(task)
}
}
func main(){
jobs := make(chan Task, 200)
sem := make(chan struct{}, 50) // limite de concorrencia total
var wg sync.WaitGroup
workerCount := 20
wg.Add(workerCount)
for i:=0;i<workerCount;i++{
go worker(i, jobs, sem, &wg)
}
http.HandleFunc("/fetch", func(w http.ResponseWriter, r *http.Request){
var t Task
if err := json.NewDecoder(r.Body).Decode(&t); err!=nil{
w.WriteHeader(http.StatusBadRequest)
return
}
// cria job com contexto do request: se cliente desconectar, podemos cancelar
select{
case jobs <- t:
w.WriteHeader(http.StatusAccepted)
case <-time.After(100 * time.Millisecond):
w.WriteHeader(http.StatusTooManyRequests)
}
})
stop := make(chan os.Signal,1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
server := &http.Server{Addr: ":8080"}
go func(){
if err := server.ListenAndServe(); err!=nil && !errors.Is(err, http.ErrServerClosed){
log.Fatalf("listen failed: %v", err)
}
}()
<-stop
log.Println("shutdown requested")
// parar de aceitar conexoes novas e dar 30s para complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
// fechar canal de jobs sinaliza fim para workers
close(jobs)
wg.Wait()
log.Println("shutdown complete")
}
O que acontece: a combinação de canal bufferizado e selecão com timeout aplica backpressure na borda. O semáforo garante que, mesmo com muitos workers, apenas N chamadas externas ocorrem simultaneamente. Shutdown usa server.Shutdown para parar aceitar conexões e espera até 30s para completar; em paralelo, fechamos o canal de jobs e aguardamos workers terminarem.
Atenção com context.Background() dentro de worker
No exemplo usei context.WithTimeout(context.Background(), ...) dentro do worker para dar deadline por job; porém, idealmente combine esse contexto com o contexto da requisição (se disponível) para que cancelamentos do cliente se propaguem. Uma forma robusta é armazenar o ctx do request no Job e usar context.WithTimeout(ctx, 10s).
5. Debugging e troubleshooting
Lista de problemas reais que você encontrará e como diagnosticar:
- Memória crescendo: provavelmente filas acumulando ou goroutines vazando. Procure goroutines bloqueadas no stack trace (runtime.Stack).
- Tasks que não respeitam cancelamento: verifique chamadas I/O que não aceitam Context; reescreva para usar versões context-aware.
- Shutdown que nunca termina: verifique se você fechou o canal de jobs e se os workers decrementam WaitGroup corretamente.
- 405/429 inesperados: ajuste tamanho da fila, timeout do select ao enfileirar, ou ofereça endpoints de fallback.
Ferramentas práticas
Use pprof para analisar goroutines e heap; runtime/pprof e net/http/pprof no servidor ajudam. Para encontrar goroutine leaks, capture goroutine dump durante pico e busque padrões repetidos.
Logging estruturado e métricas são essenciais: instrumente filas (length), taxa de rejeição (429), tempo médio de processamento e número de workers ativos.
Dica: exponha métricas Prometheus para queue_len, workers_active e job_latency. Essas três métricas ajudam a controlar decisões de backpressure em tempo real.
Erros comuns e armadilhas
Erro 1: fechar o canal de jobs antes de garantir que não haverá mais envios pode causar panic. Sempre organize shutdown para primeiro parar de aceitar requisições (server.Shutdown) antes de close(jobs).
Erro 2: usar WaitGroup sem defer wg.Done() logo no início do worker — se houver um panic você pode não decrementar e travar shutdown. Sempre defina wg.Add(...) e wg.Done() de forma consistente.
Erro 3: tratar o contexto apenas no handler e não no trabalho; se o handler retorna e você cria um contexto novo sem vínculo, não consegue cancelar um trabalho quando o cliente desconecta.
Conclusao e recomendações praticas
Recapitulando o review: evitar criar uma goroutine por requisição sem limite; prefira um worker pool com fila limitada e política de rejeição clara. Use canais bufferizados para backpressure e semáforos quando precisar limitar chamadas a recursos externos.
Reforço as práticas que devem ser padrão em serviços Go em produção:
- Propague context.Context desde o handler até as operações I/O.
- Use filas com capacidade controlada e políticas de rejeição (429) ou filas de prioridade conforme SLA.
- Implemente graceful shutdown: pare de aceitar requisições, libere recursos e dê um timeout razoável para completar trabalho.
- Instrumente com métricas: fila, rejeições, latência e workers.
Se ficou uma lição prática: backpressure é sua primeira linha de defesa. Combine com Context para cancelamento cooperativo e com um shutdown que siga uma ordem segura: parar aceitação -> esperar trabalhos -> forçar término se timeout.
Próximo passo: pegue os exemplos aqui, rode em ambiente de teste, injete carga (wrk/hey) e observe o comportamento com pprof. Ajuste tamanhos de fila e contagem de workers com base em métricas observadas, não por feeling.
Assuntos: Go, concurrency, worker-pool, context, graceful-shutdown
Leia também
Cache LRU concorrente em Go: singleflight, sharding e benchmarks práticos
Implementação prática de cache LRU concorrente em Go: comparo mutex global, sharding e singleflight, mostro código funcional, benchmarks e armadilhas para…
Diagnóstico e redução de tail-latency em Go: pprof, contenção e GOMAXPROCS
Como identificar e reduzir tail-latency em Go: pprof, contenção (locks, sync.Pool) e GOMAXPROCS. Mostro o erro comum (mutex global) e correções práticas com…
Downloader concorrente em Go: goroutines, channels, cancelamento e progresso
Aprenda a construir um downloader concorrente em Go usando goroutines, channels e context para cancelamento e progresso. Exemplo funcional, CLI simples e…
Retries exponenciais com jitter em Go: backoff, Context e cancelamento
Aprenda a implementar retries exponenciais com jitter em Go, integrando backoff, context.Context e cancelamento para evitar vazamentos e reduzir carga em…
Rate limiting distribuído em Go: token bucket local com fallback em Redis
Combinar token bucket local com fallback em Redis em Go reduz latência e mantém controle global. Este artigo explica padrões, scripts Lua, exemplos em Go e…
Mutex vs RWMutex em Go: quando usar, analogias e exemplos passo a passo
Entenda de forma prática quando usar sync.Mutex vs sync.RWMutex em Go: analogias, exemplos funcionais passo a passo, armadilhas comuns e um mini-projeto HTTP…