Blog

context e padrões de concorrência em Go

Um context em Go avisa cada goroutine de um trabalho quando parar. Aprenda cancelamento, timeouts e valores de requisição, e depois monte um worker pool, um pipeline, fan-out, um errgroup e um semáforo que nunca vazam.

Iniciar uma goroutine é fácil. Pará-la na hora certa é a parte difícil. Um usuário fecha a aba do navegador, uma chamada ao banco de dados demora demais, ou uma de cinco requisições paralelas falha. Em cada caso, algumas goroutines deveriam desistir, e alguma coisa precisa avisá-las.

Em Go, essa coisa é o context.Context. Este post cobre primeiro cancelamento, timeouts e valores com escopo de requisição, e depois cinco padrões de concorrência construídos sobre eles. Ele parte do princípio de que você já viu goroutines, channels, select e sync.WaitGroup nas três partes anteriores. Todo programa abaixo rodou no Go 1.26, e a saída foi colada da execução.

Um context é um sinal que diz pare

Um context é um valor que você passa para uma função para que ela saiba quando parar de trabalhar. O menor context útil vem de context.WithCancel:

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	fmt.Println("before:", ctx.Err())

	cancel()
	<-ctx.Done() // closed now, so this returns at once
	fmt.Println("after:", ctx.Err())

	cancel() // calling it again does nothing
	fmt.Println("again:", ctx.Err())
}

Ele imprime:

before: <nil>
after: context canceled
again: context canceled

context.Background() é o context raiz vazio. Ele nunca termina e não carrega nada. WithCancel o envolve e devolve duas coisas: um context novo e uma função cancel que o encerra.

Dois métodos dizem se um context terminou. ctx.Done() devolve um channel que é fechado quando o context termina. ctx.Err() devolve nil enquanto ele está vivo, e o motivo quando não está mais. Chamar cancel duas vezes é seguro. A segunda chamada não faz nada.

Explicado como se você tivesse dez anos

Imagine uma gerente começando um trabalho grande. Ela entrega um walkie-talkie para cada pessoa da equipe. Algumas dessas pessoas contratam ajudantes, e entregam a eles walkie-talkies na mesma frequência.

Quando a gerente diz “parem” no walkie-talkie dela, todo mundo que recebeu um nessa corrente escuta. As pessoas largam as ferramentas.

Um timeout é um despertador preso com fita a um walkie-talkie. Quando ele toca, diz “parem” por você, mesmo que ninguém tenha apertado o botão.

A versão precisa

Contexts formam uma árvore. Toda função With… recebe um pai e devolve um filho. Quando um context é cancelado, Go fecha o channel Done dele e depois cancela todos os filhos, e os filhos dos filhos, até o fim.

O cancelamento só viaja para baixo. Cancelar um filho nunca afeta o pai, nem os irmãos. A função cancel que você recebe também desfaz a ligação do filho com o pai, e é por isso que você sempre a chama, normalmente com defer cancel().

Onde a analogia falha: uma pessoa com um walkie-talkie não tem como deixar de ouvir. Uma goroutine tem. Fechar Done não para nada sozinho. Seu código precisa verificar ctx.Done() ou ctx.Err() e retornar. Uma goroutine que nunca olha simplesmente continua rodando.

Timeouts e deadlines

Um timeout é o motivo mais comum para um context terminar, e context.WithTimeout configura um em uma única chamada. A função abaixo respeita o cancelamento. Ela espera pelo trabalho ou por ctx.Done(), o que vier primeiro:

package main

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

// work pretends to do a job that takes d, but gives up if ctx ends first.
func work(ctx context.Context, d time.Duration) error {
	select {
	case <-time.After(d):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	err := work(ctx, 2*time.Second)
	fmt.Println("slow job:", err)
	fmt.Println(errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled))

	ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel2()
	fmt.Println("quick job:", work(ctx2, time.Millisecond))

	past := time.Now().Add(-time.Minute)
	ctx3, cancel3 := context.WithDeadline(context.Background(), past)
	defer cancel3()
	fmt.Println("deadline already gone:", work(ctx3, time.Millisecond))
}

Ele imprime:

slow job: context deadline exceeded
true false
quick job: <nil>
deadline already gone: context deadline exceeded

O primeiro trabalho precisa de 2 segundos e recebe 20 milissegundos, então o context ganha. ctx.Err() devolve context.DeadlineExceeded. As margens são largas de propósito. Uma diferença de 100 vezes garante que o resultado nunca depende de quão ocupada está a máquina.

