Contexto e cancelamento em Go: context.Context em operações concorrentes

Capítulo 15

Tempo estimado de leitura: 9 minutos

+ Exercício

O que é context.Context e por que ele existe

Em operações concorrentes e/ou que dependem de I/O (rede, banco, filas, chamadas a serviços), é comum precisar: (1) cancelar um conjunto de tarefas quando o usuário desistiu, quando uma requisição expirou ou quando um erro crítico aconteceu; (2) impor prazos (timeout/deadline) para evitar goroutines “presas” indefinidamente; (3) propagar sinais de cancelamento e metadados de requisição por camadas (handlers, serviços, repositórios, clientes HTTP etc.).

Em Go, o padrão idiomático para isso é o pacote context. Um context.Context é um valor imutável que carrega: um canal de cancelamento (Done()), um erro de término (Err()), um prazo (Deadline()) e, opcionalmente, valores (Value()) para dados de escopo de requisição.

Regras práticas (as que mais evitam bugs)

  • O context.Context deve ser o primeiro parâmetro de funções/métodos que podem bloquear, fazer I/O ou iniciar trabalho concorrente: func (s *Service) Do(ctx context.Context, ...).
  • Não armazene context.Context em structs para “reusar depois”. Contexto é específico de uma operação; guardar em struct costuma vazar cancelamentos, misturar requisições e causar data races.
  • Não passe nil como contexto. Use context.Background() (ou context.TODO() quando ainda não sabe qual usar).
  • Sempre chame a função de cancelamento retornada por WithCancel/WithTimeout/WithDeadline para liberar recursos internos (timers, referências).
  • Use context.WithValue com parcimônia: apenas para metadados de requisição (ex.: request-id), nunca para dependências (DB, logger, configs).

Contextos base: Background e TODO

context.Background() é o contexto raiz típico em programas (ex.: em main) e em testes. Ele nunca é cancelado e não tem deadline.

ctx := context.Background()

context.TODO() é útil quando você ainda não tem um contexto “real” para propagar, mas quer manter a assinatura pronta. Em produção, prefira Background ou o contexto recebido de uma camada superior.

Cancelamento manual com context.WithCancel

WithCancel cria um contexto filho que pode ser cancelado explicitamente. Cancelar o pai cancela o filho; cancelar o filho não cancela o pai.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Passo a passo

  • Crie um contexto cancelável: ctx, cancel := context.WithCancel(parent).
  • Inicie goroutines que observam ctx.Done().
  • Quando detectar uma condição de parada (erro crítico, usuário cancelou, etc.), chame cancel().
  • Garanta defer cancel() para liberar recursos mesmo se tudo terminar “normalmente”.
package main

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

func worker(ctx context.Context, id int) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(200 * time.Millisecond):
			fmt.Println("worker", id, "tick")
		}
	}
}

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

	go func() {
		time.Sleep(700 * time.Millisecond)
		cancel() // cancela todos que dependem de ctx
	}()

	err := worker(ctx, 1)
	fmt.Println("worker terminou:", err)
}

Note como o select permite que a goroutine acorde tanto pelo “trabalho” quanto pelo cancelamento. Sem isso, ela poderia ficar bloqueada e ignorar o cancelamento por muito tempo.

Timeout com context.WithTimeout

WithTimeout é um atalho para criar um contexto com deadline relativo ao momento atual. Quando o tempo expira, o contexto é cancelado automaticamente com erro context.DeadlineExceeded.

Passo a passo

  • Crie: ctx, cancel := context.WithTimeout(parent, 2*time.Second).
  • defer cancel() sempre.
  • Em loops e operações bloqueantes, observe ctx.Done().
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-time.After(5 * time.Second):
	// simula operação lenta
	fmt.Println("terminou")
case <-ctx.Done():
	fmt.Println("cancelado por timeout:", ctx.Err()) // DeadlineExceeded
}

Deadline absoluto com context.WithDeadline

WithDeadline define um instante exato no tempo. É útil quando você já tem um prazo absoluto (por exemplo, um SLA calculado, ou um deadline recebido de outra camada).

