DEV Community

Cover image for Concorrência em Go: o uso de locks e channels para evitar deadlocks
Carolina Vila-Nova
Carolina Vila-Nova

Posted on

Concorrência em Go: o uso de locks e channels para evitar deadlocks

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.

gopher learning

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    
}
Enter fullscreen mode Exit fullscreen mode

À 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):  
 c.count (valor atual: 100)        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"
Enter fullscreen mode Exit fullscreen mode

É 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  
}
Enter fullscreen mode Exit fullscreen mode

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  
}
Enter fullscreen mode Exit fullscreen mode

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  
}
Enter fullscreen mode Exit fullscreen mode

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  
}
Enter fullscreen mode Exit fullscreen mode

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  
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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  
}
Enter fullscreen mode Exit fullscreen mode

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  
}
Enter fullscreen mode Exit fullscreen mode

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]  
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)