Compare erros com errors.Is, como mostrou a parte sobre erros. context.Canceled significa que alguém chamou cancel. context.DeadlineExceeded significa que o tempo acabou. Código que tenta de novo costuma tratar os dois de forma diferente: um timeout pode valer outra tentativa, mas um cancelamento significa que quem chamou já foi embora.

WithDeadline recebe um ponto no tempo em vez de uma duração. WithTimeout(ctx, d) é exatamente WithDeadline(ctx, time.Now().Add(d)). Um deadline que já passou dá a você um context que acabou antes de a sua função começar.

time.After dentro de um select era uma pequena armadilha de memória, porque o timer continuava vivo até disparar. Desde o Go 1.23, um timer que nada referencia pode ser coletado pelo garbage collector, então esse padrão agora é seguro.

O cancelamento desce pela árvore

A árvore importa mais quando várias goroutines dividem um trabalho. Neste programa, root tem dois filhos. A tem um timeout de 20ms e dois workers abaixo dele, A1 e A2. B é um filho separado, com o próprio worker. Observe até onde chega o timeout de A, e até onde não chega:

root A (timeout 20ms) B (WithCancel) A1 worker A2 worker rodando Canceled rodando DeadlineExceeded rodando Canceled rodando DeadlineExceeded rodando DeadlineExceeded prazo acaba nem sobe nem desvia os cinco contexts estão vivos; cada worker espera no loop do select timeout de 20ms de A: A acaba e A.Err() é DeadlineExceeded o sinal desce: A1 e A2 veem Done fechar, e param B e root ficam intactos: o Err() deles ainda é nil main chama cancelRoot(): o sinal chega a B, que para com Canceled

Uma árvore de contexts com um timeout em um dos galhos. Quando o timer de 20ms de A dispara, A termina e o sinal desce até os workers A1 e A2, que param com DeadlineExceeded. B, o irmão, e root, o pai, continuam rodando. Só quando main cancela root o sinal chega a B.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. root, A, A1, A2 e B estão todos vivos. Cada worker fica em um loop com select, fazendo pequenas unidades de trabalho.
  2. O timer de 20ms de A dispara. A termina, e A.Err() devolve context deadline exceeded.
  3. O sinal desce. A1 e A2 veem seus channels Done fecharem, e os dois param com o mesmo erro.
  4. B e root não são afetados. O cancelamento desceu a partir de A, sem subir até root e sem ir para o lado, até B.
  5. main chama cancelRoot(). Agora o sinal desce de root até B, que para com context canceled.

Este é o programa que a animação acompanha:

package main

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

// worker does small units of work until its context ends.
func worker(ctx context.Context, name string, report chan<- string) {
	tick := time.NewTicker(time.Millisecond)
	defer tick.Stop()
	for {
		select {
		case <-ctx.Done():
			report <- name + " stopped: " + ctx.Err().Error()
			return
		case <-tick.C:
			// one small unit of work
		}
	}
}

func main() {
	root, cancelRoot := context.WithCancel(context.Background())
	defer cancelRoot()

	a, cancelA := context.WithTimeout(root, 20*time.Millisecond)
	defer cancelA()
	a1, cancelA1 := context.WithCancel(a)
	defer cancelA1()
	a2, cancelA2 := context.WithCancel(a)
	defer cancelA2()
	b, cancelB := context.WithCancel(root)
	defer cancelB()

	repA1 := make(chan string)
	repA2 := make(chan string)
	repB := make(chan string)
	go worker(a1, "A1", repA1)
	go worker(a2, "A2", repA2)
	go worker(b, "B", repB)

	fmt.Println(<-repA1)
	fmt.Println(<-repA2)
	fmt.Println("A:", a.Err())
	fmt.Println("B:", b.Err())
	fmt.Println("root:", root.Err())

	cancelRoot()
	fmt.Println(<-repB)
}

Ele imprime:

A1 stopped: context deadline exceeded
A2 stopped: context deadline exceeded
A: context deadline exceeded
B: <nil>
root: <nil>
B stopped: context canceled

Repare na ordem dos eventos em main. Ele espera os dois workers de A reportarem e só então verifica B e root. Os dois ainda são nil, mesmo com um galho inteiro da árvore já encerrado ao lado deles. Só cancelRoot() chega a B.

