Blog

sync, el detector de carreras y atomic en Go

Dos goroutines que cambian la misma variable sin coordinarse producen una carrera de datos y pierden cambios en silencio. Aprende cómo sync.Mutex, RWMutex, atomic y Once la evitan, y cómo go run -race la encuentra.

Las goroutines que solo leen sus propios datos son fáciles. El problema empieza cuando dos de ellas cambian la misma variable. Nada se cae y nada te avisa, pero algunos de los cambios desaparecen.

Este post muestra ese bug y luego las herramientas que Go te da para evitarlo y para detectarlo: sync.Mutex, sync.RWMutex, sync/atomic, sync.Once, el detector de carreras y go vet. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.

Una carrera de datos: dos goroutines, un contador

Una carrera de datos (data race) ocurre cuando dos goroutines tocan la misma memoria al mismo tiempo y al menos una de ellas escribe. Aquí hay dos goroutines, y cada una suma 1 a un contador compartido mil veces:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				count++
			}
		})
	}
	wg.Wait()
	fmt.Println("done")
}

Si lo ejecutas con go run -race ., imprime un reporte como este (los ... reemplazan líneas con direcciones de memoria y números de goroutine que cambian en cada ejecución):

WARNING: DATA RACE
...
done
exit status 66

El reporte nombra la línea, main.go:14, que es count++. Muestra una goroutine que lee o escribe count después de que otra lo escribió, sin nada que coordine a las dos. El programa igual termina e imprime done. Después, el detector de carreras hace que salga con código 66, así que una ejecución de pruebas o un job de CI falla en lugar de pasar sin decir nada. (wg.Go inicia una goroutine y la cuenta, y wg.Wait espera a todas. Hay un repaso corto más abajo.)

Este programa no imprime count a propósito. Esperarías 2000, y en alguna ejecución puede que lo veas. También puedes ver menos, y un número distinto la próxima vez. Un resultado que cambia de una ejecución a otra no se puede copiar como salida verificada, y ese es justamente el problema: no puedes confiar en él.

Algo nos sorprendió cuando ejecutamos esto en Go 1.26. En algunas ejecuciones, el reporte dice que una goroutine ya había terminado cuando la otra tocó count. Las dos nunca se solaparon, y aun así el detector reportó una carrera. No espera a ver un choque. Nota que nada, ni un lock ni un canal, obligó a una goroutine a ir antes que la otra.

Por qué la cuenta sale mal

count++ parece un solo paso, pero la máquina lo hace en tres: leer el valor, sumar 1 y escribir el resultado de vuelta. Dos goroutines pueden intercalar esos pasos, y cuando lo hacen, un incremento sobrescribe al otro.

G1 count G2 antes de count++ lee 0 anota 1 antes de count++ bloquea, lee 0 anota 1, libera antes de count++ lee 0 anota 1 espera el lock bloquea, lee 1 anota 2, libera 0 1 0 1 2 mutex se perdió uno: dos incrementos, count es 1 nada se pierde: count es 2 sin lock: cada goroutine lee, suma 1 y escribe con sync.Mutex: solo quien lo tiene toca count count vale 0; G1 y G2 ejecutan count++ una vez G1 lee 0. Antes de escribir, G2 también lee 0 G1 suma 1 al 0 que leyó y escribe 1 G2 suma 1 al 0 que leyó y escribe 1: se pierde lo de G1 con un mutex: G1 bloquea, lee 0, escribe 1, libera G2 bloquea, lee 1, escribe 2, libera: count es 2

Dos goroutines ejecutan cada una count++ una vez sobre un count compartido. Sin un lock, las dos leen 0 y las dos escriben 1, así que se pierde un incremento. Con un mutex, solo la goroutine que tiene el lock puede leer y escribir, así que la segunda ve 1 y escribe 2. El orden sin lock que se muestra es solo un intercalado posible: otros dan el resultado correcto, y por eso el bug se esconde.

Estos son los mismos pasos en palabras, por si la animación no se reproduce:

  1. count vale 0. G1 y G2 ejecutan count++ una vez cada una.
  2. G1 lee 0. Antes de que G1 escriba algo, G2 también lee 0.
  3. G1 suma 1 al 0 que leyó y escribe 1.
  4. G2 suma 1 al 0 que leyó ella y también escribe 1. Hubo dos incrementos, pero count vale 1. La actualización de G1 se perdió.
  5. Ahora con un mutex. G1 bloquea, lee 0, escribe 1 y libera. G2 tiene que esperar el lock.
  6. G2 bloquea, lee 1, escribe 2 y libera. count vale 2.

