Pular para o conteúdo

Trilha de Go · parte 14 de 65

WaitGroup em Go: esperando um grupo de goroutines

Como o sync.WaitGroup espera um grupo de goroutines terminar, por que Add vem antes do go, o que quebra ao copiar o contador e como coletar resultados.

intermediárioPublicada em · 31 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine uma excursão de escola no fim do dia. O professor não precisa saber o que cada aluno comprou na lanchonete. Ele só precisa de um número, quantos alunos ainda não voltaram ao ônibus, e o motorista não liga o motor enquanto esse número não chegar a zero. O sync.WaitGroup é esse número. Ele é um contador de tarefas pendentes: uma goroutine aumenta o contador para cada tarefa que vai começar, cada tarefa diminui o contador ao terminar, e uma goroutine pode dormir até o contador zerar.

Ele não guarda resultado, não cancela tarefa e não tem limite de tempo. Só conta e espera. Essa simplicidade é o motivo de ele ser a ferramenta padrão para esperar um grupo de goroutines, e também o motivo de ele causar problemas quando alguém espera que ele faça mais do que isso.

Esta aula assume que você já sabe criar goroutines com go e que o main encerra o processo sem esperar as filhas, como visto em Goroutine. Ela mostra o uso correto, o que o WaitGroup faz por dentro, os quatro erros que mais aparecem na prática e os padrões para coletar resultados.

Mapa da aula

SeçãoPergunta que responde
1. O problema da esperaPor que não basta um laço que confere uma variável?
2. Add, Done e WaitComo um grupo de tarefas é contado e esperado?
3. Como funciona por dentroO que existe na struct e o que acontece quando Wait dorme?
4. Add antes do goPor que a ordem entre Add e go decide se a espera funciona?
5. Copiar um WaitGroupPor que passar o valor em vez do ponteiro trava o programa?
6. Contador negativo e reusoQuais pânicos o próprio pacote lança?
7. Barreira e wg.GoComo reutilizar o contador em rodadas e o atalho da versão 1.25?
8. Por que Wait não tem timeoutComo esperar com prazo sem deixar uma goroutine presa?
9. A armadilha da variável de loopPor que a mesma linha de código pode capturar valores diferentes conforme o go.mod?
10. Coletando resultadosComo receber valores de volta por índice ou por canal, sem corrida?
11. Erros comuns e boas práticasQuais decisões evitam os problemas das seções anteriores?
12. ExercíciosTrês tarefas com resposta de referência
13. GlossárioTermos técnicos desta aula
O que não foi confirmadoO que esta aula afirma sem verificação completa
FontesDe onde vêm os fatos, e quando foram consultados

Os exemplos foram escritos em um módulo com go 1.27, rodando com a toolchain Go 1.27.2 em macOS arm64. Cada saída mostrada é de uma execução real; quando a ordem das linhas varia de uma execução para outra, a nota diz isso.

1. O problema da espera

Suponha que o main precise esperar três goroutines terminarem. A primeira ideia costuma ser um laço que confere uma variável compartilhada, algo como for pendentes > 0 {}. Esse laço tem dois problemas. O primeiro é a corrida de dados: se as tarefas decrementam pendentes sem sincronização, duas escritas podem se sobrepor e o valor final fica errado. O segundo é o busy waiting, em que o núcleo da CPU roda o laço sem parar só para ler uma variável. O main gasta um núcleo inteiro esperando, e o trabalho real disputa esse mesmo núcleo.

A segunda ideia é um canal por tarefa, em que cada goroutine envia um sinal ao terminar e o main recebe três vezes. Funciona para três tarefas, mas o código cresce com o número de tarefas, e o main precisa saber exatamente quantos sinais esperar. Se o número de tarefas só é conhecido em tempo de execução, o código fica torto.

O sync.WaitGroup resolve os dois problemas com uma interface de três métodos. Add(delta int) soma delta ao contador, e delta pode ser negativo. Done() é o mesmo que Add(-1). Wait() bloqueia a goroutine que o chama até o contador chegar a zero; se o contador já estiver em zero, Wait retorna na hora. A goroutine bloqueada não ocupa a CPU, porque o runtime a tira de execução até alguém acordá-la.

Linha do tempo: main chama Add(3), dispara três tarefas e bloqueia em Wait; o último Done() zera o contador e libera o retorno|880
figura 1 · Ciclo de Add, Done e Wait com três tarefas

A figura mostra a sequência de uma execução típica. O main registra as três tarefas com Add(3) antes de disparar as goroutines. Enquanto o contador vale 3, o main chama Wait() e fica dormindo. Cada tarefa chama Done() ao terminar, em ordem que não precisa ser a de criação, e o último Done() leva o contador a zero. Só então o Wait retorna no main. Repare que o WaitGroup aparece como um participante próprio: ele é o ponto de encontro entre quem espera e quem termina, e não uma tarefa.

2. Add, Done e Wait em um exemplo completo

O programa abaixo dispara três tarefas, cada uma com um Done() no início garantido por defer. O Add(3) vem antes do laço de go, e o Wait() fecha a espera no main.

package main

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

func executarTarefa(id int, wg *sync.WaitGroup) {
	defer wg.Done()

	fmt.Printf("[início] tarefa %d\n", id)
	time.Sleep(100 * time.Millisecond)
	fmt.Printf("[fim] tarefa %d\n", id)
}

