Intensivão Golang Avançado: aprofundamento em concorrência, arquitetura e trade-offs

O passo seguinte ao roteiro rápido de Go, com aprofundamento guiado em concorrência, arquitetura e trade-offs.

Este guia é o aprofundamento do roteiro de 30 minutos de Go. Ele parte dos mesmos fundamentos — goroutines, channels, contexto, backpressure e idempotência — para discutir com mais calma quando cada decisão de desenho se aplica.

Se você ainda não passou pela revisão rápida, comece pelo roteiro de 30 minutos e volte aqui para aprofundar cada bloco.

Como usar este guia

Avance na ordem proposta: cada seção isola uma decisão de desenho, descreve o mecanismo, a falha que ele trata, um exemplo autocontido e o critério para escolher outra abordagem. Todos os cenários abaixo são didáticos e hipotéticos — servem para treinar leitura de trade-offs, não descrevem sistemas reais. Leia com um editor aberto e adapte os snippets ao seu próprio exercício antes de levar qualquer padrão para um sistema real.

1. Modelo de execução: goroutines, scheduler e GOMAXPROCS

Mecanismo. Goroutines são unidades leves de execução multiplexadas pelo scheduler do runtime sobre threads do sistema operacional. GOMAXPROCS define quantas threads podem executar código Go simultaneamente; por padrão, acompanha o número de CPUs disponíveis. Desde o Go 1.14, as goroutines são assincronamente preemptíveis: o scheduler pode interrompê-las mesmo em loops apertados sem pontos explícitos de cooperação, distribuindo trabalho sem que cada tarefa exija uma thread dedicada.

Falha ou limite que ele trata. O modelo evita o custo de uma thread por tarefa concorrente e reduz troca de contexto do sistema operacional. O limite aparece quando se confunde concorrência com paralelismo: criar milhares de goroutines bloqueadas em I/O lento é barato, mas criar milhares de goroutines em loop apertado de CPU com GOMAXPROCS baixo apenas serializa o trabalho e aumenta pressão sobre o escalonador e o coletor de lixo.

Exemplo de aplicação. Cenário didático: processar uma lista de itens independentes em paralelo, limitada ao número de CPUs.

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func process(item int) int {
	// Simula transformação pura de CPU.
	return item * item
}

func main() {
	items := []int{1, 2, 3, 4, 5, 6, 7, 8}
	results := make([]int, len(items))

	numWorkers := runtime.GOMAXPROCS(0)
	jobs := make(chan int)

	var wg sync.WaitGroup
	for w := 0; w < numWorkers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for index := range jobs {
				results[index] = process(items[index])
			}
		}()
	}
	for i := range items {
		jobs <- i
	}
	close(jobs)
	wg.Wait()
	fmt.Println(results)
}

Quando escolher outra abordagem. Mantenha o valor default de GOMAXPROCS; se considerar sobrescrevê-lo, meça antes e depois em benchmarks controlados. Para tarefas puramente sequenciais, dependentes entre si ou com overhead de coordenação maior que o ganho, o laço simples sem goroutines é mais legível e mais rápido. Para paralelismo de dados em lote com cancelamento e limite de erro, prefira errgroup ou um pool com semáforo em vez de disparar goroutines sem controle.

2. Ownership e cancelamento com context

Mecanismo. context.Context propaga cancelamento, deadline e valores de escopo de requisição ao longo de uma cadeia de chamadas. O dono do contexto (normalmente a borda de entrada: handler HTTP, consumidor de fila, função main) cria um contexto cancelável ou com timeout; as funções internas apenas observam <-ctx.Done() e retornam ctx.Err(). Contexto é imutável: WithCancel, WithTimeout e WithValue derivam um filho sem alterar o pai.

Falha ou limite que ele trata. Sem ownership claro, goroutines órfãs continuam trabalhando depois que o cliente desistiu, o deploy desligou ou o timeout estourou — desperdiçando CPU, conexões e memória. O limite do mecanismo: contexto não cancela código por força; se a função ignorar ctx.Done() ou bloquear em operação sem suporte a contexto, o cancelamento nunca acontece.

