Destino Dev

Blog

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:

  1. Limitar concorrência total de requests simultâneas a 50.
  2. Aplicar backpressure: fila limitada de 200, rejeitando quando cheia.
  3. Cada job tem deadline de 10s e deve abortar se exceder.
  4. 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