El día que tu goroutine decidió escribir sin pedir turno (y cómo evitarlo antes de que producción te lo recuerde)
Imagina esto: son las 3:47 de la madrugada. Tu móvil vibra. Es la alerta del monitoring. Los usuarios reportan que la app devuelve datos que no cuadran, los contadores van por libre y un servicio ha empezado a escupir pánicos que ni tú mismo sabes de dónde salen.
Te conectas medio dormido, abres los logs y ahí está. Un fatal error: concurrent map writes. Clásico. Tu código compilaba, tus tests pasaban, e incluso en local iba fino. Pero en producción, con cientos de peticiones por segundo, dos goroutines se han peleado por la misma variable y ha ganado la anarquía.
Bienvenido al error de memoria compartida en Go. Ese que no ves hasta que lo ves. Y cuando lo ves, duele.
Qué es eso de "memoria compartida" y por qué Go te lo pone tan fácil de romper
Vamos al grano. En Go, cuando lanzas una goroutine con go miFuncion(), esa función corre en paralelo (o al menos de forma concurrente) con el resto. Y aquí viene el detalle jugoso: las goroutines comparten el mismo espacio de memoria del proceso. No hay copias, no hay aislamiento mágico. Si dos goroutines tocan la misma variable a la vez, tienes un data race servido.
Piénsalo como una cocina compartida. Tú estás batiendo huevos y tu compañero, sin avisar, coge el mismo bol y le echa sal. El resultado no es una tortilla mejor: es un desastre que nadie pidió.
En Go esto se traduce en algo tan tonto como esto:
go var contador int
func incrementar() { contador++ }
func main() { for i := 0; i < 1000; i++ { go incrementar() } time.Sleep(time.Second) fmt.Println(contador) // ¿1000? Ojalá. }
¿Qué crees que imprime? Pues casi nunca 1000. Imprime 987, 942, 1000, 971... lo que le apetezca al scheduler. Porque contador++ no es una operación atómica: es leer, sumar y escribir. Y entre leer y escribir, otra goroutine puede colarse y pisar el valor.
Esto en un script de juguete es una anécdota. En producción es un incidente con post-mortem incluido.
El detector de mentiras que Go te regala (y casi nadie usa)
Antes de seguir, un consejo que te va a ahorrar disgustos: usa el race detector. Go lo trae de serie y es tan simple como ejecutar tus tests con go test -race ./... o compilar con go run -race main.go.
No es magia negra. Instrumenta tu código, detecta accesos concurrentes no sincronizados y te suelta un informe con las dos goroutines implicadas y las líneas exactas. Es como tener un compañero pesado que te dice "eh, aquí la vas a liar" antes de que la líes.
Mi opinión sin filtros: si tu CI no corre con -race, estás volando a ciegas. Y no, no vale "es que va más lento". Más lento es un deploy que no revienta a las 4 de la mañana.
Canales: el idioma nativo de Go para hablar entre goroutines
Aquí es donde Go brilla de verdad. La filosofía del lenguaje no es "bloquea todo con mutex y reza". Es otra: no comuniques compartiendo memoria; comparte memoria comunicando. Suena a frase de póster motivacional, pero es literalmente el mantra que deberías tatuarte.
En lugar de que dos goroutines escriban en la misma variable, haces que una le pase el dato a la otra por un canal. Un canal es como una cinta transportadora: tú pones algo en un extremo, otro lo recoge en el otro. Nadie mete la mano en la caja del vecino.
go func main() { ch := make(chan int)
go func() {
for i := 0; i < 1000; i++ {
ch <- 1
}
close(ch)
}()
total := 0
for v := range ch {
total += v
}
fmt.Println(total) // 1000. Siempre.
}
Fíjate en la diferencia. Aquí solo una goroutine lee total. La otra solo envía por el canal. No hay carrera posible porque no hay memoria compartida en juego. Es limpio, es legible y es idiomático.
¿Y si necesitas que varias goroutines sumen a un contador? Pues o pasas los incrementos por un canal a una goroutine "dueña" del contador, o usas sync/atomic si de verdad necesitas esa variable compartida. Pero por defecto, piensa en canales.
Los tres pecados capitales que te van a explotar en producción
Vale, ya sabes la teoría. Ahora te cuento los tres patrones que veo una y otra vez en código que llega a producción y que son bombas de relojería.
Primero: el mapa compartido sin protección. Los mapas en Go no son seguros para uso concurrente. Ni leer y escribir a la vez, ni escribir desde dos goroutines. Si tienes un map[string]Algo que varias goroutines tocan, o lo proteges con un sync.RWMutex, o lo cambias por sync.Map (ojo, que no siempre es la mejor opción), o rediseñas para que solo una goroutine lo gestione.
Segundo: el cierre de canal desde el lado equivocado. Cerrar un canal desde el receptor, o cerrarlo dos veces, o enviar a un canal cerrado. Cualquiera de estas te lanza un pánico que en producción se lleva por delante el proceso entero. Regla de oro: el que envía cierra. Y solo uno.
Tercero: las goroutines zombis. Lanzas goroutines a lo loco y nunca te preocupas de que terminen. Se quedan ahí, colgadas esperando un canal que nadie va a escribir, consumiendo memoria y file descriptors hasta que el servicio se ahoga. Usa context.Context para propagar cancelaciones y no dejes goroutines huérfanas por el mundo.
Cómo lo hago yo en el mundo real
Te cuento mi receta, que no es la única pero me ha salvado más veces de las que quiero admitir.
Uno: toda goroutine que lanzo tiene una forma clara de terminar. O recibe un ctx.Done(), o consume de un canal que se cierra, o tiene un timeout. Nada de go func() { ... }() y a ver qué pasa.
Dos: los datos compartidos son la excepción, no la norma. Antes de meter un mutex, me pregunto si puedo rediseñar para que el estado viva en una sola goroutine y se comunique por canales. El 80% de las veces se puede.
Tres: el race detector corre en cada PR. Sin excepciones. Si alguien lo quita del CI, se lleva una conversación incómoda.
Cuatro: en los tests meto carga concurrente de verdad. No vale testear con una goroutine y decir "funciona". Lanza 100, 1000, las que hagan falta, y comprueba que los resultados son deterministas.
Cinco: uso pprof y el detector de fugas de goroutines cuando algo huele raro. Ver 10.000 goroutines vivas donde debería haber 50 es una señal que no puedes ignorar.
Por qué esto importa más allá del código
Podrías pensar que esto es un tema de frikis de la concurrencia. Pero no. Esto es lo que separa un servicio que aguanta Black Friday de uno que se cae cuando alguien comparte tu post en redes.
Cada vez que tu app de comida favorita procesa un pedido, cada vez que tu banco actualiza un saldo, cada vez que un videojuego online sincroniza tu partida con la del resto, hay goroutines (o equivalentes) peleándose por no pisarse. Si el código está bien hecho, no te enteras. Si está mal, te enteras tú y te enteras fuerte.
Go te da herramientas buenísimas para hacer esto bien. Canales, context, sync, el race detector, pprof... Pero también te da suficiente
Comentarios
Deja un comentario