func main() {
	var wg sync.WaitGroup
	const total = 3

	wg.Add(total) // registra as três tarefas antes de qualquer go
	for i := 1; i <= total; i++ {
		go executarTarefa(i, &wg)
	}

	wg.Wait()
	fmt.Println("todas as tarefas terminaram")
}

Rode esse código e você verá algo parecido com a saída a seguir. A ordem das linhas [início] e [fim] pode mudar de uma execução para outra, porque as três goroutines competem pelo processador. O que não muda é a última linha: ela só aparece depois que as três tarefas registraram o Done().

[início] tarefa 1
[início] tarefa 3
[início] tarefa 2
[fim] tarefa 2
[fim] tarefa 1
[fim] tarefa 3
todas as tarefas terminaram

Três detalhes merecem atenção. O primeiro é que o WaitGroup é passado por ponteiro, &wg, e a seção 5 mostra o que acontece sem ele. O segundo é que o defer wg.Done() fica na primeira linha da função. Se a tarefa retornar cedo, ou se um panic acontecer no meio, o defer ainda executa e o contador desce. Sem o defer, qualquer caminho de saída que esqueça o Done() deixa o Wait bloqueado para sempre. O terceiro é que a saída não diz nada sobre resultados: o WaitGroup garante que as três tarefas terminaram, não o que elas calcularam. A seção 10 trata disso.

3. Como funciona por dentro

A struct do sync.WaitGroup, no código-fonte da versão 1.27.2 (src/sync/waitgroup.go), tem três campos. Um noCopy de zero bytes, que existe só para o go vet reclamar de cópias. Um state do tipo atomic.Uint64, que guarda o contador e a quantidade de goroutines esperando. E um sema uint32, que é o semáforo usado para dormir e acordar. Na máquina de teste, unsafe.Sizeof deu 16 bytes para a struct inteira.

O campo state é a parte interessante. Ele é uma palavra de 64 bits que o código lê e altera com operações atômicas, sem mutex. Pelo que o código faz, o contador fica nos 32 bits altos (a leitura é int32(state >> 32)), e a quantidade de goroutines esperando fica nos bits baixos (uint32(state & 0x7fffffff)). O bit 31 é marcado pelo recurso de testes synctest. Vale dizer que o comentário no próprio arquivo descreve a ordem dos bits de outro jeito; quem decide é o código, e a seção “O que não foi confirmado” registra essa divergência.

Juntar contador e esperas numa só palavra tem um motivo prático. Se o contador e as esperas ficassem em variáveis separadas, uma goroutine poderia ler o contador em zero enquanto outra acabava de registrar uma espera, e as duas leituras não combinariam. Com uma palavra só, uma operação atômica muda as duas informações juntas.

O fluxo de Add e Done é uma soma atômica sobre o contador. Se o resultado ficar negativo, o pacote entra em pânico. Se o contador chegar a zero e houver goroutines esperando, o último Add zera o state e libera, uma a uma, as goroutines presas no semáforo. O fluxo de Wait é um laço: lê o state, retorna se o contador já for zero, e caso contrário tenta registrar mais uma espera com uma troca atômica (compare-and-swap). Se a troca der certo, a goroutine dorme no semáforo do runtime, pela chamada que aparece no stack trace da seção 5 como runtime_SemacquireWaitGroup.

Em forma simplificada, e não como cópia literal do código, o caminho de Wait se parece com isto:

// Resumo de sync.(*WaitGroup).Wait, versão 1.27.2, sem os detalhes de synctest e race.
func (wg *WaitGroup) Wait() {
	for {
		state := wg.state.Load()
		contador := int32(state >> 32)
		if contador == 0 {
			return // nada pendente: retorna sem dormir
		}
		if wg.state.CompareAndSwap(state, state+1) { // registra uma espera
			runtime_SemacquireWaitGroup(&wg.sema) // dorme no semáforo do runtime
			return
		}
	}
}

Quando o runtime coloca a goroutine para dormir, ela deixa de estar pronta para rodar e a thread do sistema operacional fica livre para executar outra goroutine. Não há chamada ao kernel nem gasto de CPU durante a espera. A figura a seguir mostra essas peças e como elas se ligam.

Estrutura interna: Add e Done mudam o contador em state, Wait dorme no semáforo sema e o último Done acorda a goroutine main|880
figura 2 · Estrutura interna do WaitGroup com contador, esperas e semáforo

Na figura, as três tarefas (azul) empurram o contador para baixo, e a goroutine main (laranja) empurra para cima com o Add(3). O Wait da mesma goroutine a manda para o semáforo, na seta pontilhada. A seta verde que sai do semáforo representa o último Done() acordando a espera. Repare que o noCopy não aparece na figura, porque ele não ocupa lugar nem participa da execução: ele só existe para o analisador estático.

4. Add antes do go

A regra mais importante do WaitGroup é esta: o Add que registra uma tarefa precisa acontecer antes de a goroutine dela começar. Na prática, isso significa chamar Add fora da goroutine, antes da palavra go. O código a seguir mostra as duas versões lado a lado, em rodadas de 2000 execuções.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

const (
	rodadas = 2000
	tarefas = 4
)

// rodadaErrada chama Add dentro da goroutine filha.
// Devolve quantas tarefas tinham terminado quando Wait voltou,
// e true se o WaitGroup entrou em pânico nessa rodada.
func rodadaErrada() (concluidas int32, panico bool) {
	defer func() {
		if recover() != nil {
			panico = true
		}
	}()

	var wg sync.WaitGroup
	var feitas atomic.Int32
	for i := 0; i < tarefas; i++ {
		go func() {
			wg.Add(1) // ERRO: pode rodar depois que Wait já olhou o contador
			defer wg.Done()
			feitas.Add(1)
		}()
	}
	wg.Wait()
	return feitas.Load(), false
}

