Pular para o conteúdo principal
sync — WaitGroup e mutexes

sync — WaitGroup e mutexes

8 min de leitura

Arquivado emLinguagem de Programação Goem

Aprenda a coordenar o ciclo de vida de goroutines com WaitGroup e a proteger estado compartilhado com Mutex e RWMutex.

Goroutines tornam o lançamento de trabalho concorrente trivial — uma única keyword go é suficiente. Mas lançar é apenas metade da história. A outra metade é saber quando esse trabalho termina, e garantir que goroutines que compartilham memória não corrompam o estado umas das outras. O package sync é a resposta do Go para ambos os problemas. Ele fornece um conjunto pequeno de primitivos cuidadosamente projetados que cobrem os padrões de coordenação mais comuns sem a cerimônia de APIs de threading de baixo nível.

O problema do goroutine leak

Quando a goroutine principal encerra, o runtime do Go desliga o programa inteiro imediatamente — sem limpeza, sem esperar. Qualquer goroutine ainda em execução é silenciosamente terminada. Isso cria dois problemas relacionados: trabalho que nunca é concluído, e recursos que as goroutines estavam segurando (arquivos abertos, conexões de banco de dados, mutexes bloqueados) que nunca são liberados.

Uma solução ingênua é dormir por tempo suficiente para as goroutines terminarem:

Isso funciona por acidente. Se process demorar mais que o esperado, ou se o escalonamento for atrasado sob carga, a goroutine principal encerra antes de todos os jobs terminarem. Um sleep fixo nunca é o mecanismo correto de coordenação — é um chute disfarçado de código.

WaitGroup

sync.WaitGroup resolve o problema de fan-out/fan-in diretamente: ele espera um conjunto de goroutines terminar antes de permitir que o chamador prossiga.

Um WaitGroup é um contador atômico com três operações:

MétodoEfeito
Add(n)Incrementa o contador em n — chamado antes de iniciar goroutines
Done()Decrementa o contador em 1 — chamado quando uma goroutine termina
Wait()Bloqueia até o contador chegar a zero

defer wg.Done() dentro de process garante que o contador seja decrementado mesmo que a função retorne antecipadamente ou entre em pânico. wg.Wait() em main bloqueia até que todas as cinco goroutines tenham chamado Done, momento em que o contador chega a zero e a execução continua.

Chame Add antes do go statement

Add deve ser chamado antes da goroutine iniciar — especificamente, antes da keyword go. Se você chamar Add dentro da goroutine, há uma race condition: Wait pode verificar o contador antes que qualquer goroutine tenha chamado Add, encontrar zero, e retornar prematuramente. Sempre associe Add(1) ao go statement que o segue.

Um WaitGroup não deve ser copiado após o primeiro uso. Sempre passe-o como pointer (*sync.WaitGroup) para goroutines e funções auxiliares — nunca por valor.

Data races e memória compartilhada

WaitGroup cuida do ciclo de vida das goroutines. Mas quando múltiplas goroutines estão rodando concorrentemente, um segundo problema surge: estado compartilhado.

Considere mil goroutines incrementando um contador compartilhado:

Executar isso com go run -race main.go reporta uma data race. O incremento counter++ não é uma instrução única de CPU — ele se expande em: leia o valor atual, some um, escreva o resultado de volta. Se duas goroutines leem o mesmo valor antes que qualquer uma escreva de volta, um incremento é perdido.

Data races estão entre os bugs mais insidiosos em programação concorrente. Eles produzem resultados que parecem corretos na maioria das vezes e falham de forma imprevisível sob carga ou em hardware diferente. Executar testes com -race deve ser prática padrão — o overhead vale a pena.

Mutex

sync.Mutex garante que apenas uma goroutine possa executar uma seção crítica por vez. Qualquer goroutine que chamar Lock enquanto outra segura o mutex é bloqueada até que Unlock seja chamado.

A convenção é chamar defer mu.Unlock() imediatamente após mu.Lock(). Isso garante que o lock seja sempre liberado quando a função circundante retornar, independentemente do caminho do código. É fácil esquecer um Unlock em cada branch — defer elimina esse risco completamente.

O código entre Lock e Unlock é a seção crítica: o bloco que requer acesso exclusivo ao estado compartilhado. Apenas uma goroutine o executa por vez; todas as outras esperam em Lock.

Um mutex protege dados, não código

Pense em um mutex como proteção para um dado específico, não para linhas de código. Mantenha o mutex próximo aos dados que ele protege — frequentemente como um field no mesmo struct. Isso torna a relação clara e evita que o mutex seja mal aplicado por código que não sabe o que está protegendo.

Seções críticas e performance

Cada linha dentro de uma seção crítica é serial. Goroutines esperando em Lock não fazem progresso até que o titular atual chame Unlock. Se a seção crítica contiver operações lentas, ela se torna um gargalo que limita o paralelismo do programa.

A solução é minimizar a seção crítica: faça o lock apenas do que deve ser protegido, realize qualquer trabalho caro fora do lock, e libere assim que o estado compartilhado estiver estável. Isso mantém a janela de exclusividade curta.

Para workloads com muito mais leituras do que escritas, mesmo um mutex mínimo cria contenção desnecessária: leitores que não modificam os dados ainda se bloqueiam mutuamente, mesmo que pudessem rodar em paralelo com segurança. Esta é a motivação para RWMutex.

RWMutex

sync.RWMutex é um reader/writer lock. Ele distingue entre dois modos de acesso:

  • Read lock (RLock / RUnlock): múltiplas goroutines podem segurar um read lock simultaneamente. Leitores não se bloqueiam mutuamente.
  • Write lock (Lock / Unlock): exclusivo. Um writer espera todos os leitores ativos terminarem, e novos leitores esperam enquanto um writer segura o lock.

Um cache em memória compartilhado ilustra bem o padrão — lookups são frequentes, atualizações são raras:

Todas as dez goroutines leitoras prosseguem sem se bloquear. Um Set concorrente esperaria até que os leitores ativos liberassem seus read locks, e novos leitores chegando enquanto um writer está esperando são enfileirados — evitando que o writer fique em starvation indefinidamente.

Prefira Mutex a menos que contenção em leituras seja comprovada

RWMutex tem mais overhead que Mutex devido ao bookkeeping necessário para leitores concorrentes. Para workloads com leituras e escritas balanceadas ou baixa concorrência, um Mutex simples é mais direto e frequentemente mais rápido. Recorra ao RWMutex quando o profiling mostrar contenção de lock em um caminho comprovadamente dominado por leituras.

O que isso significa na prática

WaitGroup, Mutex e RWMutex endereçam os dois desafios centrais em programas concorrentes: ciclo de vida (saber quando goroutines terminam) e segurança (evitar que escritas concorrentes corrompam estado compartilhado).

WaitGroup fornece fan-out/fan-in estruturado. Mutex serializa o acesso a dados compartilhados. RWMutex estende essa serialização para permitir leituras paralelas quando escritas são infrequentes. Esses três primitivos aparecem em quase todo programa Go concorrente não-trivial, e dominá-los é a base para tudo que vem depois.