deadline := time.Now().Add(1500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

Na prática, WithTimeout e WithDeadline são equivalentes em efeito; a diferença é como você expressa o prazo.

Como propagar contexto por camadas (sem guardar em struct)

O contexto deve fluir “de cima para baixo” na chamada: quem inicia a operação cria/recebe o contexto e passa adiante. Cada camada pode derivar um contexto filho (por exemplo, com timeout menor) e repassar.

Exemplo de design

type Service struct {
	repo *Repo
}

type Repo struct {
	// dependências aqui (db, client, etc.), mas NÃO context
}

func (s *Service) Process(ctx context.Context, userID string) error {
	// camada de serviço decide um timeout específico para esta etapa
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	return s.repo.LoadUser(ctx, userID)
}

func (r *Repo) LoadUser(ctx context.Context, userID string) error {
	// aqui você usaria ctx em chamadas de I/O (ex.: QueryContext)
	select {
	case <-time.After(300 * time.Millisecond):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

Anti-padrão: armazenar contexto em struct

Evite algo como:

type Service struct {
	ctx context.Context // NÃO faça isso
}

Isso tende a causar: cancelamento de uma operação afetando outra, dificuldade de testes, e confusão sobre qual contexto está ativo.

Integração com goroutines: cancelamento cooperativo com select

Cancelamento em Go é cooperativo: a goroutine precisa checar o contexto e sair. O padrão mais comum é usar select com ctx.Done() em pontos onde a goroutine poderia bloquear ou em loops.

Padrão: loop com trabalho periódico

func poll(ctx context.Context, out chan<- int) error {
	defer close(out)

	i := 0
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(100 * time.Millisecond):
			i++
			out <- i
		}
	}
}

Padrão: esperar múltiplos resultados e cancelar o restante ao primeiro erro

Quando você dispara várias tarefas concorrentes, um erro crítico pode tornar inútil continuar. Nesse caso, cancele o contexto para pedir que as outras parem.

type result struct {
	id  int
	err error
}

func task(ctx context.Context, id int) error {
	// simula trabalho variável
	select {
	case <-time.After(time.Duration(200+id*120) * time.Millisecond):
		if id == 2 {
			return fmt.Errorf("erro crítico na tarefa %d", id)
		}
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func runAll(ctx context.Context) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	resCh := make(chan result, 3)
	for i := 1; i <= 3; i++ {
		i := i
		go func() {
			err := task(ctx, i)
			resCh <- result{id: i, err: err}
		}()
	}

	for i := 0; i < 3; i++ {
		res := <-resCh
		if res.err != nil {
			cancel() // pede para as outras pararem
			return res.err
		}
	}
	return nil
}

Detalhe importante: o canal resCh é bufferizado para evitar que goroutines fiquem bloqueadas tentando enviar resultado após o retorno antecipado. Alternativamente, você poderia drenar o canal antes de retornar, ou usar errgroup (pacote golang.org/x/sync/errgroup) em projetos reais.

Interpretando ctx.Err() corretamente

Quando <-ctx.Done() dispara, ctx.Err() explica o motivo:

  • context.Canceled: cancelamento manual (ou cancelamento do pai).
  • context.DeadlineExceeded: deadline/timeout atingido.

Em funções internas, normalmente você retorna ctx.Err() diretamente. Em camadas superiores, você pode decidir se isso é erro “esperado” (ex.: timeout do usuário) ou se deve ser logado/tratado como falha.

Exercício prático: pipeline concorrente com timeout e erro crítico

Objetivo: implementar um coordenador que executa N tarefas concorrentes. O sistema deve parar corretamente quando: (1) o timeout global expirar; ou (2) qualquer tarefa retornar um erro crítico. As tarefas devem observar o contexto e encerrar rapidamente.

Especificação

  • Crie uma função RunTasks(ctx context.Context, n int) error.
  • Dentro dela, derive um contexto com timeout global de 1*time.Second usando context.WithTimeout.
  • Inicie n goroutines, cada uma executando DoWork(ctx, id).
  • DoWork deve simular trabalho em etapas (ex.: 10 iterações), e em cada iteração usar select para respeitar ctx.Done().
  • Faça uma das tarefas (por exemplo, id == 3) retornar um erro crítico em alguma etapa (ex.: na iteração 4).
  • Ao receber o primeiro erro crítico, cancele o contexto (via WithCancel ou reutilizando o cancel do timeout) e retorne esse erro.
  • Se o timeout expirar antes de todas terminarem, retorne ctx.Err() (que será DeadlineExceeded).
  • Garanta que não haja goroutines bloqueadas ao tentar enviar resultados (use canal bufferizado ou drenagem).

Esqueleto sugerido

package main

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

type taskResult struct {
	id  int
	err error
}

var ErrCritical = errors.New("erro crítico")

func DoWork(ctx context.Context, id int) error {
	for step := 1; step <= 10; step++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(120 * time.Millisecond):
			// simulando uma etapa de trabalho
			if id == 3 && step == 4 {
				return fmt.Errorf("tarefa %d: %w", id, ErrCritical)
			}
		}
	}
	return nil
}

func RunTasks(ctx context.Context, n int) error {
	ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
	defer cancel()

	resCh := make(chan taskResult, n)

	for id := 1; id <= n; id++ {
		id := id
		go func() {
			err := DoWork(ctx, id)
			resCh <- taskResult{id: id, err: err}
		}()
	}

	for i := 0; i < n; i++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case res := <-resCh:
			if res.err != nil {
				// se for crítico, cancele tudo
				if errors.Is(res.err, ErrCritical) {
					cancel()
					return res.err
				}
				// se não for crítico, você decide: acumular e continuar ou retornar
				return res.err
			}
		}
	}
	return nil
}

func main() {
	err := RunTasks(context.Background(), 5)
	fmt.Println("resultado:", err)
}

Checklist de validação do exercício

  • Ao atingir 1 segundo, o programa retorna com context.DeadlineExceeded e as goroutines param (não continuam imprimindo/trabalhando).
  • Quando a tarefa 3 falha com ErrCritical, as demais encerram rapidamente com context.Canceled (ou também DeadlineExceeded se o tempo já tiver estourado).
  • Não há deadlock ao retornar cedo (canal bufferizado ou outra estratégia equivalente).
  • As assinaturas recebem ctx como primeiro parâmetro e ele é propagado por chamadas internas.

Agora responda o exercício sobre o conteúdo:

Ao executar várias goroutines com um contexto derivado por WithTimeout, qual abordagem melhor garante que o sistema pare rapidamente no primeiro erro crítico e evite goroutines bloqueadas ao enviar resultados?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

O cancelamento em Go é cooperativo: as goroutines devem checar ctx.Done() (geralmente com select) e encerrar. Ao detectar erro crítico, chamar cancel() pede que as demais parem. Um canal bufferizado (ou drenagem) evita que goroutines travem ao enviar resultados quando há retorno cedo.

Próximo capitúlo

Estrutura de projetos em Go: convenções, internal, cmd e organização por pacotes

Arrow Right Icon
Capa do Ebook gratuito Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos
83%

Go (Golang) para Iniciantes: Fundamentos, Concorrência e Estrutura de Projetos

Novo curso

18 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.