Ese orden sin lock es uno de muchos. Si G1 termina antes de que G2 lea, el resultado es correcto. Qué órdenes ocurren depende del scheduler y de la máquina, así que el bug aparece y desaparece.

Explicado como si tuvieras diez años

Dos niños comparten una pizarra con un número escrito. El trabajo de cada niño es sumarle 1.

El niño A mira la pizarra, ve 5 y empieza a calcular 5 + 1 en la cabeza. El niño B mira en el mismo momento, también ve 5 y también calcula 6. El niño A borra el 5 y escribe 6. El niño B borra ese 6 y escribe 6. Los dos niños hicieron su trabajo, y el número solo subió en uno. A veces se manchan tanto la escritura el uno al otro que ya no se puede leer el número.

Un mutex es un único marcador. Solo el niño que tiene el marcador puede mirar la pizarra y escribir en ella. Los demás esperan hasta que lo suelte. Es más lento, porque hay niños parados esperando, pero el número siempre es correcto.

La versión precisa

count++ se compila en una lectura de la memoria que guarda count, una suma en un registro de la CPU y una escritura de vuelta a memoria. Esos tres pasos no son una sola operación indivisible. Cuando dos goroutines los ejecutan sin sincronización, el scheduler, y en una máquina con varios núcleos el propio hardware, puede intercalarlos en cualquier orden. Un leer-modificar-escribir que se solapa con otro lo sobrescribe.

El modelo de memoria de Go llama a esto una carrera de datos: dos goroutines acceden a la misma variable de forma concurrente, al menos un acceso es una escritura y ninguna sincronización los ordena. Un programa con una carrera de datos no tiene un resultado garantizado. Las actualizaciones perdidas son el caso leve. Con valores de varias palabras, como un string, la cabecera de un slice o una interfaz, un lector puede ver la mitad de un valor viejo y la mitad de uno nuevo.

Dónde falla la analogía: los niños se ven estirando la mano hacia la pizarra. Las goroutines no. Nada en count++ busca otra goroutine, y solo espera si agregas un lock. Y con la carrera real no ves una mancha. Obtienes un número que se ve perfectamente normal y está mal.

sync.Mutex: una goroutine a la vez

Un sync.Mutex es un lock con dos métodos. Lock espera hasta que el mutex esté libre y lo toma, y Unlock lo libera. El código entre los dos se ejecuta en una sola goroutine a la vez:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var mu sync.Mutex
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				mu.Lock()
				count++
				mu.Unlock()
			}
		})
	}
	wg.Wait()
	fmt.Println("count:", count)
}

Imprime:

count: 2000

Lo ejecutamos veinte veces, con y sin -race, y obtuvimos count: 2000 cada vez, sin ningún reporte de carrera. Es la versión corregida de la animación, mil veces por goroutine. El valor cero de sync.Mutex está desbloqueado, así que var mu sync.Mutex ya está listo para usarse.

El código entre Lock y Unlock es la sección crítica. Mantenla pequeña. Todas las demás goroutines que quieren el lock esperan mientras lo tienes, así que haz el trabajo lento, como una llamada de red o la lectura de un archivo, antes de bloquear.

Proteger los campos de un struct

La forma habitual de usar un mutex es ponerlo en un struct junto a los campos que protege, y hacer todo el bloqueo dentro de los métodos:

package main

import (
	"fmt"
	"sync"
)

// Stock counts items by name. It's safe to use from many goroutines.
type Stock struct {
	mu     sync.Mutex // guards counts
	counts map[string]int
}

func NewStock() *Stock {
	return &Stock{counts: make(map[string]int)}
}

func (s *Stock) Add(name string, n int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.counts[name] += n
}

func (s *Stock) Get(name string) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.counts[name]
}

func main() {
	s := NewStock()
	var wg sync.WaitGroup
	for range 50 {
		wg.Go(func() {
			s.Add("apples", 2)
			s.Add("pears", 1)
		})
	}
	wg.Wait()
	fmt.Println(s.Get("apples"), s.Get("pears"))
}

Imprime:

100 50

Cincuenta goroutines sumaron 2 manzanas y 1 pera cada una, y no se perdió nada. Quienes llaman nunca ven el mutex. Llaman a Add y Get, y el struct se cuida solo.