Exemplo de aplicação. Cenário didático: uma busca com timeout que abandona o trabalho lento.

package main

import (
	"context"
	"fmt"
	"time"
)

func fetch(ctx context.Context, id int) (string, error) {
	timer := time.NewTimer(2 * time.Second)
	defer timer.Stop()
	select {
	case <-timer.C:
		return fmt.Sprintf("item-%d", id), nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	result, err := fetch(ctx, 42)
	if err != nil {
		fmt.Println("cancelado:", err)
		return
	}
	fmt.Println(result)
}

Quando escolher outra abordagem. Use valores de contexto apenas para dados de escopo de requisição (identificador de correlação, credenciais de chamada). Nunca use contexto para parâmetros obrigatórios da função nem para estado mutável compartilhado — passe argumentos explícitos. Se o cancelamento precisa interromper computação que não observa contexto (loop apertado de CPU), verifique ctx.Done() manualmente a cada iteração ou reestruture o trabalho em etapas interrompíveis.

3. Channels e sincronização: quando o mutex é melhor

Mecanismo. Channels transferem posse de dados entre goroutines e sincronizam remetente e receptor; sync.Mutex (e sync.RWMutex) protegem acesso a estado compartilhado. A regra prática: use channels para orquestrar (sinalizar conclusão, distribuir tarefas, aplicar backpressure) e mutex para guardar invariantes de uma estrutura acessada por várias goroutines (contadores, caches, mapas).

Falha ou limite que ele trata. Channels evitam condição de corrida por construção quando o dado atravessa o canal em vez de ser compartilhado. O limite: modelar todo estado compartilhado com uma goroutine “dona” e canais de pedido/resposta adiciona latência, complexidade e risco de deadlock quando um simples mutex resolveria. Inversamente, proteger um pipeline inteiro com um único mutex gigante serializa trabalho que poderia fluir em paralelo.

Exemplo de aplicação. Cenário didático: o mesmo contador implementado das duas formas para comparar.

package main

import (
	"fmt"
	"sync"
)

// Com mutex: direto para estado compartilhado simples.
type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

// Com channel: a goroutine dona centraliza as atualizações.
func runCounterOwner(increments int) int {
	inc := make(chan struct{})
	done := make(chan int)

	go func() {
		total := 0
		for range inc {
			total++
		}
		done <- total
	}()

	var wg sync.WaitGroup
	for i := 0; i < increments; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			inc <- struct{}{}
		}()
	}
	wg.Wait()
	close(inc)
	return <-done
}

func main() {
	var c Counter
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc()
		}()
	}
	wg.Wait()
	fmt.Println("mutex:", c.n)
	fmt.Println("owner:", runCounterOwner(100))
}

Quando escolher outra abordagem. Prefira sync.Mutex/sync.RWMutex para proteger mapas, contadores e caches com acesso concorrente simples; prefira sync.Map apenas quando houver padrão comprovado de muitas leituras e poucas escritas com chaves disjuntas. Prefira channels quando precisar de fila, fan-out/fan-in, timeout via select ou backpressure natural com canal com buffer. Evite expor canais internos como API de uma estrutura com estado pequeno — o mutex mantém a interface síncrona e mais fácil de testar.

4. Concorrência limitada e backpressure

Mecanismo. Concorrência limitada impõe um teto de trabalhos simultâneos com semáforo (canal com buffer de vagas), pool de workers ou errgroup.Group com limite. Backpressure é o efeito: quando o teto é atingido, novos trabalhos esperam em vez de consumir memória, conexões e descritores de arquivo sem controle. O tamanho do buffer do canal e o número de workers são parâmetros de capacidade, não detalhes de implementação.

Falha ou limite que ele trata. Sem limite, um pico de entrada cria uma goroutine por item, esgota conexões com o banco, estoura memória com buffers acumulados e derruba o processo. O limite do mecanismo: teto baixo demais subutiliza recursos e aumenta latência de fila; teto alto demais apenas desloca o gargalo para o serviço seguinte.

