El Go Memory Model define las condiciones bajo las cuales una escritura en una variable por parte de una goroutine es garantizada como visible para una lectura en otra goroutine. No se trata de qué valores se escriben, sino del orden y la visibilidad de esos cambios. La pieza fundamental aquí es la relación happens-before. Si una operación $A$ happens-before una operación $B$, entonces la escritura realizada por $A$ es garantizada como visible para la lectura realizada por $B$.
Esta distinción es crítica porque los compiladores y las CPUs modernas reordenan las instrucciones para optimizar el rendimiento. En un procesador con múltiples núcleos, una CPU puede tener su propia caché y no ver inmediatamente un cambio hecho por otra CPU. Sin una relación formal de sincronización, el orden de ejecución de tu código fuente puede no coincidir con el orden de las operaciones de memoria en el hardware, lo que resulta en un comportamiento no determinista.
Debes utilizar mecanismos de sincronización explícitos cada vez que una variable sea escrita por una goroutine y leída por otra. Si ignoras esto y confías en la “suerte” del orden de ejecución, lo que obtendrás es una data race. El resultado no será solo un valor incorrecto, sino un programa cuyo comportamiento cambia según la arquitectura del procesador o la carga de trabajo, haciendo que los errores sean casi imposibles de reproducir en entornos de desarrollo.
Las relaciones de sincronización que establecen un happens-before son:
1. Instrucción go: El inicio de una goroutine happens-before la ejecución de su cuerpo.
2. Canales: Un send en un canal happens-before del receive correspondiente que recibe ese valor (especialmente en canales sin buffer, pero aplicable de forma transitiva).
3. sync.Mutex: Un Unlock happens-before de cualquier Lock posterior sobre el mismo mutex.
4. sync.WaitGroup: Un Done() happens-before del retorno de Wait().
package example_test
import (
"sync"
"testing"
)
// Data representa un estado complejo que no debe ser leído de forma parcial.
type Data struct {
Value int
Ready bool
}
func TestMemoryModelVisibility(t *testing.T) {
// d es el recurso compartido entre la goroutine escritora y el hilo principal.
d := &Data{}
var wg sync.WaitGroup
wg.Add(1)
// Goroutine escritora
go func() {
// Operación A: Escritura de datos.
d.Value = 100
d.Ready = true
// Operación B: Sincronización.
// wg.Done() establece un happens-before con wg.Wait().
wg.Done()
}()
// El hilo principal espera la señal de sincronización.
// Operación C: Recepción de la señal.
// La salida de wg.Wait() garantiza que la operación B ha completado.
wg.Wait()
// Operación D: Lectura de datos.
// Gracias a la transitividad del modelo de memoria:
// A -> B (orden de programa en goroutine)
// B -> C (garantía de sync.WaitGroup)
// C -> D (la lectura ocurre después de que Wait() retorna)
// Por lo tanto, A -> D. El valor 100 es garantizado.
if !d.Ready || d.Value != 100 {
t.Errorf("Error de visibilidad: d.Value = %d, d.Ready = %v", d.Value, d.Ready)
}
}
Desglose del concepto
En el ejemplo anterior, la integridad de los datos depende enteramente de la cadena de eventos que hemos construido. Fíjate en la estructura Data: si intentáramos leer d.Value justo antes de que wg.Wait() termine, no tendríamos ninguna garantía de qué valor veríamos, incluso si el procesador ya físicamente había escrito el 100 en su caché local.
La clave reside en la transitividad. La goroutine ejecuta d.Value = 100 (A) antes de llamar a wg.Done() (B). El método wg.Done() le dice al runtime que esa goroutine ha terminado su parte, y esto se comunica al hilo principal a través de wg.Wait() (C). Como el diseño de sync.WaitGroup garantiza que Done happens-before el retorno de Wait, y nuestro código de prueba realiza la lectura (D) inmediatamente después de Wait, se establece la cadena de visibilidad completa. Sin la llamada a wg.Wait(), la lectura de d.Value podría devolver 0 debido a que el compilador podría haber reordenado las escrituras o simplemente porque la CPU de la goroutine principal no ha sincronizado su caché con la de la goroutine escritora.
El error frecuente
El error más sutil y peligroso es intentar implementar una señalización manual usando un tipo primitivo sin mecanismos de sincronización.
// ERROR: Código con data race y comportamiento indefinido
var done bool
func riskyFunction() {
go func() {
time.Sleep(time.Millisecond * 10)
done = true // Escritura sin sincronización
}()
for !done { // Lectura continua en un bucle
// El compilador puede optimizar esto asumiendo que 'done'
// nunca cambia dentro de este hilo, creando un bucle infinito.
}
}
En este escenario, no hay una relación happens-before entre la escritura de done = true y la lectura en el for. El compilador de Go, al optimizar, podría notar que done no cambia dentro del cuerpo del bucle for y decidir que es seguro cargar el valor de done en un registro una sola vez antes de entrar al bucle, ignorando cualquier cambio posterior en la memoria principal. Esto resultaría en un bucle infinito, incluso si la otra goroutine ya modificó la variable.
N° 149