defer s.mu.Unlock() justo después de Lock es el patrón normal. El unlock se ejecuta sin importar cómo retorne el método, incluso durante un panic, así que un return temprano no puede dejar el lock tomado. El lock se mantiene hasta que la función termina, y esa es una razón más para que estos métodos sean cortos.

Dos convenciones más. Pon el mutex justo encima de los campos que protege, con un comentario que lo diga. Y usa receptores de puntero, func (s *Stock): un receptor de valor bloquea una copia del mutex, lo que no protege nada, como muestra la sección de go vet. Sin el mutex, este programa también podría caerse con fatal error: concurrent map writes, una comprobación que el runtime hace incluso sin -race.

sync.RWMutex para datos que se leen mucho más de lo que se escriben

Un sync.RWMutex deja que cualquier cantidad de lectores lo tengan a la vez, pero un escritor lo tiene solo. Úsalo cuando las lecturas son frecuentes y las escrituras son raras, como una configuración que un handler lee en cada request y que un administrador cambia una vez al día:

package main

import (
	"fmt"
	"sync"
)

type Config struct {
	mu       sync.RWMutex
	settings map[string]string
}

func (c *Config) Get(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.settings[key]
}

func (c *Config) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.settings[key] = value
}

func main() {
	c := &Config{settings: map[string]string{"mode": "fast"}}

	var wg sync.WaitGroup
	results := make([]string, 8)
	for i := range 8 {
		wg.Go(func() {
			results[i] = c.Get("mode")
		})
	}
	wg.Wait()
	fmt.Println(results)

	c.Set("mode", "safe")
	fmt.Println(c.Get("mode"))
}

Imprime:

[fast fast fast fast fast fast fast fast]
safe

RLock y RUnlock toman el lado de lectura. Ocho goroutines pueden leer mode en el mismo momento, y ninguna bloquea a otra. Lock y Unlock toman el lado de escritura, que espera hasta que todos los lectores se hayan ido y no deja entrar lectores nuevos mientras escribe.

Cada goroutine escribe en su propio elemento, results[i], así que nunca comparten una variable. Por eso el slice en sí no necesita lock, y también por eso la salida sale en un orden fijo.

No uses RWMutex por defecto. Lleva más contabilidad que un Mutex, así que con lecturas y escrituras mezcladas, o con una sección crítica diminuta, muchas veces no gana nada. Empieza con Mutex y cambia cuando un profile muestre lectores haciendo fila.

sync/atomic: contadores sin locks

