Sincronização
Quando várias goroutines executam ao mesmo tempo, pode acontecer de duas ou mais tentarem acessar e modificar a mesma variável. Isso pode causar uma race condition (como já vimos de forma superficial), que acontece quando o resultado de uma operação depende da ordem em que diferentes goroutines acessam os dados. Podemos evitar esses problemas usando recursos como sync.Mutex e sync/atomic, que permitem controlar o acesso a dados compartilhados entre goroutines.
Mutex
O Mutex (Mutual Exclusion) é um recurso do package sync que permite proteger uma parte do código, impedindo que várias goroutines executem essa parte ao mesmo tempo. Para isso, usamos Lock() para bloquear o acesso e Unlock() para liberar o acesso quando a operação terminar.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
contador := 0
wg.Add(2)
// Cria duas goroutines.
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
// Cada goroutine incrementa o contador 1000 vezes.
for j := 0; j < 1000; j++ {
mu.Lock()
contador++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println("Contador:", contador)
}
Resultado:
Contador: 2000
Nesse exemplo, criamos duas goroutines que incrementam a mesma variável contador. Sem o Mutex, as duas poderiam tentar modificar o valor ao mesmo tempo, causando uma race condition e produzindo resultados incorretos, mas quando usamos:
mu.Lock()
contador++
mu.Unlock()
O Lock() garante que apenas uma goroutine por vez consiga executar a parte protegida pelo mesmo Mutex. Se outra goroutine tentar acessar enquanto ele estiver bloqueado, precisará aguardar até que Unlock() libere o acesso. Isso permite que as duas goroutines modifiquem a mesma variável sem interferir uma na outra.
O sync.WaitGroup continua responsável por aguardar a conclusão das goroutines, enquanto o sync.Mutex protege o acesso à variável compartilhada.
Como o Mutex controla uma variável compartilhada?
Quando várias goroutines modificam uma mesma variável, pode parecer que cada uma trabalha com uma cópia do valor, podendo gerar resultados diferentes durante a execução, porém, as goroutines acessam a mesma variável na memória, desde que estejam trabalhando com uma variável compartilhada.
Exemplo, imagine duas goroutines incrementando a variável contador.
contador := 0
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
mu.Lock()
contador++
mu.Unlock()
}
}()
}
Nesse exemplo, a variável contador foi declarada fora das goroutines, então ambas acessam o mesmo valor na memória. Já a variável j pertence ao for interno de cada goroutine, fazendo com que cada uma tenha seu próprio j para contar as repetições.
Quando uma goroutine executa mu.Lock(), ela obtém o acesso exclusivo à parte protegida pelo Mutex, que é a parte de escrita na variável compartilhada. Se outra goroutine tentar executar mu.Lock() no mesmo Mutex, precisará aguardar até que a primeira libere o acesso com mu.Unlock().
Podemos imaginar a execução assim:
Memória compartilhada
contador = 0
Goroutine 1
mu.Lock()
contador = 0 + 1 → contador = 1
mu.Unlock()
Goroutine 2
mu.Lock()
contador = 1 + 1 → contador = 2
mu.Unlock()
Goroutine 2
mu.Lock()
contador = 2 + 1 → contador = 3
mu.Unlock()
Goroutine 1
mu.Lock()
contador = 3 + 1 → contador = 4
mu.Unlock()
Perceba que as goroutines não precisam executar alternadamente. Uma mesma goroutine pode conseguir o Mutex várias vezes seguidas, dependendo de como o Go organiza sua execução. O importante é que cada goroutine lê o valor atualizado do contador ao entrar na parte protegida pelo Mutex, porque todas estão trabalhando com a mesma variável.
& para modificar a variável?A diferença está em como a variável é acessada pela função. Em Go, quando passamos uma variável como argumento para uma função, o valor da variável é copiado, porém, no nosso exemplo, não estamos passando contador como argumento para as goroutines. Estamos usando uma função anônima que acessa uma variável declarada fora dela, isso é conhecido como closure.
Uma closure é uma função que consegue acessar variáveis do escopo onde foi criada, mesmo que essas variáveis estejam fora do corpo da função, exemplo:
package main
import "fmt"
func main() {
contador := 0
// A função acessa a variável contador diretamente.
incrementar := func() {
contador++
}
incrementar()
incrementar()
fmt.Println(contador)
}
Resultado:
2
Perceba que não passamos &contador, mas a função conseguiu modificar a variável original. Isso acontece porque a closure captura a variável do escopo externo, e não uma cópia do seu valor.
O valor é copiado quando passamos uma variável como argumento de uma função:
package main
import "fmt"
func incrementar(contador int) {
contador++
}
func main() {
contador := 0
// Passa uma cópia do valor.
incrementar(contador)
fmt.Println(contador)
}
Resultado:
0
Aqui a função recebeu uma cópia de contador, por isso, a alteração não afeta a variável original. Se quiséssemos modificar a variável original dessa forma, precisaríamos receber um ponteiro e passar &contador (como já estudamos).
*Então, resumindo, quando passamos uma variável como argumento para uma função, uma cópia do seu valor é criada. Já quando uma função anônima acessa diretamente uma variável do escopo externo, ela trabalha com a variável original, podendo modificar seu valor na memória.
E por que isso não afeta o loop for?
O for de cada goroutine funciona de forma independente, porque a variável j é local à função executada por aquela goroutine, exemplo:
Goroutine 1 Goroutine 2
j = 0 → +1 j = 0 → +1
j = 1 → +1 j = 1 → +1
j = 2 → +1 j = 2 → +1
| |
| |
v v
┌────────────────────────────────┐
│ Memória compartilhada │
│ │
│ contador = 6 │
└────────────────────────────────┘
Isso significa que cada goroutine executará suas 1000 repetições, independentemente de quantas vezes a outra goroutine já tenha incrementado o contador compartilhado. Ao finalizar, teremos 1000 incrementos da primeira goroutine e outros 1000 da segunda, totalizando 2000.
Sem o Mutex, poderia acontecer de duas goroutines lerem o mesmo valor antes de qualquer uma terminar a atualização:
contador = 0
Goroutine 1 Goroutine 2
Lê contador = 0 Lê contador = 0
Calcula 0 + 1 Calcula 0 + 1
Escreve contador = 1 Escreve contador = 1
Resultado final: contador = 1
Nesse caso, mesmo que as duas goroutines tenham executado contador++, o resultado foi apenas 1, porque uma atualização sobrescreveu o resultado da outra. Esse problema é conhecido como race condition e, nesse exemplo, também ocorre uma data race, porque duas goroutines acessam a mesma variável sem sincronização e pelo menos uma delas modifica seu valor.
O Mutex evita esse problema, garantindo que apenas uma goroutine por vez execute o incremento protegido. Assim, nenhuma atualização será perdida, independentemente da ordem em que as goroutines forem executadas.
Atomic
O Atomic é um recurso do package sync/atomic que permite executar determinadas operações sobre variáveis compartilhadas de forma atômica, sem precisar controlar manualmente o acesso com Lock() e Unlock(). Uma operação atômica é uma operação que acontece de uma vez só, sem permitir que outras goroutines interfiram enquanto ela está sendo executada.
Por exemplo, quando duas goroutines incrementam o mesmo contador usando atomic.Add(1), o Go garante que cada incremento seja feito corretamente, sem que uma goroutine sobrescreva o resultado da outra. Isso é muito útil para operações simples, como incrementar contadores ou modificar valores compartilhados:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
// Cria um contador atômico.
var contador atomic.Int64
wg.Add(2)
// Cria duas goroutines.
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
// Incrementa o contador 1000 vezes.
for j := 0; j < 1000; j++ {
contador.Add(1)
}
}()
}
wg.Wait()
// Lê o valor do contador.
fmt.Println("Contador:", contador.Load())
}
Resultado:
Contador: 2000
Nesse exemplo, usamos atomic.Int64, que representa um número inteiro de 64 bits preparado para operações atômicas, e quando chamamos:
contador.Add(1)
O Go incrementa o valor de forma atômica, permitindo que várias goroutines façam essa operação sem perder incrementos. Para consultar o valor, usamos:
contador.Load()
O Load() faz a leitura atômica do valor, garantindo que ela seja compatível com as operações atômicas feitas por outras goroutines. Diferente do Mutex, não precisamos bloquear e liberar manualmente o acesso, porque o próprio atomic oferece operações específicas para trabalhar com esses valores.