Exemplo de aplicação. Cenário didático: buscar URLs com no máximo 3 requisições simultâneas.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func fetchURL(ctx context.Context, url string) error {
	select {
	case <-time.After(100 * time.Millisecond):
		fmt.Println("ok:", url)
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	urls := []string{"a", "b", "c", "d", "e", "f", "g", "h"}

	const maxInflight = 3
	sem := make(chan struct{}, maxInflight)

	var wg sync.WaitGroup
loop:
	for _, u := range urls {
		select {
		case sem <- struct{}{}: // adquire a vaga antes de iniciar a goroutine
		case <-ctx.Done():
			break loop
		}
		wg.Add(1)
		go func(url string) {
			defer wg.Done()
			defer func() { <-sem }() // libera vaga
			_ = fetchURL(ctx, url)
		}(u)
	}
	wg.Wait()
}

Quando escolher outra abordagem. Se a ordem de conclusão importar ou os erros precisarem encerrar o lote, use errgroup com limite em vez de WaitGroup manual. Se o produtor é muito mais rápido que o consumidor de forma sustentada, limitar concorrência não basta: adicione descarte com select/default, fila persistente ou controle de admissão na borda (limite de taxa, circuit breaker). Para I/O com latência dominada por espera, o teto pode ser maior que o número de CPUs; para CPU-bound, mantenha próximo de GOMAXPROCS.

5. Consumidores resilientes: ACK, idempotência, retry e ordem por chave

Mecanismo. Um consumidor resiliente separa receber, processar e confirmar (ACK). A mensagem só recebe ACK após o efeito ser durável; o processamento é idempotente (repetir a mesma mensagem produz o mesmo estado); falhas transitórias usam retry com backoff e limite de tentativas; falhas persistentes vão para uma fila de mensagens mortas (DLQ); e a ordem só é garantida dentro de uma chave de partição, processada por um único worker por vez.

Falha ou limite que ele trata. Sem esse desenho, três falhas clássicas aparecem: confirmar antes de processar perde mensagens em caso de queda; processar sem idempotência duplica efeitos (cobrança, envio, escrita) quando o broker reentrega; reprocessar sem DLQ trava o consumidor em mensagem envenenada. O limite: garantia global de ordem e exatamente-uma-vez de ponta a ponta não existem em sistemas distribuídos práticos — o desenho entrega ordem por chave e efeito único via idempotência.

Exemplo de aplicação. Cenário didático e simulação sequencial e volátil: mapa em memória, DLQ em slice e ACK lógico não têm durabilidade e não demonstram particionamento — o campo Key é apenas um rótulo, sem worker por partição. Serve só para treinar a sequência receber → processar → confirmar.

package main

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

type Message struct {
	ID  string // chave de idempotência (simulada)
	Key string // rótulo didático: não demonstra particionamento
	Body string
}

// Store é simulação sequencial e volátil: perde o estado ao reiniciar.
type Store struct {
	mu   sync.Mutex
	done map[string]bool
}

func (s *Store) AlreadyProcessed(id string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.done[id]
}

func (s *Store) MarkProcessed(id string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.done[id] = true
}

func handle(m Message) error {
	if m.Body == "poison" {
		return errors.New("falha persistente")
	}
	fmt.Println("processada:", m.ID)
	return nil
}

func consume(messages []Message, store *Store) {
	var dlq []Message // simulação volátil: perde o conteúdo ao reiniciar
	for _, m := range messages { // processamento sequencial: sem concorrência nem partição por chave
		if store.AlreadyProcessed(m.ID) {
			continue // reentrega simulada
		}
		var err error
		for attempt := 1; attempt <= 3; attempt++ {
			if err = handle(m); err == nil {
				break
			}
			time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
		}
		if err != nil {
			dlq = append(dlq, m) // simula mover para DLQ e confirmar, sem durabilidade
			continue
		}
		store.MarkProcessed(m.ID) // ACK lógico simulado, sem efeito durável
	}
	fmt.Println("dlq:", len(dlq))
}

func main() {
	store := &Store{done: map[string]bool{}}
	msgs := []Message{
		{ID: "1", Key: "pedido-7", Body: "ok"},
		{ID: "1", Key: "pedido-7", Body: "ok"}, // reentrega
		{ID: "2", Key: "pedido-7", Body: "poison"},
	}
	consume(msgs, store)
}

Quando escolher outra abordagem. Se o efeito for naturalmente idempotente (escrita com SET pela chave, upsert com versão), a chave de idempotência pode ser a própria chave de negócio. Se a ordem global for requisito real — e não apenas conveniência — reduza o paralelismo a um único consumidor ou reparticione por chave; o custo é vazão menor. Em produção, troque a simulação em memória por armazenamento durável: a verificação de idempotência e a confirmação (claim) precisam ser atômicas e duráveis, e a ordem por chave exige partição por chave com um único worker ativo por partição.

6. Shutdown gracioso

Mecanismo. Shutdown gracioso converte um sinal de término (SIGINT/SIGTERM) em uma sequência ordenada: parar de aceitar trabalho novo, cancelar contextos, aguardar trabalhos em voo com timeout e só então encerrar recursos (servidor HTTP, consumidores, pools de conexão). Em Go, o padrão combina signal.NotifyContext (ou os/signal), http.Server.Shutdown e sync.WaitGroup para acompanhar goroutines de fundo.

Falha ou limite que ele trata. Sem essa sequência, o processo morre no meio de requisições e confirmações: clientes recebem conexões cortadas, mensagens voltam para a fila sem controle e arquivos/escritas ficam pela metade. O limite: o tempo de graça é finito — trabalhos que ignoram o contexto de shutdown estouram o timeout e são interrompidos de qualquer forma, então cada etapa precisa observar o cancelamento.

Exemplo de aplicação. Cenário didático limitado a servidor HTTP sem workers de fundo: drena apenas conexões HTTP ao receber Ctrl+C. Workers de fundo exigiriam WaitGroup/drenagem adicional, não cobertos aqui.

package main

import (
	"context"
	"fmt"
	"net/http"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	server := &http.Server{Addr: ":8080", Handler: mux}

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		fmt.Println("ouvindo em :8080")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			fmt.Println("erro:", err)
		}
	}()

	<-ctx.Done() // sinal recebido: parar de aceitar, drenar o resto
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = server.Shutdown(shutdownCtx)
	fmt.Println("encerrado com graça")
}

