Lección 7 de 8. Esta es la razón por la que mucha gente llega a Go. Lanzar una tarea en paralelo cuesta una palabra, y coordinarlas no requiere cerrojos la mayoría de las veces. También es donde más fácil es escribir algo que funciona casi siempre, que es la peor clase de error; por eso la lección acaba con la herramienta que los caza.
Una goroutine cuesta una palabra
go fmt.Println("desde la goroutine")
fmt.Println("desde main")
salida, tres ejecuciones seguidasdesde main desde main desde main
La goroutine no llegó a imprimir ni una sola vez. No es un fallo: cuando main termina, el programa termina y se lleva por delante todo lo que estuviera corriendo. Nadie espera a nadie por defecto.
Una goroutine no es un hilo del sistema. Arranca con unos pocos kilobytes y el runtime de Go reparte miles de ellas sobre unos pocos hilos reales. Lanzar diez mil es normal; lanzar diez mil hilos del sistema, no.
Esperar: WaitGroup
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go trabajar(i, &wg, res)
}
wg.Wait() // aquí se para hasta que las tres llamen a Done()
func trabajar(id int, wg *sync.WaitGroup, res chan<- string) {
defer wg.Done() // lo primero: garantizar que se descuenta
…
}
El defer wg.Done() en la primera línea es la costumbre establecida: así se descuenta aunque la función salga por un error. Si se olvida, Wait() no vuelve nunca.
Y fíjate en el tipo del parámetro: chan<- string es un canal solo de envío. Declararlo así deja escrito en la firma que esa función no va a leer del canal, y el compilador lo hace cumplir.
Canales: la forma de hablar entre goroutines
c := make(chan string)
go func() { c <- "hola desde la goroutine" }()
fmt.Println(<-c)
salidahola desde la goroutine
Esas tres líneas hacen dos cosas a la vez: pasan un dato y sincronizan. El fmt.Println no se ejecuta hasta que la goroutine envía, y la goroutine no continúa hasta que alguien recibe. Sin cerrojos, sin señales, sin nada más.
Con búfer, el que envía no espera mientras quede sitio:
res := make(chan string, 3) // caben tres sin que nadie lea
Y para recorrer todo lo que llegue hasta que se cierre:
close(res)
for m := range res {
fmt.Println(m)
}
salidatarea 1 lista tarea 2 lista tarea 3 lista
panic; recibir de uno cerrado devuelve el valor cero inmediatamente, que es lo que hace que el range termine.select: lo primero que llegue
select {
case v := <-rapido:
fmt.Println("ganó el", v)
case v := <-lento:
fmt.Println("ganó el", v)
case <-time.After(200 * time.Millisecond):
fmt.Println("se agotó el tiempo")
}
salidaganó el rápido
select espera en varios canales a la vez y atiende al primero que esté listo. Ese tercer caso con time.After es el patrón estándar para poner un tiempo límite: si en 200 milisegundos no ha llegado nada, se sale por ahí.
Lo que de verdad hay que temer: la carrera
Mil goroutines sumando uno a la misma variable. Debería dar 1000:
contador := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); contador++ }()
}
wg.Wait()
fmt.Println("contador =", contador, "(debería ser 1000)")
cuatro ejecuciones seguidascontador = 977 (debería ser 1000) contador = 986 (debería ser 1000) contador = 985 (debería ser 1000) contador = 974 (debería ser 1000)
Nunca da bien y nunca da igual. contador++ no es una operación única: lee, suma y escribe, y entre esos tres pasos cabe otra goroutine. Lo peor es que un programa así puede pasar meses funcionando y fallar el día que hay carga.
El detector de carreras
Go trae uno dentro. Se activa con una bandera:
go run -race .
salida==================
WARNING: DATA RACE
Read at 0x00c0000140a8 by goroutine 11:
main.main.func1()
/home/ana/w/l7c/main.go:13 +0x7b
Previous write at 0x00c0000140a8 by goroutine 10:
main.main.func1()
/home/ana/w/l7c/main.go:13 +0x8d
Te dice la línea exacta, quién leyó y quién escribió. Acostúmbrate a ejecutar las pruebas con -race: go test -race ./.... Es la herramienta más valiosa de esta lección.
Arreglarlo
var mu sync.Mutex
go func() {
defer wg.Done()
mu.Lock()
contador++
mu.Unlock()
}()
salida, tres ejecucionescontador = 1000 contador = 1000 contador = 1000
Y con -race ya no protesta. La otra solución, más de Go, sería que una sola goroutine llevara la cuenta y las demás le mandaran los incrementos por un canal. De ahí el lema de la comunidad: no te comuniques compartiendo memoria; comparte memoria comunicándote.
Lo que hay que llevarse
| Para | Usa |
|---|---|
| Lanzar algo en paralelo | go f() |
| Esperar a que terminen varias | sync.WaitGroup, con defer wg.Done() |
| Pasar datos y sincronizar a la vez | Un canal |
| Atender a varios canales | select |
| Poner un tiempo límite | case <-time.After(…) |
| Proteger una variable compartida | sync.Mutex |
| Saber si lo has hecho mal | -race, siempre |
Todo el código de esta lección se ejecutó con Go 1.25 antes de publicarla. Las cuatro ejecuciones de la carrera son cuatro ejecuciones reales y seguidas del mismo programa, y el aviso del detector es el que imprimió, con sus direcciones de memoria y todo.