// rodadaCerta chama Add antes de cada go.
func rodadaCerta() int32 {
	var wg sync.WaitGroup
	var feitas atomic.Int32
	wg.Add(tarefas)
	for i := 0; i < tarefas; i++ {
		go func() {
			defer wg.Done()
			feitas.Add(1)
		}()
	}
	wg.Wait()
	return feitas.Load()
}

func main() {
	var voltouCedo, panicos int
	menor := int32(tarefas)
	for r := 0; r < rodadas; r++ {
		n, p := rodadaErrada()
		if p {
			panicos++
		} else if n < tarefas {
			voltouCedo++
			if n < menor {
				menor = n
			}
		}
	}

	certo := 0
	for r := 0; r < rodadas; r++ {
		if rodadaCerta() < tarefas {
			certo++
		}
	}

	fmt.Printf("Add dentro do go (%d rodadas): Wait voltou antes das tarefas em %d; pânico em %d; menor contagem vista: %d\n",
		rodadas, voltouCedo, panicos, menor)
	fmt.Printf("Add antes do go (%d rodadas): Wait voltou antes das tarefas em %d\n", rodadas, certo)
}
Add dentro do go (2000 rodadas): Wait voltou antes das tarefas em 2000; pânico em 0; menor contagem vista: 0
Add antes do go (2000 rodadas): Wait voltou antes das tarefas em 0

A primeira linha da saída é o erro. Em cada rodada, o Wait voltou com as tarefas ainda por terminar. Na execução registrada, o Wait retornou antes de todas as tarefas em todas as 2000 rodadas, e em outra execução o número foi de 1999 de 2000. O motivo é que a goroutine main chega ao Wait antes de as filhas terem sido agendadas, então o contador ainda está em zero e o Wait não tem o que esperar. Quando o Add das filhas finalmente roda, o main já passou. Por isso a regra existe: o contador tem que estar correto no instante em que o Wait olha para ele, e isso só é garantido quando o Add acontece antes do go.

O go vet da versão 1.27.2 pega esse padrão sem executar o programa. A linha da análise é esta:

02-add-no-lugar-errado/main.go:28:10: WaitGroup.Add called from inside new goroutine

A nota de versão do Go 1.25 menciona esse analisador, o waitgroup, que aponta chamadas de Add mal posicionadas. Vale rodar go vet no projeto antes de confiar só nos testes, porque o bug aparece de forma intermitente e um teste pode passar por sorte.

Add dentro da goroutine faz Wait ler contador zero e o processo encerrar; Add antes do go faz Wait esperar as três tarefas|880
figura 3 · Add dentro da goroutine (erro) e Add antes do go (certo)

A figura põe os dois casos lado a lado com os mesmos papéis. À esquerda, a tarefa rosa chama Add(1) só depois de começar, e a seta bloqueada mostra esse Add chegando tarde demais. O Wait do main já leu zero e retornou, e o processo termina, levando as tarefas junto. À direita, o main registra Add(3) antes de cada go, o contador vai para três, o Wait espera e só retorna quando as tarefas zeram o contador.

5. Copiar um WaitGroup

O Go passa argumentos por valor. Quando uma função recebe um sync.WaitGroup sem ponteiro, ela recebe uma cópia da struct inteira, e o Done() dela mexe só na cópia. O original, que o main está esperando, nunca chega a zero. O código a seguir faz exatamente isso.

package main

import (
	"fmt"
	"sync"
)

func trabalhador(id int, wg sync.WaitGroup) { // ERRO: recebe uma cópia do WaitGroup
	defer wg.Done()
	fmt.Printf("worker %d concluído\n", id)
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go trabalhador(1, wg) // cada chamada copia a struct inteira

	wg.Wait() // o contador do main nunca chega a zero
	fmt.Println("fim")
}

O programa não termina. A tarefa roda, chama Done() na sua cópia e sai. O main continua bloqueado no Wait do original, que ficou com contador 1. Como nenhuma outra goroutine existe para mudar isso, o runtime percebe que todas estão dormindo e encerra o processo com erro fatal. O trecho real da saída:

worker 1 concluído
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x102858210?, 0x18?)
	/opt/homebrew/Cellar/go/1.27.2/libexec/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x173731b96020)
	/opt/homebrew/Cellar/go/1.27.2/libexec/src/sync/waitgroup.go:206 +0xa4
main.main()
	.../03-copia-por-valor/main.go:19 +0x84
exit status 2

Repare na linha goroutine 1 [sync.WaitGroup.Wait]: ela diz exatamente onde o main está parado. Em um programa grande, essa informação aponta direto para o Wait, mas não para a cópia que causou o problema. Por isso o go vet é tão útil aqui. Ele acusa as duas ocorrências no código:

03-copia-por-valor/main.go:8:29: trabalhador passes lock by value: sync.WaitGroup contains sync.noCopy
03-copia-por-valor/main.go:17:20: call of trabalhador copies lock value: sync.WaitGroup contains sync.noCopy

A correção é passar *sync.WaitGroup na assinatura e &wg na chamada. Como a regra vale para qualquer struct que contenha noCopy, a boa prática é nunca guardar um WaitGroup por valor em um campo de struct copiada com frequência, nem retorná-lo de funções.

