Concurrencia en Go

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ómo sincroniza un canal sin búfer en GoUn canal sin búfer no es un buzón: es una citala goroutinec <- "hola"espera aquí hastaque alguien recibamain<-cespera aquí hastaque alguien envíecanalel traspaso ocurrecuando ambos lleganEsa espera mutua es lo que sincroniza: no hace falta ningún cerrojo para coordinar dos goroutinesCon búfer —make(chan string, 3)— el que envía no espera mientras quede huecoSi nadie recibe nunca, el programa se queda parado: eso es un deadlock
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
Quién cierra un canal. Lo cierra el que envía, nunca el que recibe, y solo cuando no va a mandar nada más. Enviar a un canal cerrado provoca un 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.