Condições de corrida (data races) são um dos bugs mais sutis e perigosos em programas concorrentes. Em Go, a linguagem oferece ferramentas poderosas para detectá-las e preveni-las. Nesta aula, vamos explorar o que é uma data race, como usar o race detector integrado, as técnicas de sincronização e a abordagem de memória compartilhada via comunicação.

Entender e evitar condições de corrida é essencial para escrever código concorrente seguro e confiável. Vamos mergulhar nos conceitos e práticas que todo desenvolvedor Go deve dominar.

O que é data race

Uma data race ocorre quando duas ou mais goroutines acessam a mesma variável simultaneamente, e pelo menos um dos acessos é de escrita. Isso leva a comportamentos imprevisíveis, pois o resultado depende da ordem de execução não determinística das goroutines.

Em Go, uma data race é uma condição de corrida que envolve acesso concorrente a memória compartilhada sem sincronização adequada. O modelo de memória de Go define que, sem sincronização, não há garantia de que uma goroutine veja as alterações feitas por outra.

Exemplo clássico de data race:

package main

import (
	"fmt"
	"sync"
)

var counter int

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // data race: acesso concorrente sem sincronização
		}()
	}
	wg.Wait()
	fmt.Println(counter)
}

Neste código, múltiplas goroutines incrementam a variável counter simultaneamente. O resultado esperado seria 1000, mas devido à data race, o valor final pode ser menor e variar a cada execução.

go test -race

Go inclui um detector de corridas (race detector) integrado que pode ser ativado com a flag -race nos comandos go run, go build e go test. Ele instrumenta o código para detectar acessos concorrentes a memória compartilhada em tempo de execução.

Para usar com testes, execute:

go test -race ./...

O detector reporta cada data race encontrada, mostrando o stack trace das goroutines envolvidas. É uma ferramenta essencial para identificar problemas de concorrência durante o desenvolvimento.

Exemplo de saída do race detector:

WARNING: DATA RACE
Read at 0x00c0000a0008 by goroutine 8:
  main.main.func1()
      /path/to/main.go:12 +0x3a

Previous write at 0x00c0000a0008 by goroutine 7:
  main.main.func1()
      /path/to/main.go:12 +0x50

Goroutine 8 (running) created at:
  main.main()
      /path/to/main.go:10 +0x47

Goroutine 7 (finished) created at:
  main.main()
      /path/to/main.go:10 +0x47

O detector é ativado em tempo de execução, portanto, para detectar todas as raças, é necessário que o código seja executado com cargas de trabalho que exercitem os caminhos concorrentes.

Sincronização

A sincronização em Go pode ser feita usando canais (channels) ou primitivas de sincronização do pacote sync, como Mutex e WaitGroup. A escolha depende do padrão de concorrência desejado.

Uso de Mutex:

package main

import (
	"fmt"
	"sync"
)

type SafeCounter struct {
	mu sync.Mutex
	v  int
}

func (c *SafeCounter) Inc() {
	c.mu.Lock()
	c.v++
	c.mu.Unlock()
}

func (c *SafeCounter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.v
}

func main() {
	var wg sync.WaitGroup
	c := SafeCounter{}
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc()
		}()
	}
	wg.Wait()
	fmt.Println(c.Value())
}

Uso de canais:

package main

import (
	"fmt"
)

func main() {
	ch := make(chan int)
	go func() {
		for i := 0; i < 1000; i++ {
			ch <- 1
		}
		close(ch)
	}()

	sum := 0
	for v := range ch {
		sum += v
	}
	fmt.Println(sum)
}

Ambos os exemplos evitam data races: o primeiro usando mutex para proteger o acesso à variável compartilhada, o segundo usando canais para comunicação e evitando compartilhamento direto de memória.

Memória compartilhada

O lema de Go para concorrência é: "Não se comunique compartilhando memória; em vez disso, compartilhe memória comunicando-se." Isso significa que, idealmente, você deve usar canais para passar dados entre goroutines, em vez de acessar diretamente variáveis compartilhadas.

No entanto, há situações em que o compartilhamento de memória com sincronização é mais apropriado, como em estruturas de dados de alto desempenho ou quando o padrão de acesso é complexo. Nesses casos, use sync.Mutex ou sync.RWMutex para proteger os acessos.