El paquete sync/atomic tiene tipos cuyas operaciones se hacen como pasos únicos que la CPU garantiza que no se pueden intercalar. Para un contador, eso es todo lo que necesitas:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var count atomic.Int64
	var wg sync.WaitGroup
	for range 4 {
		wg.Go(func() {
			for range 1000 {
				count.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("count:", count.Load())
}

Imprime:

count: 4000

count.Add(1) hace la lectura, la suma y la escritura como un solo paso indivisible, así que no hay nada con lo que otra goroutine pueda intercalarse. Load lee el valor actual. El valor cero es 0 y está listo para usarse.

El tipo atomic.Int64, junto con Int32, Uint64, Bool y Pointer[T], llegó en Go 1.19. El código más viejo llama a atomic.AddInt64(&n, 1) sobre un int64 normal. Los tipos son más seguros: no puedes leer el valor si no es a través de Load, y go vet se queja si copias uno.

Cuando los atomics no alcanzan

Los atomics hacen segura una operación. No hacen segura una secuencia de operaciones. Esta función reserva un asiento si queda alguno, y cada llamada que hace es atómica, pero aun así está rota:

func reserveBroken(seats *atomic.Int64) bool {
	if seats.Load() > 0 { // check...
		seats.Add(-1) // ...then act: another goroutine can run in between
		return true
	}
	return false
}

Con un asiento libre, dos goroutines pueden hacer Load y obtener 1 las dos, ver las dos que es mayor que 0, y hacer Add(-1) las dos. Vendiste el último asiento dos veces y seats vale -1. El detector de carreras tampoco lo marca, porque cada acceso pasó por un atomic. Es una carrera de lógica, no una carrera de datos.

La solución es convertir la comprobación y la actualización en un solo paso. CompareAndSwap(old, new) pone el valor nuevo solo si el valor sigue siendo old, y te dice si lo hizo:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// reserve takes one seat if any are left. The check and the update
// happen in a single CompareAndSwap, so no one can slip in between.
func reserve(seats *atomic.Int64) bool {
	for {
		n := seats.Load()
		if n <= 0 {
			return false
		}
		if seats.CompareAndSwap(n, n-1) {
			return true
		}
		// someone else changed seats since we loaded it: try again
	}
}

func main() {
	var seats atomic.Int64
	seats.Store(10)

	var booked atomic.Int64
	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() {
			if reserve(&seats) {
				booked.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println("booked:", booked.Load(), "left:", seats.Load())
}

Imprime:

booked: 10 left: 0

Cien goroutines persiguieron diez asientos, y exactamente diez consiguieron uno. Ese bucle funciona, pero ya es más difícil de leer de lo que sería un mutex. Esta es la regla práctica: usa atomics para un solo número, como un contador, un flag o un indicador. En cuanto tengas que mantener dos valores consistentes, o comprobar una cosa y luego cambiar otra, usa un mutex.

sync.Once y sync.OnceValue: hacerlo exactamente una vez

sync.Once ejecuta una función exactamente una vez, sin importar cuántas goroutines la llamen ni cómo se solapen. Esa es la forma segura de hacer una inicialización perezosa en código concurrente. Go 1.21 agregó sync.OnceValue, que envuelve una función que devuelve un valor y guarda el resultado en caché:

package main

import (
	"fmt"
	"sync"
)

var (
	setupOnce sync.Once
	ready     bool
)

func setup() {
	fmt.Println("setting up")
	ready = true
}

var loadConfig = sync.OnceValue(func() map[string]string {
	fmt.Println("loading config")
	return map[string]string{"region": "eu"}
})

func main() {
	var wg sync.WaitGroup
	regions := make([]string, 5)
	for i := range 5 {
		wg.Go(func() {
			setupOnce.Do(setup)
			regions[i] = loadConfig()["region"]
		})
	}
	wg.Wait()
	fmt.Println(ready, regions)
}

Imprime:

setting up
loading config
true [eu eu eu eu eu]

Cinco goroutines llamaron a setupOnce.Do(setup), y setting up se imprimió una vez. Si una segunda goroutine llega mientras setup todavía se está ejecutando, Do la hace esperar hasta que setup retorne. Por eso es seguro leer ready después, y por eso setting up siempre se imprime antes que loading config.

loadConfig es una función devuelta por sync.OnceValue. La primera llamada ejecuta la función envuelta, y cada llamada posterior devuelve el mismo map sin volver a ejecutarla. sync.OnceValues hace lo mismo para una función que devuelve un valor y un error.

El código escrito a mano del tipo “si es nil, créalo” tiene el mismo bug de comprobar-y-actuar que el ejemplo de los asientos. sync.Once es la solución.

sync.WaitGroup, en breve

sync.WaitGroup espera a que un conjunto de goroutines termine, y todos los programas de este post dependen de él. La parte sobre goroutines lo cubrió a fondo. En resumen: wg.Go(f) inicia f en una goroutine nueva y la cuenta, y wg.Wait() se bloquea hasta que todas hayan retornado. Antes de Go 1.25 escribías wg.Add(1), go func() { defer wg.Done(); ... }() a mano, y todavía vas a ver esa forma en la mayoría del código existente. Un WaitGroup solo espera. No protege ningún dato, así que no reemplaza a un mutex.

El detector de carreras: -race

El detector de carreras viene integrado en el toolchain de Go, y lo activas con un solo flag. Funciona con run, test y build:

go run -race .
go test -race ./...
go build -race -o app .

-race compila tu programa con instrumentación extra que registra cada acceso a memoria y cada operación de lock, de canal y de WaitGroup. Cuando dos goroutines acceden a la misma memoria sin sincronización que las ordene, y una escribe, imprime un reporte WARNING: DATA RACE con los dos stack traces. Al final imprime Found N data race(s) y sale con código 66.

Encuentra carreras de datos en el código que realmente se ejecuta durante esa ejecución. Como mostró el primer ejemplo, los dos accesos no tienen que chocar en el mismo instante. Solo tienen que estar sin orden.

Lo que no puede encontrar:

  • Carreras en código que no se ejecutó. Si el camino con la carrera solo se ejecuta cuando un request trae cierto header, y tu prueba nunca envía ese header, -race no reporta nada. Una ejecución limpia con -race significa “no hay carreras en lo que se ejecutó”, no “no hay carreras”.
  • Carreras de lógica. El asiento reservado dos veces solo usaba llamadas atómicas, así que no hay carrera de datos que reportar. El bug está en tu lógica.
  • Deadlocks. Esa es otra falla, y el runtime reporta algunos por su cuenta.

Tiene un costo real. Un programa con -race usa varias veces más memoria y corre varias veces más lento, así que no se publican binarios de producción con él. Ejecuta go test -race ./... en cada cambio, en local y en CI. El detector nunca reporta un falso positivo: si dice que hay una carrera, la hay.

Copiar un mutex es un bug, y go vet lo detecta

Un mutex no se debe copiar después del primer uso. Una copia es un lock aparte, así que bloquearla no protege nada. La forma fácil de copiar uno por accidente es un receptor de valor:

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

Inc recibe una copia de todo el Counter, mutex incluido. Bloquea la copia, incrementa el n de la copia y tira las dos. Llamar a c.Inc() sobre un contador nuevo e imprimir c.n imprime 0. Compila sin quejarse, pero go vet lo rechaza. En un módulo llamado example.com/counter, go vet . imprime:

main.go:13:9: Inc passes lock by value: example.com/counter.Counter contains sync.Mutex

y sale con código 1. Esta comprobación se llama copylocks. También marca pasar por valor a una función un struct que contiene un mutex, asignar uno a una variable nueva y recorrer por valor un slice de ellos. La solución aquí es el receptor de puntero, func (c *Counter) Inc().

No cuentes con que go test lo detecte. Solo ejecuta un subconjunto pequeño de las comprobaciones de vet, y cuando agregamos un archivo de pruebas a este paquete en Go 1.26, go test pasó. Ejecuta go vet ./... tú mismo, y también en CI.

¿Mutex o canal?

El proverbio de Go es “no comuniques compartiendo memoria; comparte memoria comunicando”, y los canales, el tema de la parte sobre canales y select, son la herramienta para eso. Es una guía, no una prohibición de los mutex. La biblioteca estándar usa mucho los dos.

Una forma razonable de elegir:

  • Usa un canal cuando pasas la propiedad de unos datos de una goroutine a otra, o cuando coordinas pasos: un pool de workers, un pipeline, un resultado que se devuelve, una señal para detenerse.
  • Usa un mutex cuando varias goroutines comparten un estado que se queda en un solo lugar: una caché, un contador, un map de sesiones. Un struct con un mutex y unos pocos métodos suele ser más corto y más claro que una goroutine dueña del map que atiende pedidos por canales.
  • Usa un atomic para un solo número o flag que muchas goroutines actualizan.

Si el código se te resiste, prueba con la otra opción.

sync.Map, y por qué casi nunca lo necesitas

sync.Map es un map seguro para uso concurrente sin un lock propio. Parece la opción obvia, pero es especializado. No tiene tipos (las claves y los valores son any), no tiene len, y está afinado para dos casos: claves que se escriben una vez y después solo se leen, como una caché que solo crece, y muchas goroutines que trabajan cada una con su propio conjunto de claves sin solaparse. Para todo lo demás, incluida la mayoría de los maps de una API REST, un map normal con un sync.Mutex o un sync.RWMutex al lado tiene tipos, es más fácil de razonar y muchas veces es más rápido. Usa sync.Map solo cuando un profile muestre contención de lock justo en ese patrón.

Qué recordar

  • Una carrera de datos son dos goroutines usando la misma variable a la vez, con al menos una escritura y sin sincronización. count++ es leer, sumar y escribir, y las escrituras que se solapan se pierden.
  • sync.Mutex deja entrar a una goroutine a la vez en una sección crítica. Bloquea, usa defer para el unlock, mantén la sección pequeña y pon el mutex junto a los campos que protege.
  • sync.RWMutex permite muchos lectores o un escritor. Úsalo para datos que se leen mucho, no por defecto.
  • atomic.Int64 y sus parientes hacen segura una sola operación. Comprobar-y-actuar necesita CompareAndSwap o un mutex, y el detector de carreras no va a atrapar el bug de lógica.
  • sync.Once y sync.OnceValue ejecutan la inicialización exactamente una vez, sin importar cuántas goroutines la pidan.
  • Ejecuta go test -race ./... todo el tiempo. Solo encuentra carreras en el código que se ejecuta, y un reporte nunca es una falsa alarma.
  • Nunca copies un mutex. Usa receptores de puntero y deja que go vet lo haga cumplir.

Si dos goroutines pueden tocar la misma variable y una de ellas escribe, algo tiene que decidir quién va primero.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.