Una data race ocurre cuando dos goroutines acceden a la misma dirección de memoria de forma concurrente, y al menos uno de esos accesos es una escritura, sin que exista una sincronización explícita (como un sync.Mutex o un paquete sync/atomic). No te confundas: no se trata solo de que un contador sume mal; hablamos de comportamiento indefinido (undefined behavior). El compilador de Go y la CPU optimizan el código bajo la premisa de que una variable no cambiará a menos que el hilo actual la modifique. Si una race ocurre, el compilador puede decidir mantener un valor viejo en un registro de la CPU en lugar de leerlo de la memoria principal, haciendo que una goroutine vea un estado “imposible” o corrupto.
Para detectar esto, Go ofrece el race detector activado con la flag -race. Este componente instrumenta el binario, creando una estructura de memoria en la sombra (shadow memory) que rastrea cada acceso a la memoria y detecta si dos hilos chocan. Es una herramienta vital para la integridad de tu sistema, pero tiene un costo: instrumentar el binario aumenta el uso de CPU entre un 5x y un 10x, y la memoria en una escala similar. Por eso, nunca debes usarlo en producción para medir rendimiento, pero es obligatorio ejecutar tus tests y tu pipeline de CI con -race.
package example_test
import (
"testing"
)
// Counter es una estructura simple que intentará ser modificada
// por múltiples goroutines sin protección.
type Counter struct {
value int
}
// Inc incrementa el valor de forma no atómica.
func (c *Counter) Inc() {
// Esta línea es el foco de la race.
// A nivel de CPU, esto es: leer -> sumar -> escribir.
c.value++
}
// TestRaceCondition es un test diseñado para ser detectado por -race.
func TestRaceCondition(t *testing.T) {
c := &Counter{}
// Lanzamos 1000 goroutines que incrementan el mismo valor.
// Sin sincronización, esto causará una data race.
for i := 0; i < 1000; i++ {
go c.Inc()
go c.Inc()
}
// Al ejecutar `go test -race`, el detector interceptará los
// accesos concurrentes a c.value y detendrá el test con un error.
if c.value == 0 {
t.Log("El contador es cero, algo anda mal.")
}
}
En el ejemplo anterior, la línea c.value++ es el epicentro del desastre. A nivel de máquina, un incremento no es una operación atómica; es una secuencia de tres pasos: 1) Leer el valor actual de la memoria al registro, 2) Sumar 1 al registro, 3) Escribir el resultado de vuelta a la memoria. Si dos goroutines ejecutan esto exactamente al mismo tiempo, ambas pueden leer el mismo valor inicial (por ejemplo, 10), sumar 1 en sus propios registros y escribir 11, perdiendo uno de los incrementos.
El race detector detecta que la dirección de memoria de c.value está siendo accedida por dos hilos simultáneamente cuando uno de ellos intenta escribir, y lanza un reporte detallado con los stack traces de ambos hilos involucrados. Si ejecutas este test con go test, el test podría pasar con un valor incorrecto (como 1452 en lugar de 2000), dándote una falsa sensación de seguridad. Solo con go test -race verás la verdad.
El error frecuente
Un error clásico es pensar que leer una variable es “seguro” porque “solo la voy a leer”. Por ejemplo, si tienes una goroutine que escribe en un map y otra que lo lee, el programa no solo dará resultados inconsistentes; el runtime de Go detectará la concurrencia en el mapa y provocará un panic inmediato que no puedes capturar con recover.
N° 150