Una goroutine es una función que corre junto al resto de tu programa, y Go puede ejecutar miles a la vez. Aprende a iniciarlas, esperarlas, evitar que se fuguen y cómo el scheduler reparte unos pocos hilos entre ellas.
Una goroutine es una función que corre al mismo tiempo que el resto de tu programa. Inicias una escribiendo go delante de una llamada a función. Eso se aprende en un minuto. Saber cuándo terminan tus goroutines, y qué hace el runtime con ellas mientras tanto, lleva más tiempo.
Este post cubre cómo iniciar goroutines, cómo esperarlas, lo baratas que son y el scheduler que las ejecuta. Termina con el bug de goroutines más común, la fuga. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
go inicia una goroutine, y main no la espera
Poner go antes de una llamada a función inicia esa llamada en una goroutine nueva y sigue de inmediato, sin esperar a que la llamada termine.
package main
import (
"fmt"
"time"
)
func report() {
time.Sleep(time.Minute) // stands in for slow work
fmt.Println("report finished")
}
func main() {
go report()
fmt.Println("main done")
}
Imprime:
main done
El reporte nunca se imprime. main inició la goroutine, imprimió su propia línea y retornó, mucho antes de que pasara el minuto. Cuando main retorna, el programa termina. Go no espera a las otras goroutines, y no te avisa de que algunas seguían corriendo. Simplemente se detienen.
El sleep está ahí para que el resultado sea el mismo en cada ejecución. Quítalo y escribe go fmt.Println("hello from the goroutine") en su lugar, y el resultado pasa a ser como lanzar una moneda. Ejecutamos esa versión 200 veces en una misma computadora. Más o menos la mitad de las ejecuciones imprimió la línea de la goroutine y el resto no. Nada en el programa decide cuál. Un programa que solo funciona cuando una goroutine resulta ser rápida tiene un bug.
A veces vas a ver time.Sleep al final de main para «arreglar» esto. Esconde el problema en una computadora tranquila y falla en una ocupada. La solución es esperar a que las goroutines digan que terminaron.
Esperar bien con sync.WaitGroup
Un sync.WaitGroup es un contador de goroutines que siguen corriendo, y Wait se bloquea hasta que ese contador llega a cero.
package main
import (
"fmt"
"sync"
)
func main() {
names := []string{"ana", "bo", "chen"}
greetings := make([]string, len(names))
var wg sync.WaitGroup
for i, name := range names {
wg.Add(1)
go func() {
defer wg.Done()
greetings[i] = "hello, " + name
}()
}
wg.Wait()
for _, g := range greetings {
fmt.Println(g)
}
}
Imprime:
hello, ana
hello, bo
hello, chen
Hay tres llamadas que conocer:
wg.Add(1)suma uno al contador. Llámalo antes de la sentenciago, en la goroutine que va a esperar. Si lo llamas dentro de la goroutine nueva,Waitpodría ejecutarse primero, ver cero y retornar antes de tiempo.wg.Done()resta uno. Ponerlo detrás dedeferhace que se ejecute aunque la función retorne antes.wg.Wait()se bloquea hasta que el contador vuelve a cero.
Fíjate en que la salida sale en orden. Las tres goroutines pueden correr en cualquier orden, pero cada una escribe en su propia posición, greetings[i]. Ningún par de goroutines toca el mismo elemento, y main lee el slice solo después de que Wait retorna. La impresión pasa en un solo lugar, en un orden fijo. Ese es el hábito que mantiene predecible la salida concurrente.
Go 1.25 agregó wg.Go
Desde Go 1.25, WaitGroup tiene un método Go que hace el Add, la sentencia go y el Done por ti:
package main
import (
"fmt"
"sync"
)
func main() {
squares := make([]int, 5)
var wg sync.WaitGroup
for i := range squares {
wg.Go(func() {
squares[i] = i * i
})
}
wg.Wait()
fmt.Println(squares)
}
Imprime:
[0 1 4 9 16]
wg.Go(f) es lo mismo que wg.Add(1) seguido de una goroutine que llama a f y después a Done. No puedes olvidar el Done, y no puedes poner el Add en el lugar equivocado. La función que pasas no recibe argumentos y no devuelve nada, así que llega a los valores que necesita a través de un closure, como hace i aquí. El resto de este post usa wg.Go. Vas a seguir viendo Add y Done en la mayoría del código existente, así que vale la pena saber leer las dos formas.
Closures en goroutines y la variable del bucle
Una goroutine iniciada dentro de un bucle casi siempre usa la variable del bucle, y la parte sobre flujo de control mostró por qué eso antes era una trampa.
Mira squares[i] = i * i arriba. La goroutine no corre cuando el bucle llega a ella. Corre un poco después, quizá cuando el bucle ya terminó. Entonces, ¿qué i ve?
Desde Go 1.22, cada iteración del bucle tiene su propia i. La goroutine iniciada en la tercera vuelta ve la i de la tercera vuelta, y nada la cambia después. Por eso el programa es correcto.
Antes de Go 1.22, todo el bucle compartía una sola i. Las goroutines que corrían tarde leían el valor al que hubiera llegado el bucle, muchas veces el último. El código escrito para Go antiguo lo resuelve con i := i dentro del bucle, o pasando el valor como argumento, go func(i int) { ... }(i). En Go 1.22 y posteriores no necesitas ninguna de las dos cosas. La regla sigue la línea go de go.mod, no la versión de Go que instalaste, así que un módulo que todavía dice go 1.21 tiene el comportamiento viejo.
Las goroutines son baratas
Una goroutine cuesta mucho menos que un hilo del sistema operativo, así que iniciar miles de ellas es Go normal.
package main
import (
"fmt"
"sync"
)
func main() {
const n = 10_000
results := make(chan int, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
results <- i + 1
})
}
wg.Wait()
close(results)
total := 0
for r := range results {
total += r
}
fmt.Println("goroutines:", n)
fmt.Println("total:", total)
}
Imprime:
goroutines: 10000
total: 50005000
Diez mil goroutines envían cada una un número a un canal. El canal tiene espacio para los diez mil valores, así que ningún emisor tiene que esperar. Después de Wait, main cierra el canal y suma todo lo que hay en él. El total es 1 + 2 + … + 10.000, que da 50.005.000, sin importar en qué orden corrieron las goroutines.
¿Por qué es barato? La parte sobre memoria mostró que el stack de una goroutine nueva empieza con unos pocos kilobytes y crece solo cuando lo necesita. Un hilo del sistema operativo normalmente reserva de entrada un stack fijo mucho más grande. Además, crear una goroutine es un trabajo que hace el propio runtime de Go, sin pedírselo al sistema operativo.
Barato no es gratis. Cada goroutine sigue ocupando su stack y todo lo que referencia hasta que termina. Diez mil goroutines de vida corta están bien. Diez mil que nunca terminan son una fuga, y eso aparece al final de este post.
El canal de aquí hace un trabajo que vas a conocer bien en la parte sobre canales. Por ahora, lee results <- i + 1 como «pon este valor en la cola» y for r := range results como «saca valores hasta que se cierre».
Concurrencia no es paralelismo
Concurrencia significa que tu programa está organizado como varias tareas que avanzan de forma independiente. Paralelismo significa que varias tareas se están ejecutando en el mismo instante, en núcleos de CPU distintos. Las goroutines te dan concurrencia. Que también tengas paralelismo depende de cuántos núcleos puede usar el runtime.
Ese límite se llama GOMAXPROCS. Es la cantidad de hilos del sistema operativo que pueden ejecutar código Go en el mismo momento. runtime.NumCPU() informa cuántas CPU lógicas puede usar el proceso, y por defecto GOMAXPROCS parte de ese número. Desde Go 1.25, en Linux, el valor por defecto también respeta un límite de CPU de cgroup, el tipo de límite que ponen los contenedores. Un programa limitado a dos CPU recibe un GOMAXPROCS menor que la cantidad de núcleos de la máquina. Los dos números dependen de dónde corre el programa, así que ninguno de los programas de aquí los imprime.
Puedes fijar GOMAXPROCS tú mismo. Ponerlo en 1 elimina el paralelismo por completo, y las goroutines siguen funcionando:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(1)
var mu sync.Mutex
total := 0
var wg sync.WaitGroup
for i := range 1000 {
wg.Go(func() {
mu.Lock()
total += i
mu.Unlock()
})
}
wg.Wait()
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("total:", total)
}
Imprime:
GOMAXPROCS: 1
total: 499500
runtime.GOMAXPROCS(1) fija el límite y runtime.GOMAXPROCS(0) lo lee sin cambiarlo. Con un límite de uno, solo una goroutine ejecuta código Go en cada instante. Las mil goroutines se turnan, y la respuesta sigue siendo 0 + 1 + … + 999.
El mutex sigue siendo necesario. Turnarse no significa que cada goroutine termine su suma antes de que empiece la siguiente, y de todos modos el programa no debería depender de ese ajuste. La parte sobre sync cubre los mutex y el detector de carreras. En código real rara vez vas a fijar GOMAXPROCS. El valor por defecto suele ser el correcto.
Cómo el scheduler ejecuta goroutines
El runtime de Go ejecuta muchas goroutines sobre una cantidad pequeña de hilos del sistema operativo, y la pieza que decide qué goroutine corre dónde es el scheduler.
Explicado como si tuvieras diez años
Imagina la cocina de un restaurante con muchos cocineros y solo unas pocas estufas. Los cocineros son goroutines. Las estufas son los hilos que hacen el trabajo de verdad. Puede haber cincuenta cocineros y cuatro estufas.
Un jefe de cocina, el scheduler, decide qué cocinero se pone en qué estufa. Cada cocinero trabaja en un plato un rato, y después le toca a otro.
A veces un cocinero tiene que esperar el horno. No se queda parado frente a la estufa sin hacer nada. Se hace a un lado, y el jefe de cocina manda a otro a esa estufa. Cuando suena el horno, el cocinero que esperaba vuelve a la fila para una estufa. Puede que ni siquiera sea la estufa donde empezó.
Por eso una cocina con cuatro estufas puede mantener ocupados a cincuenta cocineros.
La versión precisa
El scheduler del runtime usa tres tipos de objeto, que normalmente se llaman G, M y P:
- G es una goroutine: su stack, y hasta dónde llegó.
- M es una máquina, es decir, un hilo del sistema operativo. Solo una M puede ejecutar código de verdad.
- P es un procesador: el permiso para ejecutar código Go, más una cola local de goroutines listas para correr. Hay exactamente
GOMAXPROCSP.
Para ejecutar código Go, una M tiene que tener una P. La M toma una G de la cola de ejecución de esa P y la ejecuta. Esto se llama scheduling M:N: muchas goroutines comparten una cantidad menor de hilos del sistema operativo, y es el runtime de Go, no el sistema operativo, el que decide qué goroutine corre en qué hilo.
Simplificado: dos P, cada una unida a un hilo, cada una con una cola de ejecución local. Cuando G1 se bloquea en un canal, queda en espera y suelta su P, así que el hilo ejecuta la siguiente goroutine en lugar de esperar. Cuando G1 vuelve a estar lista, regresa a una cola de ejecución, que puede ser la de otra P. El scheduler real también tiene una cola de ejecución global, y las P desocupadas les roban trabajo a las ocupadas.
Estos son los mismos pasos en palabras, por si la animación no se reproduce:
- P0 está unida al hilo M0 y ejecuta G1. P1 está unida al hilo M1 y ejecuta G2. G3 y G4 esperan en la cola de P0, y G5 en la de P1.
- G1 intenta recibir de un canal que no tiene nada. El scheduler pone a G1 en espera. Ya no está usando ni un hilo ni una P.
- M0 no espera a G1. P0 toma la siguiente goroutine de su cola, G3, y la ejecuta.
- G2 envía un valor por ese canal. Eso deja a G1 lista para correr, y G1 entra en la cola de P1, porque P1 es donde ocurrió el envío.
- G2 termina, y P1 ejecuta G1. G1 empezó en el hilo M0 y ahora corre en M1.
Una goroutine no está atada a un hilo. Corre donde una P la tome.
Tres cosas más de las que se encarga el scheduler:
- Llamadas al sistema bloqueantes. Algunos trabajos, como leer un archivo, bloquean el hilo completo dentro del sistema operativo. Cuando eso pasa, el runtime le quita la P a la M bloqueada y se la da a otro hilo, para que las demás goroutines sigan corriendo. La documentación del paquete
runtimelo dice claramente: los hilos bloqueados en llamadas al sistema no cuentan para el límite deGOMAXPROCS. Las lecturas de red son distintas. El runtime espera los sockets con un network poller, así que una goroutine que espera la red queda en espera como G1, sin ocupar un hilo. - Preemption. Una goroutine no puede quedarse con una P para siempre. Desde Go 1.14 el runtime puede interrumpir una goroutine en la mayoría de las plataformas, incluso en un bucle cerrado sin llamadas a funciones, así que una goroutine ocupada no puede dejar sin turno a las demás.
- Robo de trabajo. Cuando la cola de una P está vacía, la P busca en la cola de ejecución global y después toma goroutines de otras P. La animación deja esto fuera para que se pueda seguir.
Dónde falla la analogía: en una cocina hay un solo tipo de cosa frente a la estufa, el cocinero. Go tiene ahí dos ideas separadas: el hilo (M) y el permiso para ejecutar código Go (P). Un hilo atascado en una llamada al sistema es como una estufa con un cocinero que no se puede mover, y el runtime lo resuelve trayendo otra estufa, un hilo nuevo, y dándole la P. Además, un jefe de cocina piensa cada decisión. El scheduler de Go toma las mismas decisiones simples muy rápido, y no sabe qué goroutine es más importante.
Fugas de goroutines
Una fuga de goroutine es una goroutine que nunca termina, normalmente porque espera en un canal que nadie va a volver a usar.
package main
import (
"fmt"
"runtime"
)
func firstResult() int {
ch := make(chan int)
go func() {
ch <- 42 // nobody will ever receive this
}()
return -1 // gave up without reading ch
}
func main() {
fmt.Println("before:", runtime.NumGoroutine())
for range 3 {
firstResult()
}
fmt.Println("after:", runtime.NumGoroutine())
}
Imprime:
before: 1
after: 4
runtime.NumGoroutine() informa cuántas goroutines existen. Antes del bucle hay una, el propio main. Cada llamada a firstResult inicia una goroutine que intenta enviar por un canal sin buffer. Un envío por un canal sin buffer espera hasta que alguien recibe. Pero firstResult retorna sin recibir, y ningún otro código tiene ch. Así que cada goroutine espera para siempre. Tres llamadas, tres goroutines atascadas y una cuenta de 4.
Nada se cae. Go reporta un deadlock solo cuando todas las goroutines están atascadas, y aquí main sigue adelante. El recolector de basura tampoco ayuda. No libera una goroutine bloqueada, aunque nada más pueda alcanzar su canal. En un servidor que llama a firstResult una vez por petición, la cuenta sube con cada petición hasta que se acaba la memoria.
La cuenta es exacta aquí porque una goroutine se cuenta desde el momento en que go la crea, y estas tres nunca terminan. Contar goroutines también es la forma de detectar una fuga en código real. Observa runtime.NumGoroutine() a lo largo del tiempo, o mira el perfil de goroutines de net/http/pprof. Si el número sigue subiendo mientras la carga se mantiene estable, algo tiene una fuga. Una lista larga de goroutines atascadas en la misma línea te dice dónde.
Go 1.26 también trae un perfil experimental llamado goroutineleak. Solo existe en programas compilados con GOEXPERIMENT=goroutineleakprofile, y usa el recolector de basura para encontrar goroutines bloqueadas en algo que ninguna goroutine en ejecución puede alcanzar. Lo probamos con este programa, y es un buen recordatorio de lo que significa «concurrente»: en una ejecución reportó dos goroutines fugadas, no tres. Lo más probable es que la tercera ya se hubiera creado pero todavía no hubiera llegado a su envío cuando se tomó el perfil. Un detector de fugas solo puede ver goroutines que ya están atascadas.
Para este bug hay dos soluciones habituales:
- Darle al canal espacio para ese único valor,
make(chan int, 1). El envío se completa de inmediato, y la goroutine termina aunque nadie lea. - Darle a la goroutine una forma de enterarse de que tiene que parar. Para eso están
selectycontext, en las partes sobre canales y sobrecontext.
La regla que te llevas: antes de iniciar una goroutine, sabe cómo va a terminar.
Lo que sigue
Este post usó canales y un mutex sin explicarlos. La parte sobre canales y select cubre cómo las goroutines se pasan valores entre sí, incluidos los canales sin buffer, el cierre y select. La parte sobre sync cubre los mutex, el detector de carreras y el paquete atomic, que necesitas en cuanto las goroutines comparten memoria.
Qué recordar
go f()iniciafen una goroutine nueva y no espera. Cuandomainretorna, el programa termina, y las goroutines que siguen corriendo se detienen.- Espera con un
sync.WaitGroup. Desde Go 1.25,wg.Go(func() { ... })hace elAddy elDonepor ti. - Las goroutines son baratas, así que miles son normales. Junta sus resultados a través de un canal o de un slice protegido, e imprime desde un solo lugar.
- La concurrencia es cómo está estructurado el programa. El paralelismo es ejecutar en el mismo instante, y
GOMAXPROCSlo limita. - El scheduler ejecuta goroutines (G) sobre hilos (M) a través de procesadores (P). Una goroutine que se bloquea queda en espera, y su hilo pasa a otro trabajo.
- Desde Go 1.22, cada iteración del bucle tiene su propia variable, así que una goroutine iniciada en un bucle ve el valor de su propia iteración.
- Una goroutine bloqueada para siempre en un canal es una fuga. Sabe cómo va a terminar cada goroutine que inicias.
Iniciar una goroutine es una palabra. Saber cuándo termina es tu trabajo.