Cada worker é o formato a copiar. É um laço for em volta de um select, com um case para o trabalho e um case para ctx.Done(). Enquanto cada passo que bloqueia estiver dentro de um select assim, a goroutine sempre consegue ouvir “pare”.

Onde fica o ctx: primeiro parâmetro, nunca campo de struct

A biblioteca padrão e quase todo código Go concordam em uma convenção. Uma função que pode ser cancelada recebe um context.Context como primeiro parâmetro, chamado ctx:

func (s *Store) LoadUser(ctx context.Context, id int) (User, error)

Não guarde um context em uma struct. Um context pertence a uma chamada, como uma requisição HTTP, mas uma struct costuma durar muitas chamadas. Um context guardado acaba sendo o errado: cancelado cedo demais para a próxima requisição, ou nunca cancelado. Passá-lo explicitamente também deixa claro quais funções podem ser paradas.

Mais duas regras. Nunca passe um context nil. Se você ainda não tem um, use context.TODO(), que se comporta como Background() mas marca o ponto para depois. E sempre chame a função cancel. O go vet verifica essa segunda regra para você. Dado ctx, _ := context.WithTimeout(context.Background(), time.Second), ele reporta:

$ go vet .
main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

Por que parou: Cause e AfterFunc

ctx.Err() só diz “canceled” ou “deadline exceeded”, o que muitas vezes é pouco para depurar. O Go 1.20 adicionou WithCancelCause, e o Go 1.21 adicionou WithTimeoutCause e AfterFunc:

package main

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

func main() {
	ctx, cancel := context.WithCancelCause(context.Background())
	cleaned := make(chan struct{})
	context.AfterFunc(ctx, func() {
		close(cleaned) // runs in its own goroutine once ctx ends
	})

	cancel(errors.New("server shutting down"))
	<-cleaned
	fmt.Println("cleanup ran")
	fmt.Println("Err:  ", ctx.Err())
	fmt.Println("Cause:", context.Cause(ctx))

	slow := errors.New("payment provider took too long")
	ctx2, cancel2 := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, slow)
	defer cancel2()
	<-ctx2.Done()
	fmt.Println("Err:  ", ctx2.Err())
	fmt.Println("Cause:", context.Cause(ctx2))
}

Ele imprime:

cleanup ran
Err:   context canceled
Cause: server shutting down
Err:   context deadline exceeded
Cause: payment provider took too long

O cancel de WithCancelCause recebe um erro. ctx.Err() continua devolvendo context.Canceled, então as verificações existentes seguem funcionando, e context.Cause(ctx) devolve o seu erro. WithTimeoutCause faz o mesmo para um timeout. Em uma linha de log, “payment provider took too long” é muito mais útil do que “context deadline exceeded”.

context.AfterFunc registra uma função para rodar quando o context terminar. Ela roda na própria goroutine, e é por isso que main espera no channel cleaned antes de imprimir. Imprimir de dentro do callback entraria em corrida com a saída do próprio main. AfterFunc devolve uma função stop que cancela o registro do callback se ele ainda não rodou.

Valores com escopo de requisição

Um context também pode carregar valores, e context.WithValue é o jeito de dados como um ID de requisição atravessarem uma cadeia de chamadas sem entrar na assinatura de cada função:

package main

import (
	"context"
	"fmt"
)

// An unexported key type: no other package can make a key equal to this one.
type requestIDKey struct{}

func withRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func requestID(ctx context.Context) string {
	id, ok := ctx.Value(requestIDKey{}).(string)
	if !ok {
		return "no-request-id"
	}
	return id
}

func loadUser(ctx context.Context, userID int) {
	fmt.Printf("[%s] loading user %d\n", requestID(ctx), userID)
}

func main() {
	ctx := withRequestID(context.Background(), "req-7f3a")
	loadUser(ctx, 42)
	loadUser(context.Background(), 43)
}

Ele imprime:

[req-7f3a] loading user 42
[no-request-id] loading user 43

A chave é um tipo struct vazio e privado. Dois pacotes que usam a string "id" como chave sobrescreveriam um ao outro. Uma chave de um tipo não exportado não colide com a chave de mais ninguém. ctx.Value devolve any, então você verifica o tipo com comma-ok e trata o caso em que o valor não existe.

