Un data race en su definición más básica es una condición que ocurre cuando 2 o más hilos acceden una variable compartida/global y al menos uno de los hilos la escribe.
El siguiente ejemplo es exagerado para mostrar cómo ocurre:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
wg := sync.WaitGroup{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { counter++; wg.Done() }()
}
wg.Wait() // esto se asegura que todos los hilos terminen.
fmt.Println(counter)
}
En este ejemplo iniciamos 1000 goroutinas que incrementan el valor de
counter
en 1
, usamos un sync.WaitGroup
para asegurarnos que todas
las goroutinas se ejecuten.
Cuánto es el resultado del programa anterior? No se sabe. Es
indeterminado. Puede ser 1000
pero también puede ser 930
, 999
, …
no se sabe.
Muchos programadores tienden a pensar que como ellos conocen las circunstancias en las que corre el programa o en sus pruebas siempre funciona entonces el “data race es benigno”. Esto es un grave error, no existe tal cosa. Como el resultado cuando hay data races es indeterminado, el código no es correcto y cosas malas pueden ocurrir, peor aún, cuando eso pasa es extremadamente difícil encontrar la causa del problema.
Cómo detectar un data race?
Existen varias implementaciones para detectar data races, en Go el
compilador oficial ya trae soporte integrado para detectarlos.
Simplemente se agrega el flag -race
cuando se ejecutan los tests, se
compila o se ejecuta el programa. Ejemplo:
$ go run -race ejemplo.go
==================
WARNING: DATA RACE
Read at 0x00c0000a4008 by goroutine 8:
main.main.func1()
ejemplo.go:15 +0x38
Previous write at 0x00c0000a4008 by goroutine 7:
main.main.func1()
ejemplo.go:15 +0x4e
Goroutine 8 (running) created at:
main.main()
ejemplo.go:14 +0xe8
Goroutine 7 (finished) created at:
main.main()
ejemplo.go:14 +0xe8
==================
1000
Found 1 data race(s)
exit status 66
Cómo prevenir un data race?
A los programadores, incluso a los más experimentados, nos cuesta mucho trabajo pensar en paralalismo, pensar en que el código se ejecuta exactamente a la vez en 2 o más procesadores.
La primera opción de muchos programadores cuando encuentran un data race es usar
un if
para controlar el acceso a la variable, como en el siguiente ejemplo:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
wg := sync.WaitGroup{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
if counter == 0 {
counter++
}
wg.Done()
}()
}
wg.Wait()
fmt.Println(counter)
}
Este es un ejemplo de un supuesto data race "benigno"
. El resultado al
ejecutarlo casi siempre es 1
pero en realidad el comportamiento del programa
sigue siendo indeterminado y por lo tanto incorrecto.
El race detector también encontrará el problema en el ejemplo anterior:
$ go run -race ejemplo.go
==================
WARNING: DATA RACE
Read at 0x00c0000a4008 by goroutine 8:
main.main.func1()
ejemplo.go:15 +0x3c
Previous write at 0x00c0000a4008 by goroutine 7:
main.main.func1()
ejemplo.go:16 +0x8d
Goroutine 8 (running) created at:
main.main()
ejemplo.go:14 +0xe8
Goroutine 7 (finished) created at:
main.main()
ejemplo.go:14 +0xe8
==================
1
Found 1 data race(s)
exit status 66
Existen varios mecanismo para implementar una sincronización correcta,
quizás el más común son los llamados Mutex
:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
wg := sync.WaitGroup{}
mu := sync.Mutex{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
mu.Lock()
counter++
mu.Unlock()
wg.Done()
}()
}
wg.Wait()
fmt.Println(counter)
}
Este programa siempre retornara 1000
y el race detector de Go no
detectará ningún problema.
El problema de usar estos mecanismos es que bloquean por lo tanto vuelven nuestro programa más lento. La mejor opción es no compartir memoria entre hilos a menos que sea necesario o ahorre tiempo sin impactar demasiado el rendimiento.