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
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ção | Pergunta que responde |
|---|---|
| 1. O problema da espera | Por que não basta um laço que confere uma variável? |
| 2. Add, Done e Wait | Como um grupo de tarefas é contado e esperado? |
| 3. Como funciona por dentro | O que existe na struct e o que acontece quando Wait dorme? |
| 4. Add antes do go | Por que a ordem entre Add e go decide se a espera funciona? |
| 5. Copiar um WaitGroup | Por que passar o valor em vez do ponteiro trava o programa? |
| 6. Contador negativo e reuso | Quais pânicos o próprio pacote lança? |
| 7. Barreira e wg.Go | Como reutilizar o contador em rodadas e o atalho da versão 1.25? |
| 8. Por que Wait não tem timeout | Como esperar com prazo sem deixar uma goroutine presa? |
| 9. A armadilha da variável de loop | Por que a mesma linha de código pode capturar valores diferentes conforme o go.mod? |
| 10. Coletando resultados | Como receber valores de volta por índice ou por canal, sem corrida? |
| 11. Erros comuns e boas práticas | Quais decisões evitam os problemas das seções anteriores? |
| 12. Exercícios | Três tarefas com resposta de referência |
| 13. Glossário | Termos técnicos desta aula |
| O que não foi confirmado | O que esta aula afirma sem verificação completa |
| Fontes | De 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.
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.
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.
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.
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.
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.
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.
| Problema | Sintoma | Correção |
|---|---|---|
Add dentro da goroutine filha | Wait retorna cedo, e o processo pode encerrar antes das tarefas | Chamar Add antes do go, na goroutine que espera |
WaitGroup passado por valor | Deadlock com all goroutines are asleep | Passar *sync.WaitGroup e &wg |
Done demais | panic: sync: negative WaitGroup counter | Conferir se cada tarefa chama Done uma vez, usando defer |
| Variável de loop capturada | Todas as goroutines veem o mesmo valor | Go 1.22 ou superior no go.mod, ou i := i |
| Escrita concorrente sem exclusão | -race acusa DATA RACE | Slice indexado, canal, Mutex ou atomic |
| Espera sem limite | Goroutine auxiliar presa se a tarefa travar | context 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 dego doc sync.WaitGroupego 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, sobreWaitGroup.Go, e seção dego vetsobre o analisadorwaitgroup. 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
- Goroutine
- Channels
- Context
- Structs
- Ponteiros
- sync
- deadlock
- Race Condition
- Worker Pools e Rate Limiting
- Defer
- Trilha de Go