Skip to main content


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.


Por que não precisamos usar o operador & 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.