Exemplo com RWMutex:

package main

import (
	"fmt"
	"sync"
)

type SafeMap struct {
	mu sync.RWMutex
	m  map[string]int
}

func (sm *SafeMap) Get(key string) int {
	sm.mu.RLock()
	defer sm.mu.RUnlock()
	return sm.m[key]
}

func (sm *SafeMap) Set(key string, value int) {
	sm.mu.Lock()
	defer sm.mu.Unlock()
	sm.m[key] = value
}

func main() {
	sm := SafeMap{m: make(map[string]int)}
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			sm.Set(fmt.Sprintf("key%d", i), i)
		}()
	}
	wg.Wait()
	fmt.Println(sm.Get("key5"))
}

Lembre-se de que o race detector só encontra raças que realmente ocorrem durante a execução. Portanto, é importante escrever testes que exerçam a concorrência de forma abrangente.

Boas práticas

Sempre execute seus testes e programas com a flag -race durante o desenvolvimento. Evite compartilhar memória sempre que possível; prefira canais. Se precisar compartilhar, use primitivas de sincronização adequadas e mantenha o escopo de bloqueio o menor possível.

Além disso, evite padrões como sync/atomic para iniciantes, a menos que você entenda profundamente as barreiras de memória. O race detector é seu aliado, mas não substitui um bom design.

Referências

Exercícios

  1. O código a seguir contém uma data race. Identifique-a e corrija usando um mutex.

    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    var count int
    
    func main() {
    	var wg sync.WaitGroup
    	for i := 0; i < 100; i++ {
    		wg.Add(1)
    		go func() {
    			defer wg.Done()
    			count++
    		}()
    	}
    	wg.Wait()
    	fmt.Println(count)
    }

    ✓ Resposta: Adicione um mutex para proteger o acesso a count:
    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    var (
    	count int
    	mu    sync.Mutex
    )
    
    func main() {
    	var wg sync.WaitGroup
    	for i := 0; i < 100; i++ {
    		wg.Add(1)
    		go func() {
    			defer wg.Done()
    			mu.Lock()
    			count++
    			mu.Unlock()
    		}()
    	}
    	wg.Wait()
    	fmt.Println(count)
    }
  2. Reescreva o programa do exercício 1 usando canais para evitar data races, sem usar mutex.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    )
    
    func main() {
    	ch := make(chan int)
    	go func() {
    		for i := 0; i < 100; i++ {
    			ch <- 1
    		}
    		close(ch)
    	}()
    	sum := 0
    	for v := range ch {
    		sum += v
    	}
    	fmt.Println(sum)
    }
  3. O que o comando go test -race ./... faz? Explique em uma frase.

    ✓ Resposta: Ele executa todos os testes do pacote atual e subpacotes com o detector de corridas ativado, que reporta acessos concorrentes a memória compartilhada.
  4. Escreva um programa que cria 10 goroutines, cada uma escrevendo em um mapa compartilhado, protegido por sync.RWMutex. Use o race detector para verificar se há data races.

    ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    type SafeMap struct {
    	mu sync.RWMutex
    	m  map[int]int
    }
    
    func (sm *SafeMap) Set(key, value int) {
    	sm.mu.Lock()
    	defer sm.mu.Unlock()
    	sm.m[key] = value
    }
    
    func main() {
    	sm := SafeMap{m: make(map[int]int)}
    	var wg sync.WaitGroup
    	for i := 0; i < 10; i++ {
    		wg.Add(1)
    		go func(i int) {
    			defer wg.Done()
    			sm.Set(i, i*2)
    		}()
    	}
    	wg.Wait()
    	fmt.Println(sm.m)
    }
  5. Explique a diferença entre sync.Mutex e sync.RWMutex e quando usar cada um.

    ✓ Resposta: sync.Mutex fornece bloqueio exclusivo: apenas uma goroutine pode acessar o recurso por vez. sync.RWMutex permite múltiplas leituras simultâneas, mas apenas uma escrita exclusiva. Use sync.RWMutex quando houver muitas leituras e poucas escritas, pois melhora a performance.