Use valores só para dados com escopo de requisição. Ou seja, coisas que descrevem a requisição e cruzam fronteiras de API: um ID de requisição, um ID de trace, o usuário autenticado. Não use para um handle de banco de dados, um logger ou uma flag de configuração. Essas são dependências de verdade, e o lugar delas é em parâmetros ou campos de struct, onde o compilador consegue vê-las. Value não tem tipo, e é encontrado subindo a árvore um pai por vez, então dependências escondidas ali falham em tempo de execução, e não na hora do build.

Worker pool

Um worker pool roda um número fixo de goroutines que pegam jobs do mesmo channel. Ele limita quanto trabalho acontece ao mesmo tempo, não importa quantos jobs cheguem:

package main

import (
	"cmp"
	"fmt"
	"slices"
	"sync"
)

type result struct {
	job, square int
}

func worker(jobs <-chan int, results chan<- result) {
	for j := range jobs {
		results <- result{job: j, square: j * j}
	}
}

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

	var wg sync.WaitGroup
	for range 3 {
		wg.Go(func() { worker(jobs, results) })
	}

	go func() {
		for j := range 9 {
			jobs <- j + 1
		}
		close(jobs) // workers' range loops end
	}()

	go func() {
		wg.Wait()
		close(results) // main's range loop ends
	}()

	var all []result
	for r := range results {
		all = append(all, r)
	}
	slices.SortFunc(all, func(x, y result) int { return cmp.Compare(x.job, y.job) })
	fmt.Println(len(all), "results")
	fmt.Println(all)
}

Ele imprime:

9 results
[{1 1} {2 4} {3 9} {4 16} {5 25} {6 36} {7 49} {8 64} {9 81}]

Três workers dividem o channel jobs. Cada job vai para exatamente um deles. Dois closes fazem tudo terminar. Fechar jobs encerra o laço range de cada worker. Uma goroutine separada espera todos os workers e depois fecha results, o que encerra o laço em main.

Os workers terminam os jobs na ordem que o scheduler escolher, então os resultados chegam em uma ordem diferente a cada execução. Cada resultado carrega o número do seu job, e ordenar por ele deixa a saída igual todas as vezes. Imprimir os resultados à medida que chegam não deixaria.

Pipeline

Um pipeline é uma cadeia de estágios ligados por channels, em que cada estágio é uma goroutine que lê de um channel e escreve no próximo. Este tem três: um gerador, um que eleva ao quadrado e um que soma. O gerador nunca para sozinho, então o context é o único jeito de desligá-lo:

package main

import (
	"context"
	"fmt"
)