Cópia por valor: o go copia 16 bytes, o Done da cópia zera só a cópia, e o Wait do main fica preso no contador original em 1|880
figura 4 · Cópia de um WaitGroup por valor deixa o Wait preso

A figura acompanha a cópia pelos endereços. O original fica em um endereço (0x1000 na ilustração), com contador 1. O go copia os 16 bytes para outro endereço (0x8000), e é essa cópia que a tarefa decrementa. O Wait do main continua olhando o original, e a seta de bloqueio mostra o ponto em que o programa para. Os endereços são ilustrativos, e não a saída de uma execução.

6. Contador negativo e reuso

Se o número de Done() passar do número de Add(), o contador fica negativo, e o pacote entra em pânico na hora. O código abaixo faz isso de propósito, com um único Add(1) e dois Done().

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	wg.Done()
	fmt.Println("contador zerado; o próximo Done não tem tarefa para descontar")
	wg.Done()
	fmt.Println("nunca chega aqui")
}
contador zerado; o próximo Done não tem tarefa para descontar
panic: sync: negative WaitGroup counter

goroutine 1 [running]:
sync.(*WaitGroup).Add(0x562cb104c0f0, 0xffffffffffffffff)
	/opt/homebrew/Cellar/go/1.27.2/libexec/src/sync/waitgroup.go:118 +0x264
sync.(*WaitGroup).Done(...)
	/opt/homebrew/Cellar/go/1.27.2/libexec/src/sync/waitgroup.go:156
main.main()
	.../04-contador-negativo/main.go:13 +0x8c
exit status 2

O pânico é bom, porque aponta para a linha do Done() excedente. Em programas reais, a causa costuma ser um Done() duplicado por um defer somado a uma chamada manual, ou um Add com valor errado calculado a partir de uma lista. O código-fonte da versão 1.27.2 define duas outras mensagens de pânico ligadas a uso indevido. Uma é sync: WaitGroup misuse: Add called concurrently with Wait, para um Add que chega enquanto outra goroutine já está registrada no Wait. A outra é sync: WaitGroup is reused before previous Wait has returned, para reuso antes de o Wait anterior voltar. Esta aula não conseguiu provocar essas duas de forma repetível, então elas aparecem aqui apenas como texto do código-fonte, como registrado na seção “O que não foi confirmado”.

7. Barreira e wg.Go

Um WaitGroup pode ser reutilizado, desde que um Wait tenha voltado antes do próximo ciclo de Add. Assim ele funciona como uma barreira: todas as tarefas de uma rodada precisam terminar antes de a próxima rodada começar. O exemplo a seguir faz duas rodadas de três tarefas cada, usando wg.Go, disponível desde o Go 1.25.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for rodada := 1; rodada <= 2; rodada++ {
		for i := range 3 {
			wg.Go(func() {
				fmt.Printf("rodada %d, tarefa %d\n", rodada, i)
			})
		}
		wg.Wait() // barreira: a rodada 2 só começa depois de as três da rodada 1 terminarem
		fmt.Printf("rodada %d concluída\n", rodada)
	}
}
rodada 1, tarefa 2
rodada 1, tarefa 0
rodada 1, tarefa 1
rodada 1 concluída
rodada 2, tarefa 2
rodada 2, tarefa 0
rodada 2, tarefa 1
rodada 2 concluída

Observe que as linhas da rodada 2 só aparecem depois de rodada 1 concluída. Dentro de cada rodada a ordem das tarefas varia, mas a barreira entre rodadas é garantida pelo Wait. Repare também que o for i := range 3 cria uma variável i nova a cada iteração, o que o wg.Go precisa para capturar o valor certo. A seção 9 explica por que essa garantia depende da versão declarada no módulo.

O método wg.Go(f) faz o que o padrão Add(1), go func() e defer Done() fazia à mão: registra a tarefa, dispara a goroutine e chama Done() quando f retorna. A documentação oficial recomenda preferi-lo em código novo, e avisa que f não pode entrar em pânico, porque a goroutine criada pelo Go não tem como devolver o erro para quem espera. Nos exemplos desta aula, wg.Go e a forma manual com Add e defer Done() são equivalentes, e o código antigo com Add continua válido.

Uma consequência da barreira vale a pena guardar. Como o Wait não consome o contador de forma destrutiva, ele serve para qualquer número de rodadas, mas nenhuma rodada pode começar enquanto a anterior ainda tem goroutines bloqueadas no Wait. Se você precisa de uma barreira que todas as goroutines atravessem juntas, em ciclos repetidos, o WaitGroup sozinho não cobre o caso, porque cada rodada exige um novo WaitGroup ou um novo ciclo de Add.

8. Por que Wait não tem timeout

A assinatura de Wait é func (wg *WaitGroup) Wait(), sem parâmetro de contexto e sem prazo. Isso é intencional. O WaitGroup só sabe contar e dormir, e não tem como interromper uma goroutine que ele não criou. Quando um programa precisa de limite de tempo, a solução é colocar o Wait em outra goroutine, que avisa por um canal quando o contador zera, e usar um select com um time.After do lado de quem espera.

package main

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

