Lanzar una goroutine
Pon go delante de una llamada a función y esa llamada se ejecuta de forma concurrente. La sentencia retorna de inmediato; quien llama no espera.
Cuatro goroutines ejecutan shout a la vez. El orden en que terminan no está definido, pero la salida sale siempre en el orden original, porque cada goroutine escribe en su propio índice y main solo lee el slice cuando wg.Wait() ha retornado.
Ese ejemplo ya contiene las tres cosas que necesita casi cualquier programa con goroutines: una forma de lanzar trabajo (go), una forma de esperarlo (sync.WaitGroup) y una forma de recuperar resultados sin que dos goroutines toquen la misma memoria (un elemento del slice para cada una).
main no espera
Cuando main retorna, el programa termina. Las goroutines que siguen en marcha se detienen allí donde estén. Nada las espera.
Normalmente esto imprime solo from main. A veces la goroutine se planifica a tiempo y ves las dos líneas. Ese "normalmente" es el problema: código que funciona en tu máquina y falla en un servidor cargado.
Un time.Sleep al final de main hace que la demo imprima las dos líneas, y es el arreglo equivocado. Adivina cuánto tarda el trabajo. Espera al trabajo en sí, con un WaitGroup (la página de WaitGroup lo explica en detalle) o con un channel.
Recuperar resultados
Una sentencia go tira los valores de retorno de la función. x := go f() no compila. Hay dos formas estándar de devolver datos.
Una posición por goroutine, como en el primer ejemplo. Da tamaño previo a un slice, pasa a cada goroutine su índice y lee después de Wait. El orden se conserva y no hace falta ningún lock, porque ninguna pareja de goroutines escribe el mismo elemento.
Un channel. Cada goroutine envía su resultado; el receptor los recoge. Los resultados llegan en el orden en que terminan, no en el que empezaron.
Recibir exactamente len(nums) valores sirve además como espera: main no puede pasar del bucle hasta que todas las goroutines hayan enviado. El orden de llegada cambia entre ejecuciones, así que el programa ordena antes de imprimir nada que dependa del orden. La página de channels cubre los channels con buffer, cerrarlos y range sobre un channel.
Variables de bucle y closures (cambio de Go 1.22)
Desde Go 1.22, cada iteración de un bucle for recibe una copia nueva de las variables de bucle. Una closure lanzada en una goroutine captura el valor de esa iteración, así que esto es correcto:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
Antes de Go 1.22, todas las iteraciones compartían un único i y un único w, y cada goroutine solía ver el último valor. El código antiguo lo esquiva pasando los valores como argumentos, go func(i int, w string) { ... }(i, w), o sombreándolos, i := i. Las dos cosas son inofensivas en Go 1.22 y posteriores, y todavía las verás en código existente. El nuevo comportamiento se aplica cuando el go.mod del módulo indica go 1.22 o superior.
Las goroutines son baratas
Una goroutine empieza con una pila pequeña (unos pocos kilobytes) que el runtime hace crecer y encoger según haga falta. El planificador de Go ejecuta las goroutines sobre un conjunto de hilos del sistema, con un máximo de GOMAXPROCS ejecutando código Go a la vez, y por defecto GOMAXPROCS es igual al número de CPUs. Bloquearse en un channel, un mutex, un sleep o I/O de red aparca la goroutine y libera el hilo para otra.
Así que lanzar una goroutine por tarea está bien incluso con números grandes:
Cien mil goroutines terminan en una fracción de segundo. La suma es siempre 4999950000, porque atomic.Int64 hace que cada suma sea indivisible. Barato no quiere decir gratis: cada goroutine que sigue bloqueada mantiene viva su pila y todo lo que referencia.
| Hilo del sistema | Goroutine | |
|---|---|---|
| Lo crea | el kernel | el runtime de Go |
| Pila inicial | fija, a menudo 1 MB o más | unos pocos KB, crece bajo demanda |
| Cambio de contexto | cambio de contexto del kernel | planificador de Go, en espacio de usuario |
| Identidad | tiene un ID de hilo | ningún ID legible, por diseño |
| Cantidad típica | cientos | de miles a millones |
Carreras de datos
Dos goroutines que acceden a la misma variable a la vez, con al menos una de ellas escribiendo, forman una carrera de datos. El resultado es impredecible, no simplemente "un poco desviado": se pierden actualizaciones, y una carrera sobre un string, un slice, un map o un valor de interfaz puede hacer caer el programa o corromper la memoria.
En una máquina con varios núcleos, esto imprime un número distinto por debajo de 10000 en la mayoría de ejecuciones, porque dos goroutines leen el mismo valor antiguo y ambas escriben ese valor más uno. En un solo núcleo puede imprimir 10000, que es peor: el bug pasa tu test y aparece en producción.
Go incluye un detector de carreras. Ejecuta tu programa o tus tests con -race:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
Señala la línea exacta (counter++) y las dos goroutines. Solo informa de las carreras que ocurren de verdad durante la ejecución, así que úsalo con tests que recorran las rutas concurrentes. Ralentiza el programa varias veces, así que es para tests y preproducción, no para producción.
Los arreglos, del más simple al más general:
- No compartas. Da a cada goroutine sus propios datos y combínalos al final (el patrón de una posición por goroutine).
- Usa
sync/atomicpara un solo contador o flag:var n atomic.Int64; n.Add(1). - Usa un
sync.Mutexalrededor de cualquier cosa más grande, como un map o un struct con varios campos. La página de mutex cubre tambiénRWMutexysync.Once. - Envía los datos por un channel para que solo una goroutine sea su dueña en cada momento.
Un panic en una goroutine mata el programa
Si una goroutine provoca un panic y nada lo recupera dentro de esa misma goroutine, todo el programa cae, incluidos main y todas las demás goroutines. Un recover en main no sirve, porque recover solo captura los panics de su propia goroutine.
Recuperar así tiene sentido en el borde de un servidor de larga duración, donde una petición defectuosa no debe tumbar al resto. Dentro del código normal, un panic suele significar un bug, y caer de forma ruidosa es el resultado correcto.
Fugas de goroutines
Una goroutine que se bloquea para siempre nunca termina y nunca libera su memoria. La causa clásica es un envío que nadie va a recibir:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
Cada llamada fuga len(urls) - 1 goroutines. En un servidor que atiende esta petición miles de veces, la memoria sube hasta que el proceso muere. Dos arreglos: hacer el channel lo bastante grande para que todos los emisores puedan terminar (make(chan string, len(urls))), o dar a las goroutines una forma de rendirse, normalmente un context.Context más un select sobre ctx.Done(). Puedes vigilar las fugas con runtime.NumGoroutine() en los tests.
Limitar cuántas se ejecutan a la vez
"Una goroutine por elemento" está bien para 10.000 cálculos baratos. No está bien para 10.000 peticiones HTTP al mismo servidor ni para 10.000 archivos abiertos. Limita la concurrencia con un channel con buffer usado como semáforo:
El channel con buffer admite como mucho 3 fichas, así que como mucho 3 goroutines han pasado la línea sem <- en cada momento. El pico nunca puede superar 3, y con doce tareas que duermen llega a 3 en la práctica. Un conjunto fijo de goroutines worker que leen de un channel de trabajos es la otra forma habitual; la página de WaitGroup construye uno.
Fuera de la librería estándar, golang.org/x/sync/errgroup combina en un solo tipo un WaitGroup, el primer error, la cancelación por context y un límite de concurrencia (g.SetLimit(n)). Es la elección habitual en código de producción que necesita las cuatro cosas.
Errores comunes
- Olvidar esperar.
mainretorna y el trabajo nunca llega a ocurrir, sin avisar. Cada sentenciagonecesita una forma correspondiente de saber que terminó. - Llamar a
wg.Adddentro de la goroutine.Waitpuede ejecutarse antes queAdd, ver el contador a cero y retornar antes de tiempo. Llama aAddantes de la sentenciago. - Compartir una variable sin sincronización. Los maps son el caso típico: las escrituras concurrentes en un map suelen ser detectadas por el runtime y hacen caer el programa con
fatal error: concurrent map writes, querecoverno puede capturar. - Suponer un orden. Las goroutines se ejecutan en el orden que elija el planificador. Si la salida tiene que estar ordenada, recoge y ordena, o escribe en posiciones indexadas.
- Usar
time.Sleeppara sincronizar. Hace los tests lentos y siguen fallando a ratos. Espera al evento, no a una suposición. - Lanzar una goroutine sin forma de detenerla. Cualquier cosa que haga un bucle o espere I/O debería recibir un
context.Contextpara que quien la llama pueda cancelarla.
Preguntas frecuentes
¿Qué es una goroutine en Go?
Una goroutine es una llamada a función que se ejecuta de forma concurrente con el resto del programa. Se lanza poniendo go delante de una llamada: go work(). Las goroutines las gestiona el runtime de Go, no el sistema operativo, y el runtime reparte muchas de ellas entre un número pequeño de hilos del sistema, así que lanzar miles es lo normal.
¿Cómo espero a que terminen las goroutines en Go?
Usa un sync.WaitGroup: llama a wg.Add(1) antes de cada sentencia go, pon defer wg.Done() al principio de la goroutine y wg.Wait() donde necesites que hayan terminado todas. Si las goroutines producen valores, recibir un valor por goroutine desde un channel también sirve como espera.
¿Qué diferencia hay entre una goroutine y un hilo?
Un hilo del sistema operativo tiene una pila fija (a menudo de 1 MB o más) y lo planifica el kernel. Una goroutine empieza con una pila de unos pocos kilobytes que crece según haga falta, y el planificador de Go alterna entre goroutines en espacio de usuario. El runtime ejecuta goroutines en hasta GOMAXPROCS hilos a la vez (por defecto, el número de CPUs).
¿Cómo obtengo un valor de retorno de una goroutine?
Una sentencia go descarta los valores de retorno de la función. Envía el resultado por un channel (results <- compute(x)) o escríbelo en tu propia posición de un slice con tamaño previo (out[i] = compute(x)) y léelo después de wg.Wait().
¿Por qué mi programa Go termina antes de que la goroutine imprima nada?
Cuando main retorna, el programa termina y todas las demás goroutines se detienen sin ejecutar el código que les queda. Nada espera a las goroutines automáticamente. Bloquea main hasta que termine el trabajo con un WaitGroup o una recepción de un channel. Añadir time.Sleep solo esconde el problema.