Publicado originalmente no [Medium]
Carolina Vila-Nova
A concorrência é um dos superpoderes do Go, mas também pode se tornar uma dor de cabeça para o desenvolvedor iniciante. Felizmente, Go nos dá múltiplas ferramentas nativas para lidar com código concorrente –a questão é saber quando usar cada uma delas. Este artigo aborda esse tema, usando exemplos com diferentes complexidades.
Sincronização para quê?
Imagine que você está construindo um contador de visitantes para um site.
type VisitorCounter struct {
count int
}
func (c *VisitorCounter) Increment() {
c.count++
}
func (c *VisitorCounter) GetCount() int {
return c.count
}
À primeira vista, o código parece correto. Mas quando N goroutines são executadas simultaneamente (e de maneira randômica), algo estranho acontece:
// Duas requisições HTTP chegam simultaneamente
// Goroutine A (Usuário 1): Goroutine B (Usuário 2):
lê c.count (valor atual: 100) lê c.count (valor atual: 100)
incrementa para 101 incrementa para 101
escreve 101 em c.count escreve 101 em c.count
// Resultado: count = 101 (deveria ser 102)
// Um visitante foi "perdido"
É o que se chama de “race condition”, ou condição de corrida: como as threads acessam um recurso compartilhado mas não estão sincronizadas, o resultado final depende de qual é executada primeiro. Em um site com milhares de visitantes simultâneos, você poderia perder uma quantidade significativa de dados. Fca clara assim a necessidade de sincronizar o acesso a dados compartilhados.
Usando mutexes
Uma possível solução é usar a primitiva de sincronização mutex (acrônimo de "mutual exclusion") para garantir que apenas uma goroutine possa modificar o contador por vez:
type SafeVisitorCounter struct {
mu sync.Mutex
count int
}
func (c *SafeVisitorCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++ // Apenas uma goroutine executa isso por vez
}
func (c *SafeVisitorCounter) GetCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count // Leitura também precisa ser protegida
}
Agora você tem um contador mais seguro — mas também um problema de performance. Se seu site tem um dashboard que exibe o contador em tempo real e 1.000 usuários querem visualizá-lo simultaneamente, todos vão ter que esperar em fila para ler o valor, mesmo que não exista conflito entre as leituras.
Otimizando leituras com RWMutex
É aí que podemos empregar um mutex que possa distinguir entre operações de leitura e de escrita:
type OptimizedVisitorCounter struct {
mu sync.RWMutex
count int
}
func (c *OptimizedVisitorCounter) Increment() {
c.mu.Lock() // Escrita ainda precisa de acesso exclusivo
defer c.mu.Unlock()
c.count++
}
func (c *OptimizedVisitorCounter) GetCount() int {
c.mu.RLock() // Múltiplas leituras podem acontecer simultaneamente
defer c.mu.RUnlock()
return c.count
}
Com essa mudança, N usuários podem ler o contador simultaneamente, enquanto apenas as escritas (incrementos) precisam esperar por acesso exclusivo. Assim, a performance melhora para workloads com muitas leituras.
Um exemplo mais complexo: gerenciamento de tokens
Agora que entendemos os conceitos básicos, vamos aplicá-los a um problema mais realista. Vamos construir um microsserviço que precisa se autenticar junto a uma API externa usando tokens JWT. Esses tokens expiram periodicamente e precisam ser renovados, mas você não quer ter que fazer uma nova chamada à API a cada requisição.
func (s *TokenService) GetToken(ctx context.Context) (string, error) {
s.mu.RLock()
if time.Now().Before(s.expiresAt) && s.token != "" {
token := s.token
s.mu.RUnlock()
return token, nil
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
newToken, expiresAt, err := s.apiClient.GetNewToken(ctx)
if err != nil {
return "", err
}
s.token = newToken
s.expiresAt = expiresAt
return newToken, nil
}
Aqui há pequeno problema. Se 50 requisições chegarem simultaneamente quando o token está expirando, todas vão tentar renová-lo, resultando em 50 chamadas desnecessárias à API externa.
O padrão "double-check"
Uma solução elegante para esse problema é o padrão “double-check”, ou seja, uma dupla checagem da condição, uma vez com o read lock e outra vez com o write lock.
func (s *SimpleTokenService) GetToken() string {
// primeira checagem - leitura rápida
s.mu.RLock()
if time.Now().Before(s.expiry) && s.token != "" {
token := s.token
s.mu.RUnlock()
return token
}
s.mu.RUnlock()
// segunda checagem com write lock
s.mu.Lock()
defer s.mu.Unlock()
// Verifica se outra goroutine já obteve o token atualizado
if time.Now().Before(s.expiry) && s.token != "" {
return s.token
}
// Apenas uma goroutine chega até aqui
fmt.Println("🔄 Making API call...")
time.Sleep(100 * time.Millisecond)
s.token = fmt.Sprintf("token-%d", time.Now().Unix())
s.expiry = time.Now().Add(5 * time.Second)
return s.token
}
O double-check previne o problema do “thundering herd”, que ocorre quando várias threads competem por um recurso limitado assim que ele se torna disponível. Em vez de cada uma fazer uma chamada à API, elas compartilham o resultado de apenas uma chamada.
Quando as coisas dão errado: "deadlocks"
Quando nossa aplicação se torna mais complexa e múltiplos recursos são compartilhados entre vários processos, surge o risco de deadlock. Situações comuns em que isso ocorre são acessos a dispositivos ou a execução de transações em bases de dados.
Considere um sistema de transferência bancária:
type Account struct {
id int
mu sync.Mutex
balance float64
}
func Transfer(from, to *Account, amount float64) error {
from.mu.Lock()
defer from.mu.Unlock()
to.mu.Lock()
defer to.mu.Unlock()
if from.balance < amount {
return errors.New("saldo insuficiente")
}
from.balance -= amount
to.balance += amount
return nil
}
Mas veja o que ocorre quando duas transferências simultâneas acontecem em direções opostas:
// Goroutine 1: Transfer(contaA, contaB, 100)
contaA.mu.Lock() // ✅ Consegue lock A
contaB.mu.Lock() // ⏳ Espera Goroutine 2 liberar B
// Goroutine 2: Transfer(contaB, contaA, 200)
contaB.mu.Lock() // ✅ Consegue lock B
contaA.mu.Lock() // ⏳ Espera Goroutine 1 liberar A
// Resultado: Ambas esperam para sempre
Os dois processos ficam em estado de espera mútua indefinida, cada um bloqueando um recurso que o outro precisa e que só pode ser libertado pelo processo bloqueado, impedindo a execução de ambos e, potencialmente, do sistema.
Uma possível solução seria estabelecer uma ordem consistente para a aquisição de locks:
func SafeTransfer(from, to *Account, amount float64) error {
first, second := from, to
if first.id > second.id {
first, second = second, first
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if from.balance < amount {
return errors.New("saldo insuficiente")
}
from.balance -= amount
to.balance += amount
return nil
}
Agora, independentemente da direção da transferência, os locks são sempre adquiridos na mesma ordem, eliminando a possibilidade de deadlock ou de race condition.
Quando usar channels
Quando não há estado compartilhado a ser protegido, mas há a necessidade de garantir uma ordem de processamento, uma coordenação de comportamentos ou estados a comunicar, o Go oferece uma ferramenta distinta: channels.
No caso a seguir, queremos processar uploads de imagens em um pipeline:
func ProcessImageUploads(imagePaths []string) []ProcessedImage {
rawImages := make(chan string, 10)
resizedImages := make(chan ResizedImage, 10)
finalImages := make(chan ProcessedImage, 10)
go func() {
defer close(rawImages)
for _, path := range imagePaths {
rawImages <- path // "Aqui está uma imagem para processar"
}
}()
go func() {
defer close(resizedImages)
for path := range rawImages {
img := loadImage(path)
resized := resize(img)
resizedImages <- resized // "Aqui está uma imagem redimensionada"
}
}()
go func() {
defer close(finalImages)
for resized := range resizedImages {
final := applyFilters(resized)
finalImages <- final // "Aqui está uma imagem processada"
}
}()
var results []ProcessedImage
for final := range finalImages {
results = append(results, final)
}
return results
}
Aqui, o uso de channels garante que as imagens sejam processas em um pipeline em que os estágios correm de maneira concorrente para melhor performance e cada um processa as imagens conforme elas são disponibilizadas.
Combinando ambas as abordagens
Em sistemas reais, frequentemente precisamos fazer uso de ambas as abordagens. Considere um sistema de métricas que coleta dados de múltiplas fontes:
type MetricsCollector struct {
// Channel para coordenação - receber eventos
events chan MetricEvent
// Lock para proteção - guardar métricas agregadas
mu sync.RWMutex
metrics map[string]float64
}
func (m *MetricsCollector) Start() {
go func() {
for event := range m.events { // Channel: coordenação
m.mu.Lock() // Lock: proteção de dados
m.metrics[event.Name] += event.Value
m.mu.Unlock()
}
}()
}
func (m *MetricsCollector) RecordEvent(name string, value float64) {
m.events <- MetricEvent{Name: name, Value: value} // Channel: comunicação
}
func (m *MetricsCollector) GetMetric(name string) float64 {
m.mu.RLock() // Lock: leitura segura
defer m.mu.RUnlock()
return m.metrics[name]
}
Aqui usamos channels para coordenar o envio de eventos (comportamento) e locks para proteger o map de métricas (dados).
Framework de decisão
Use mutex quando:
- Você tem dados compartilhados (variáveis, maps, slices)
- Múltiplas goroutines precisam ler/escrever os mesmos dados
- Você quer simplicidade e performance para proteção de estado
Use RWMutex quando:
- Você tem muito mais leituras que escritas
- A performance de leituras concorrentes é importante
- Você ainda está protegendo dados compartilhados
Use channels quando:
- Você quer coordenar o comportamento entre goroutines
- Você está implementando pipelines ou worker pools
- Você quer comunicar valores, não proteger estado compartilhado
Use ambos quando:
- Você tem um sistema complexo com coordenação E proteção de dados
- Você quer separar claramente a comunicação da proteção de estado
Lembre-se dessa máxima do Go:
“Don’t communicate by sharing memory; share memory by communicating.”
Conclusão
A chave para um código Go concorrente robusto é escolher a ferramenta certa para cada problema específico, entender as compensações e sempre pensar em cenários de falha.
PS — O Go tem uma ferramenta marota para detectar deadlocks: a flag -race
go run -race main.go

Top comments (0)