// esperarComTimeout devolve true se o WaitGroup zerou dentro do prazo.
// Wait não aceita timeout, então a espera vai para outra goroutine.
func esperarComTimeout(wg *sync.WaitGroup, prazo time.Duration) bool {
	pronto := make(chan struct{})
	go func() {
		wg.Wait()
		close(pronto)
	}()

	select {
	case <-pronto:
		return true
	case <-time.After(prazo):
		return false
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(300 * time.Millisecond)
	}()

	antes := runtime.NumGoroutine()
	ok := esperarComTimeout(&wg, 50*time.Millisecond)
	fmt.Printf("dentro do prazo: %v; goroutines vivas: %d (antes da espera: %d)\n",
		ok, runtime.NumGoroutine(), antes)

	wg.Wait()
	time.Sleep(10 * time.Millisecond)
	fmt.Printf("depois que o worker terminou: goroutines vivas: %d\n", runtime.NumGoroutine())
}
dentro do prazo: false; goroutines vivas: 3 (antes da espera: 2)
depois que o worker terminou: goroutines vivas: 1

A primeira linha mostra o comportamento esperado: o prazo de 50 ms expirou antes de a tarefa, que dorme 300 ms, terminar. A segunda linha mostra o custo. Enquanto o Wait ainda não voltou, a goroutine auxiliar continua viva e presa no Wait. O número de goroutines vivas subiu de 2 para 3. Só quando a tarefa termina é que a auxiliar acorda, fecha o canal e sai, e a contagem volta a 1.

O problema é que, se a tarefa nunca terminar, a goroutine auxiliar fica presa para sempre. Ela não vaza memória de forma grave, mas é um vazamento de goroutine, e em um servidor com muitas requisições ele se acumula. A solução mais robusta é fazer a tarefa respeitar um context.Context, para que ela termine sozinha quando o prazo passar, e usar o WaitGroup só para saber que todas saíram. Quando o objetivo é também coletar erros e cancelar as irmãs ao primeiro erro, o pacote golang.org/x/sync/errgroup costuma ser o caminho, e ele combina Add, Done e Wait com contexto. Esta aula não o testou, então ele não é usado nos exemplos.

9. A armadilha da variável de loop

Antes do Go 1.22, a variável de um for era uma só para o laço inteiro. Uma goroutine criada dentro do laço e que lesse essa variável só veria o valor que ela tivesse no momento em que ela rodasse, que costuma ser o valor final. A especificação da versão atual descreve a mudança assim, em tradução livre: cada iteração tem sua própria variável declarada. O código a seguir é o mesmo nos dois módulos, e a única diferença é a linha go do go.mod.

// go.mod do módulo que usa o comportamento novo
module loopgo127

go 1.27
// go.mod do módulo que usa o comportamento antigo
module loopgo121

go 1.21
package main

import (
	"fmt"
	"sort"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	var valores []int

	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			valores = append(valores, i) // captura i
			mu.Unlock()
		}()
	}

	wg.Wait()
	sort.Ints(valores)
	fmt.Println("valores capturados:", valores)
}

No módulo com go 1.27, a saída é a sequência completa. No módulo com go 1.21, o mesmo código produz cinco vezes o último valor:

valores capturados: [0 1 2 3 4]
valores capturados: [0 1 2 3 4]
valores capturados: [0 1 2 3 4]
valores capturados: [5 5 5 5 5]
valores capturados: [5 5 5 5 5]
valores capturados: [5 5 5 5 5]
valores capturados: [5 5 5 5 5]
valores capturados: [5 5 5 5 5]

A diferença não vem da toolchain. A toolchain é a mesma, e o que muda é a versão da linguagem que o go.mod declara. Esta aula confirmou isso comparando os dois módulos. A nota de versão do Go 1.22 descreve a mudança, mas, na página consultada, não diz literalmente que a linha go do módulo controla o comportamento.

O mesmo erro aparece de duas formas no ferramental. O -race acusa a corrida, porque a goroutine lê i enquanto o laço o escreve:

==================
WARNING: DATA RACE
Read at 0x00c00019c040 by goroutine 7:
  main.main.func1()
      .../05-variavel-de-loop/go121/main.go:19 +0xc8

Previous write at 0x00c00019c040 by main goroutine:
  main.main()
      .../05-variavel-de-loop/go121/main.go:14 +0xdc

Goroutine 7 (running) created at:
  main.main()
      .../05-variavel-de-loop/go121/main.go:16 +0xc4
==================
valores capturados: [5 5 5 5 5]
Found 1 data race(s)
exit status 66

E o go vet avisa antes mesmo de rodar:

main.go:19:30: loop variable i captured by func literal

A correção no código antigo é copiar a variável dentro do corpo do laço, como em i := i, ou passar i como argumento da função. No Go 1.22 ou superior, nada disso é necessário, mas o vet ainda ajuda a conferir módulos que declaram uma versão antiga.

Variável de loop: no Go 1.21 as goroutines leem um só i e veem 5; no Go 1.22 ou superior cada iteração tem o seu i e veem de 0 a 4|880
figura 5 · Variável de loop compartilhada no Go 1.21 e por iteração no Go 1.22

A figura mostra os dois mundos. Em cima, uma única caixa i é lida por todas as goroutines, e o resultado sai com cinco valores 5. Embaixo, cada iteração tem a sua caixa, com valores de 0 a 4, e o resultado sai como [0 1 2 3 4]. A diferença está só na forma de declarar a variável, e é por isso que ela se esconde tão bem em código que parece correto.

10. Coletando resultados: slice indexado ou canal