Quando escolher outra abordagem. O exemplo acima cobre só o HTTP; se houver workers de fundo, acompanhe-os com sync.WaitGroup e drenagem adicional antes de concluir o shutdown. Para CLIs e jobs de lote sem rede, basta propagar o contexto de sinal às etapas e aguardar o WaitGroup — sem servidor HTTP. Em orquestradores que enviam SIGKILL após o período de graça, dimensione o timeout de shutdown abaixo do limite da plataforma (por exemplo, terminationGracePeriod). Se trabalhos em voo não puderem ser interrompidos com segurança, prefira drenagem com checkpoint e retomada em vez de tentar estender o timeout indefinidamente.

7. Observabilidade: logs, métricas e traces que explicam o sistema

Mecanismo. Observabilidade combina três sinais com o mesmo vocabulário de rótulos: logs estruturados para eventos discretos (com identificador de correlação), métricas para comportamento agregado (contadores, histogramas de latência, gauges de fila e de goroutines) e traces para seguir uma requisição através de goroutines e serviços. Em Go, isso significa propagar o identificador pelo context, expor métricas no formato do coletor usado e instrumentar fronteiras (HTTP, fila, banco) em vez de cada função interna.

Falha ou limite que ele trata. Sem instrumentação nas fronteiras, incidentes de concorrência são invisíveis: fila crescendo, workers saturados, retries multiplicando carga e timeouts encadeados aparecem apenas como “lentidão”. O limite: instrumentação excessiva (logar cada iteração, cardinalidade alta em rótulos como IDs únicos) custa CPU, memória e armazenamento — e pode derrubar o próprio sistema observado.