// naturals sends 1, 2, 3, ... until ctx ends. It never stops on its own.
func naturals(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := 1; ; n++ {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// square sends the square of every value it receives, until in closes or ctx ends.
func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// sumFirst adds up the first k values from in.
func sumFirst(in <-chan int, k int) int {
	total := 0
	for range k {
		total += <-in
	}
	return total
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	squares := square(ctx, naturals(ctx))

	fmt.Println("sum of first 5 squares:", sumFirst(squares, 5))

	cancel()
	for range squares {
		// drain until square closes its channel
	}
	fmt.Println("both stages stopped")
}

Ele imprime:

sum of first 5 squares: 55
both stages stopped

sumFirst pega cinco valores e retorna, deixando duas goroutines sem ninguém lendo a saída delas. Sem ctx, as duas ficariam bloqueadas em out <- … para sempre. Com ele, cancel() dá ao select de cada estágio uma segunda saída. Elas retornam, e o defer close(out) delas roda.

O laço vazio for range squares prova isso. Ele só termina quando square fecha o seu channel, e square só o fecha depois de parar. Se o cancelamento não funcionasse, o programa travaria ali em vez de imprimir a última linha.

Fan-out e fan-in

Fan-out significa várias goroutines lendo do mesmo channel para dividir o trabalho. Fan-in significa juntar os channels de saída separados delas de volta em um só:

package main

import (
	"context"
	"fmt"
	"slices"
	"sync"
)

func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// merge copies every value from every input onto one channel.
func merge(ctx context.Context, inputs ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	for _, in := range inputs {
		wg.Go(func() {
			for v := range in {
				select {
				case out <- v:
				case <-ctx.Done():
					return
				}
			}
		})
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

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

	src := make(chan int)
	go func() {
		defer close(src)
		for n := range 10 {
			src <- n + 1
		}
	}()

	// fan out: three workers read the same channel
	w1 := square(ctx, src)
	w2 := square(ctx, src)
	w3 := square(ctx, src)

	// fan in: merge their outputs
	var got []int
	for v := range merge(ctx, w1, w2, w3) {
		got = append(got, v)
	}
	slices.Sort(got)
	fmt.Println(got)
}

Ele imprime:

[1 4 9 16 25 36 49 64 81 100]

Três estágios square leem de src, então os dez números são divididos entre eles. merge inicia uma goroutine por entrada, copia os valores para out e fecha out quando todas as entradas se esgotam. É o mesmo formato de WaitGroup seguido de close do worker pool.

Os valores juntados chegam intercalados, sem ordem fixa, então main os ordena antes de imprimir. Cada estágio também recebe ctx. Aqui o programa roda até o fim e nada é cancelado, mas se main parasse de ler antes, o cancel adiado liberaria todas as goroutines.

Um errgroup, construído a partir de WaitGroup

Uma necessidade comum é rodar várias tarefas ao mesmo tempo, parar todas quando uma falha e devolver esse primeiro erro. O pacote errgroup faz isso, mas ele fica em golang.org/x/sync, fora da biblioteca padrão. A ideia é pequena o bastante para construir com sync.WaitGroup, sync.Once e um context:

package main

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

// Group runs tasks in goroutines, keeps the first error,
// and cancels the shared context when that error happens.
type Group struct {
	wg     sync.WaitGroup
	once   sync.Once
	err    error
	cancel context.CancelCauseFunc
}

func WithContext(parent context.Context) (*Group, context.Context) {
	ctx, cancel := context.WithCancelCause(parent)
	return &Group{cancel: cancel}, ctx
}

func (g *Group) Go(task func() error) {
	g.wg.Go(func() {
		if err := task(); err != nil {
			g.once.Do(func() {
				g.err = err
				g.cancel(err)
			})
		}
	})
}

func (g *Group) Wait() error {
	g.wg.Wait()
	g.cancel(g.err) // release the context even if nothing failed
	return g.err
}

func main() {
	g, ctx := WithContext(context.Background())
	status := make([]string, 3)

	for i := range 3 {
		g.Go(func() error {
			if i == 1 {
				status[i] = "failed"
				return fmt.Errorf("fetch %d: connection refused", i)
			}
			select {
			case <-time.After(2 * time.Second):
				status[i] = "finished"
				return nil
			case <-ctx.Done():
				status[i] = "stopped early"
				return ctx.Err()
			}
		})
	}

	err := g.Wait()
	fmt.Printf("%q\n", status)
	fmt.Println("first error:", err)
	fmt.Println("cause:", context.Cause(ctx))
}

Ele imprime:

["stopped early" "failed" "stopped early"]
first error: fetch 1: connection refused
cause: fetch 1: connection refused

A tarefa 1 falha na hora. once.Do guarda o erro dela e cancela o context compartilhado com esse erro como causa. As tarefas 0 e 2 estão esperando um trabalho de 2 segundos, veem ctx.Done() fechar e param antes.

Essas duas também devolvem um erro, context.Canceled. Ele nunca substitui o verdadeiro. sync.Once roda a função exatamente uma vez, e qualquer outra chamada espera até essa primeira execução terminar. Quando uma tarefa cancelada chega a once.Do, o primeiro erro já está guardado. Então “first error” aqui significa o erro que causou o cancelamento, todas as vezes.

Cada tarefa escreve no seu próprio elemento de status, então nenhum mutex é necessário. main só lê o slice depois que Wait retorna.

Concorrência limitada com um semáforo

Às vezes você tem muitas tarefas, mas só pode rodar algumas de cada vez, porque uma API tem limite de requisições ou um banco de dados tem dez conexões. Um channel com buffer vira um semáforo simples, com uma vaga para cada unidade da sua capacidade:

package main

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

func main() {
	const limit = 2
	sem := make(chan struct{}, limit)

	var (
		mu      sync.Mutex
		running int
		peak    int
		wg      sync.WaitGroup
	)
	results := make([]int, 6)

	for i := range 6 {
		sem <- struct{}{} // take a slot; blocks while two are busy
		wg.Go(func() {
			defer func() { <-sem }() // give the slot back

			mu.Lock()
			running++
			peak = max(peak, running)
			mu.Unlock()

			time.Sleep(5 * time.Millisecond) // pretend to call a slow service
			results[i] = i * 10

			mu.Lock()
			running--
			mu.Unlock()
		})
	}
	wg.Wait()

	fmt.Println(results)
	fmt.Println("never more than", limit, "at once:", peak <= limit)
}

Ele imprime:

[0 10 20 30 40 50]
never more than 2 at once: true

Enviar para sem ocupa uma vaga. Com capacidade 2, o terceiro envio bloqueia até uma tarefa em andamento terminar e receber de sem para liberar sua vaga. Pegar a vaga antes de wg.Go garante que nunca existem mais de duas goroutines, e não seis goroutines com quatro delas esperando.

O programa imprime peak <= limit, e não peak. Se duas tarefas de fato se sobrepuseram depende do escalonamento. É provável, mas não é garantido. O limite, por outro lado, vale em toda execução. Para tornar a espera cancelável, pegue a vaga em um select com um case <-ctx.Done():.

Toda goroutine precisa de um jeito de parar

Uma goroutine bloqueada para sempre em um channel é um vazamento. Ela segura sua stack e tudo para o que aponta, e nada vai liberá-la. Aqui está o vazamento, ao lado da correção:

package main

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

// leaky sends three values, with no way to give up.
func leaky(out chan<- int, exited chan<- struct{}) {
	defer close(exited)
	for i := range 3 {
		out <- i
	}
}

// fixed sends three values, but stops as soon as ctx ends.
func fixed(ctx context.Context, out chan<- int, exited chan<- struct{}) {
	defer close(exited)
	for i := range 3 {
		select {
		case out <- i:
		case <-ctx.Done():
			return
		}
	}
}

func report(name string, exited <-chan struct{}) {
	select {
	case <-exited:
		fmt.Println(name, "goroutine exited")
	case <-time.After(100 * time.Millisecond):
		fmt.Println(name, "goroutine still stuck on its send")
	}
}

func main() {
	out := make(chan int)
	exited := make(chan struct{})
	go leaky(out, exited)
	fmt.Println("leaky got", <-out) // take one value, then walk away
	report("leaky", exited)

	ctx, cancel := context.WithCancel(context.Background())
	out2 := make(chan int)
	exited2 := make(chan struct{})
	go fixed(ctx, out2, exited2)
	fmt.Println("fixed got", <-out2)
	cancel() // walk away, but say so
	report("fixed", exited2)
}

Ele imprime:

leaky got 0
leaky goroutine still stuck on its send
fixed got 0
fixed goroutine exited

Os dois produtores querem enviar três valores, e main pega só um de cada. leaky não tem como desistir, então fica parado em out <- 1 pelo resto do programa. report espera 100ms, muito mais do que uma goroutine precisa para sair, e a goroutine continua lá. fixed coloca o envio dentro de um select com ctx.Done(), então cancel() permite que ele retorne.

Antes de escrever go, responda a uma pergunta: o que faz esta goroutine retornar? Boas respostas são “o channel de entrada dela fecha”, “o context dela é cancelado” ou “ela termina um trabalho de tamanho limitado”. “Alguém lê a saída dela” só é uma boa resposta se esse alguém não puder parar antes.

O Go 1.26 adiciona um profile experimental goroutineleak ao runtime/pprof, que reporta goroutines bloqueadas em algo que mais nada consegue alcançar. Ele só existe quando você compila com GOEXPERIMENT=goroutineleakprofile. Sem isso, pprof.Lookup("goroutineleak") devolve nil, e chamar WriteTo nele causa panic, então verifique nil primeiro.

O que lembrar

  • Um context carrega um sinal de parada, e às vezes um deadline, para baixo em uma árvore. O cancelamento vai só para os filhos, nunca sobe para os pais nem vai para os irmãos.
  • Cancelar não para uma goroutine sozinho. Coloque cada passo que bloqueia em um select com case <-ctx.Done(): e devolva ctx.Err().
  • context.Canceled significa que alguém chamou cancel, e context.DeadlineExceeded significa que o tempo acabou. Use context.Cause para dizer o porquê.
  • Passe ctx como primeiro parâmetro, nunca o guarde em uma struct e sempre chame cancel. O go vet pega um descartado.
  • Reserve os valores de context para dados com escopo de requisição, com um tipo de chave não exportado.
  • Worker pools, pipelines, fan-in e errgroups terminam todos do mesmo jeito: um WaitGroup espera, e então um close avisa quem lê que acabou. Ordene resultados que chegam sem ordem fixa.
  • Antes de cada instrução go, saiba o que faz aquela goroutine retornar.

Toda goroutine que você inicia precisa de um jeito de parar, e um context costuma ser esse jeito.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.