O WaitGroup não devolve nada. Quando cada tarefa precisa produzir um valor, existem dois padrões usuais. O primeiro escreve cada resultado em uma posição de um slice já alocado, com o índice da entrada. Como cada goroutine escreve em uma posição diferente, não há corrida, e o Wait garante que a leitura só acontece depois de todas as escritas. O segundo envia os resultados por um canal com buffer, e uma goroutine sentinela espera o Wait e fecha o canal, para que o laço range do consumidor termine. Os dois estão no pacote abaixo, com testes que rodam sob -race.

package coleta

import "sync"

// PorIndice aplica f a cada entrada. Cada goroutine escreve somente no seu
// próprio índice, então o slice não precisa de mutex.
func PorIndice(entradas []int, f func(int) int) []int {
	saidas := make([]int, len(entradas))
	var wg sync.WaitGroup

	wg.Add(len(entradas))
	for i, x := range entradas {
		go func() {
			defer wg.Done()
			saidas[i] = f(x)
		}()
	}

	wg.Wait()
	return saidas
}

// Resultado carrega o índice da entrada junto com o valor calculado,
// porque a ordem de chegada no canal não é a ordem de entrada.
type Resultado struct {
	Indice int
	Valor  int
}

// ComCanal entrega os resultados por um canal. Uma goroutine sentinela
// espera o WaitGroup e fecha o canal, para o laço range terminar.
func ComCanal(entradas []int, f func(int) int) []Resultado {
	canal := make(chan Resultado, len(entradas))
	var wg sync.WaitGroup

	wg.Add(len(entradas))
	for i, x := range entradas {
		go func() {
			defer wg.Done()
			canal <- Resultado{Indice: i, Valor: f(x)}
		}()
	}

	go func() {
		wg.Wait()
		close(canal)
	}()

	var resultados []Resultado
	for r := range canal {
		resultados = append(resultados, r)
	}
	return resultados
}

O canal precisa de sentinela porque o consumidor só sabe que acabou quando o canal fecha, e quem sabe que todas as tarefas terminaram é o WaitGroup. Se o consumidor fizesse wg.Wait() antes do laço, e o canal não tivesse buffer suficiente, os produtores ficariam bloqueados no envio e o Wait nunca voltaria. A sentinela resolve isso porque o Wait roda em outra goroutine, enquanto o consumidor já drena o canal.

Os testes do pacote usam -race:

package coleta

import (
	"reflect"
	"sort"
	"testing"
)

func dobro(x int) int { return x * 2 }

func TestPorIndice(t *testing.T) {
	entradas := []int{1, 2, 3, 4, 5}
	got := PorIndice(entradas, dobro)
	want := []int{2, 4, 6, 8, 10}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("PorIndice = %v, quero %v", got, want)
	}
}

func TestComCanal(t *testing.T) {
	entradas := []int{1, 2, 3, 4, 5}
	got := ComCanal(entradas, dobro)
	if len(got) != len(entradas) {
		t.Fatalf("recebi %d resultados, quero %d", len(got), len(entradas))
	}
	sort.Slice(got, func(a, b int) bool { return got[a].Indice < got[b].Indice })
	for i, r := range got {
		if r.Indice != i || r.Valor != entradas[i]*2 {
			t.Fatalf("resultado %d = %+v", i, r)
		}
	}
}
=== RUN   TestPorIndice
--- PASS: TestPorIndice (0.00s)
=== RUN   TestComCanal
--- PASS: TestComCanal (0.00s)
PASS
ok  	aulawg/coleta	1.184s

O padrão por índice é mais simples quando o tamanho da saída é conhecido antes da execução, porque evita o canal e a sentinela. O padrão por canal serve quando os resultados chegam em ordem arbitrária e não há um índice natural para guardá-los, ou quando o consumidor precisa processar resultados enquanto eles ainda chegam. Nos dois casos, uma única goroutine fecha o canal depois que todos os envios terminaram, e isso evita o pânico de envio em canal fechado.

Coleta por índice: três workers gravam cada um na sua posição de saidas, o WaitGroup conta os Done e o main lê o slice só depois que o Wait libera|880
figura 6 · Coleta de resultados em um slice indexado por tarefa
Coleta por canal: workers enviam Resultado ao canal, uma sentinela espera o WaitGroup e fecha o canal, e o for range termina no fechamento|880
figura 7 · Coleta de resultados por canal com sentinela que fecha o canal

A primeira figura mostra o padrão por índice. As entradas entram nos workers, cada um escreve numa posição própria do slice, e a leitura do main só começa quando o Wait libera. A segunda mostra o padrão por canal, em que o WaitGroup alimenta a sentinela, a sentinela fecha o canal e o consumidor recebe os resultados até o fim. Em ambas, o WaitGroup não carrega dados: ele só garante a ordem entre as escritas e a leitura.

O WaitGroup também não protege dados compartilhados em geral. Ele garante que o Wait espera, e que as escritas feitas antes de cada Done aparecem para quem vem depois do Wait. Mas duas goroutines que escrevem na mesma variável ao mesmo tempo continuam em corrida. O pacote ruim mostra isso com um somador sem exclusão mútua, e o -race acusa:

package ruim

import "sync"

// Contar soma valores de várias goroutines sem exclusão mútua.
// O WaitGroup garante que o Wait espere, mas não protege o total.
func Contar(n int) int {
	total := 0
	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		go func() {
			defer wg.Done()
			total++ // escrita concorrente, sem Mutex nem atomic
		}()
	}
	wg.Wait()
	return total
}
==================
WARNING: DATA RACE
Write at 0x00c000012278 by goroutine 28:
  aulawg/ruim.Contar.func1()
      .../ruim/ruim.go:14 +0x78