Exemplo de aplicação. Cenário didático: worker que registra correlação, latência e profundidade da fila sem dependências externas.

package main

import (
	"context"
	"log/slog"
	"time"
)

type ctxKey string

const requestIDKey ctxKey = "request_id"

func processOrder(ctx context.Context, orderID string) {
	start := time.Now()
	logger := slog.With("request_id", ctx.Value(requestIDKey), "order", orderID)
	logger.InfoContext(ctx, "inicio")
	defer func() {
		// Em um sistema real, observe este valor em um histograma.
		logger.InfoContext(ctx, "fim", "duracao_ms", time.Since(start).Milliseconds())
	}()
	time.Sleep(50 * time.Millisecond)
}

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey, "req-123")
	queueDepth := 7 // em um sistema real, exponha como gauge
	slog.InfoContext(ctx, "worker", "fila", queueDepth)
	processOrder(ctx, "pedido-7")
}

Quando escolher outra abordagem. Para depuração local e exercícios, logs estruturados bastam; adicione métricas quando precisar de alertas e comparação entre deploys, e traces quando o caminho atravessar múltiplos serviços ou filas. Se a cardinalidade explodir, agregue por rota padrão, código de status e nome de operação — nunca por ID individual. Se o custo de coleta ficar alto, amostre traces e mantenha logs de erro completos, não o inverso.

8. Profiling: CPU, memória e goroutines bloqueadas

Mecanismo. Profiling coleta amostras do que o programa realmente faz: perfil de CPU mostra onde o tempo é gasto, perfil de heap mostra onde a memória é alocada, e os perfis de goroutine e bloqueio mostram onde a concorrência trava (espera em mutex, canal vazio, I/O). O fluxo padrão é reproduzir a carga, capturar com net/http/pprof ou runtime/pprof, comparar antes/depois e só então otimizar o caminho quente comprovado.

Falha ou limite que ele trata. Sem perfil, otimizações miram o lugar errado: reduz-se alocação em código frio enquanto o gargalo real é contenção de lock ou milhares de goroutines paradas no mesmo canal. O limite: perfis são amostras estatísticas, não verdades absolutas — cargas sintéticas curtas e benchmarks sem representatividade produzem conclusões falsas, e ativar profiling contínuo com overhead alto em produção pode distorcer as medições.

Exemplo de aplicação. Cenário didático: expor o endpoint de profiling em um servidor de exercício e capturar CPU por 30 segundos.

package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof"
)

func busy(n int) int {
	total := 0
	for i := 0; i < n; i++ {
		total += i * i
	}
	return total
}

var sink int

func main() {
	// Em exercício local: http://localhost:6060/debug/pprof/
	go func() {
		if err := http.ListenAndServe("localhost:6060", nil); err != nil && err != http.ErrServerClosed {
			fmt.Println("pprof:", err)
		}
	}()

	// Carga contínua durante a captura; mantém o servidor vivo.
	for {
		sink = busy(1_000_000)
	}
}
# Captura 30s de CPU e abre o relatório interativo:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Dentro do pprof: top, list busy, web

Quando escolher outra abordagem. Se o sintoma for memória crescente, comece pelo perfil de heap (/debug/pprof/heap) e pelo gráfico de goroutines antes do perfil de CPU — vazamento de goroutine aparece como contagem que nunca cai. Para contenção de locks, ative runtime.SetMutexProfileFraction e SetBlockProfileRate em ambiente de teste, não permanentemente em produção. Se o gargalo estiver fora do processo (banco lento, rede, broker), profiling local não ajuda: volte à observabilidade (seção 7) e meça latência por fronteira antes de micro-otimizar Go.

Referências oficiais