Qué es un data race?


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.