Previous write at 0x00c000012278 by goroutine 48:
  aulawg/ruim.Contar.func1()
      .../ruim/ruim.go:14 +0x78
==================
    testing.go:1867: race detected during execution of test
FAIL
FAIL	aulawg/ruim	0.181s

Para somar corretamente, use um sync.Mutex em volta do incremento, ou uma operação atômica do pacote sync/atomic, ou ainda, melhor, deixe cada goroutine devolver o seu valor por um canal ou por índice, como nos padrões acima, e some no main.

11. Erros comuns e boas práticas

Todo mundo esquece um Done() uma vez, e quem diz que nunca esqueceu ainda não rodou o programa com carga suficiente. Os erros desta aula têm causas parecidas. Em quase todos, o contador estava certo em algum momento, mas o código o lia ou alterava no instante errado, ou o valor foi copiado e a cópia virou outro contador. A primeira regra decorre disso: chame Add com o total conhecido antes de disparar as goroutines, na mesma goroutine que vai esperar. A segunda é usar sempre defer wg.Done() na primeira linha da tarefa, para que nenhum caminho de saída esqueça o decremento. A terceira é passar o WaitGroup por ponteiro, e rodar go vet no projeto para pegar as cópias que escaparam. A quarta é não usar o WaitGroup para dados: ele espera, e a comunicação de resultado fica com slices indexados ou canais.

Há também o que não fazer. Não chame Add enquanto outra goroutine já está bloqueada no Wait do mesmo grupo com o contador em zero. Não reutilize o contador antes de o Wait anterior voltar. E não espere com timeout apenas trocando o Wait por um laço de leitura, porque o laço vira busy waiting.

ProblemaSintomaCorreção
Add dentro da goroutine filhaWait retorna cedo, e o processo pode encerrar antes das tarefasChamar Add antes do go, na goroutine que espera
WaitGroup passado por valorDeadlock com all goroutines are asleepPassar *sync.WaitGroup e &wg
Done demaispanic: sync: negative WaitGroup counterConferir se cada tarefa chama Done uma vez, usando defer
Variável de loop capturadaTodas as goroutines veem o mesmo valorGo 1.22 ou superior no go.mod, ou i := i
Escrita concorrente sem exclusão-race acusa DATA RACESlice indexado, canal, Mutex ou atomic
Espera sem limiteGoroutine auxiliar presa se a tarefa travarcontext na tarefa, e select com timeout de quem espera
Pergunta de entrevistaComo você coleta resultados e erros de várias goroutines?

Uso um slice indexado, em que cada goroutine escreve na sua posição, o que dispensa lock. Ou um canal de resultados, fechado por uma goroutine que espera o WaitGroup. Quando preciso parar no primeiro erro, uso errgroup, que cancela o contexto compartilhado. O que não faço é fechar o canal dentro dos produtores sem coordenar a espera.

12. Exercícios

Os exercícios têm resposta de referência logo abaixo de cada enunciado. Escreva a sua antes de conferir.

Exercício 1. O código a seguir tenta esperar cinco tarefas, mas às vezes termina antes delas. Explique o motivo e corrija.

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
	go func() {
		wg.Add(1)
		defer wg.Done()
		fazerTrabalho()
	}()
}
wg.Wait()

Resposta de referência. O Add(1) está dentro da goroutine filha. Quando o Wait olha o contador, as filhas podem ainda não ter rodado, e ele vale zero. A correção é chamar wg.Add(5) uma vez, antes do laço, e deixar o Done() dentro de cada goroutine:

var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
	go func() {
		defer wg.Done()
		fazerTrabalho()
	}()
}
wg.Wait()

Aqui wg é capturado pela closure, então todas as goroutines usam o mesmo contador. Se a tarefa fosse uma função separada, o parâmetro teria que ser *sync.WaitGroup.

Exercício 2. Sem executar, diga por que este programa trava. Depois corrija.

func processar(id int, wg sync.WaitGroup) {
	defer wg.Done()
	fmt.Println("tarefa", id)
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)
	go processar(1, wg)
	go processar(2, wg)
	wg.Wait()
}

Resposta de referência. O wg é passado por valor para processar, então cada Done() desce o contador da cópia, e o original fica em 2 para sempre. O Wait do main trava. A correção é receber *sync.WaitGroup e passar &wg. O go vet acusa o problema apontando as duas chamadas.

Exercício 3. Escreva buscarTodos(urls []string) []Resposta, que chama uma função de busca para cada URL em paralelo, devolve as respostas na mesma ordem das URLs e não tem corrida. Sua solução deve passar em go vet e em go run -race.

Resposta de referência, executada nesta aula. A função cria o slice de respostas antes das goroutines e cada uma escreve no próprio índice. O programa de demonstração inclui uma URL vazia, para mostrar o erro sendo guardado sem interromper as outras.

package main

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

type Resposta struct {
	URL    string
	Status int
	Erro   error
}

// simulaChamada no lugar de uma requisição HTTP real: falha para uma URL vazia.
func simulaChamada(url string) (int, error) {
	if url == "" {
		return 0, errors.New("URL vazia")
	}
	return 200, nil
}

// buscarTodos devolve uma resposta por URL, na mesma ordem da entrada.
func buscarTodos(urls []string) []Resposta {
	respostas := make([]Resposta, len(urls))
	var wg sync.WaitGroup

	wg.Add(len(urls))
	for i, url := range urls {
		go func() {
			defer wg.Done()
			status, err := simulaChamada(url)
			respostas[i] = Resposta{URL: url, Status: status, Erro: err}
		}()
	}

	wg.Wait()
	return respostas
}

func main() {
	urls := []string{"https://go.dev", "", "https://pkg.go.dev"}
	for _, r := range buscarTodos(urls) {
		if r.Erro != nil {
			fmt.Printf("%q: erro: %v\n", r.URL, r.Erro)
			continue
		}
		fmt.Printf("%q: %d\n", r.URL, r.Status)
	}
}
"https://go.dev": 200
"": erro: URL vazia
"https://pkg.go.dev": 200

O go vet ficou silencioso e o go run -race não acusou corrida. A ordem da saída é a da entrada, e não a ordem em que as goroutines terminaram, porque ela foi garantida pelo índice.

13. Glossário

Add: método do WaitGroup que soma um valor, possivelmente negativo, ao contador de tarefas pendentes.

Barreira: ponto de sincronização em que um grupo de goroutines espera até que todas cheguem. O Wait de um WaitGroup tem esse efeito para uma rodada.

Busy waiting: espera em que uma goroutine roda um laço sem parar, consumindo CPU, apenas para ler uma variável.

Compare-and-swap: operação atômica do processador que troca um valor só se ele ainda for o esperado. O Wait a usa para registrar uma espera.

Contador: número de tarefas registradas por Add e ainda não concluídas por Done.

Cópia por valor: o Go passa structs inteiras em atribuições e chamadas de função, então o destino recebe uma cópia independente.

Data race (corrida de dados): duas goroutines acessam a mesma posição de memória, pelo menos uma escreve, e nenhuma sincronização ordena os acessos. O -race detecta esse caso em tempo de execução.

Done: método que equivale a Add(-1), sinalizando que uma tarefa terminou.

noCopy: struct de tamanho zero embutida no WaitGroup para que o go vet acuse cópias por valor.

Sentinela: goroutine auxiliar cuja única função é esperar uma condição e agir sobre ela, aqui fechar um canal depois do Wait.

Semáforo do runtime: mecanismo interno que faz uma goroutine dormir sem consumir CPU e acorda-a depois. O Wait o usa por meio de runtime_SemacquireWaitGroup.

Slice indexado: slice alocado antes da execução, em que cada goroutine grava somente na posição que lhe cabe.

Variável de loop: variável declarada na cláusula de um for. Se ela é nova a cada iteração depende da versão da linguagem declarada no go.mod.

wg.Go: método desde o Go 1.25 que registra e dispara uma função em uma goroutine, chamando Done ao final.

O que não foi confirmado

Esta seção lista o que a aula afirma sem verificação completa, ou o que ficou fora do que foi testado.

O comentário que acompanha os campos de state em src/sync/waitgroup.go, na versão 1.27.2, descreve os bits em ordem diferente da que o código lê. A leitura desta aula segue o código, porque é ele que o programa executa. Essa divergência não foi investigada além disso.

As mensagens de pânico sync: WaitGroup misuse: Add called concurrently with Wait e sync: WaitGroup is reused before previous Wait has returned estão no código-fonte, mas não foram reproduzidas em um programa. A aula não sabe quantas execuções seriam necessárias para provocá-las de forma confiável.

O nome runtime_SemacquireWaitGroup e os estados _Gwaiting e _Grunnable aparecem no stack trace e na descrição do escalonador. O código do runtime que implementa o semáforo não foi lido nesta consulta, e a descrição de quem acorda quem segue a visão geral da aula de Goroutine.

Os números de contagem das seções 4 e 8 dependem da máquina e do escalonamento. Foram medidos em macOS arm64 com Go 1.27.2, e podem mudar. O que se repetiu em todas as execuções foi o padrão: o erro de Add tardio aparece em quase todas as rodadas.

A relação entre a linha go do go.mod e o comportamento das variáveis de loop foi confirmada apenas pelo experimento desta aula, com dois módulos. A página de notas de versão do Go 1.22 não diz isso literalmente, e a especificação local descreve o comportamento sem mencionar o go.mod.

O analisador waitgroup do go vet foi citado pela nota do Go 1.25. Esta aula viu a sua saída para o caso de Add dentro da goroutine, mas não conferiu a lista completa de casos que ele reconhece.

A referência ao errgroup na seção 8 não foi testada. O pacote não está usado nos exemplos.

Os endereços 0x1000 e 0x8000 da figura 04 são ilustrativos. As linhas de trace das saídas foram abreviadas com ... no caminho dos arquivos, e isso está indicado no próprio texto da saída.

Fontes

Consultadas em 3 de novembro de 2026.

  • Código-fonte e documentação da toolchain local, Go 1.27.2 instalado pelo Homebrew: src/sync/waitgroup.go, doc/go_spec.html, e a saída de go doc sync.WaitGroup e go doc sync.WaitGroup.Go. Caminho da toolchain: /opt/homebrew/Cellar/go/1.27.2/libexec.
  • Go 1.25 Release Notes, seção da biblioteca padrão para sync, sobre WaitGroup.Go, e seção de go vet sobre o analisador waitgroup. Disponível em: https://go.dev/doc/go1.25
  • Go 1.22 Release Notes, seção “Changes to the language”, sobre a semântica das variáveis de loop. Disponível em: https://go.dev/doc/go1.22
  • Especificação da linguagem Go, seção “For statements”, na cópia local da toolchain, com a nota de que cada iteração tem sua própria variável a partir do Go 1.22.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?