Ejercicio 7: Go Paciente - Haciendo que Go Espere a las Goroutines
📖 ¿Quieres aprender más? Lee The Bootstrap y The Scheduler en Internals for Interns para profundizar en el arranque del runtime de Go y la planificación de goroutines.
En este ejercicio, modificarás el runtime de Go para que espere a que todas las goroutines terminen antes de que el programa finalice. Actualmente, cuando main() retorna, Go termina inmediatamente incluso si hay goroutines todavía en ejecución. Haremos que Go sea “paciente” esperando a que todas las goroutines terminen.
Objetivos de Aprendizaje
Al finalizar este ejercicio, serás capaz de:
- Entender el proceso de terminación de programas en Go
- Saber cómo contar las goroutines activas
- Modificar la función principal del runtime para cambiar el comportamiento del programa
- Entender los compromisos de la espera automática de goroutines
Introducción: ¿Cómo Arranca un Programa Go?
Un binario de Go no empieza en tu función main(). El sistema operativo primero ejecuta código ensamblador específico de la arquitectura (_rt0_amd64_linux o similar), que prepara la infraestructura fundamental del runtime. Este proceso de bootstrap inicializa tres abstracciones centrales que el runtime de Go usa para gestionar la ejecución:
- G (Goroutine): Una unidad de ejecución ligera con su propia pila (empezando en 2KB). Cada sentencia
gocrea un nuevo G. - M (Machine): Un hilo del sistema operativo que realmente ejecuta las goroutines. Cada M tiene una goroutine especial
g0usada para tareas de gestión del runtime. - P (Processor): Un contexto de planificación lógico que conecta goroutines con hilos. Cada P tiene su propia cola de goroutines ejecutables y una caché de memoria. El número de Ps se controla con
GOMAXPROCS.
La secuencia de bootstrap es: setup en ensamblador → inicialización del scheduler (pools de pilas, asignador de memoria, recolector de basura) → runtime.main() → iniciar hilo del monitor del sistema → activar GC → ejecutar las funciones init() de los paquetes → finalmente llamar a tu main.main().
Cuando tu main() retorna, el control vuelve a runtime.main(), que procede con el proceso de desmontaje. Este es el punto exacto que vamos a modificar.
Contexto: Comportamiento Actual de Terminación de Go
Actualmente, cuando escribes:
package main
import "time"
func main() {
go func() {
time.Sleep(2 * time.Second)
println("Goroutine finished!")
}()
println("Main finished!")
// Program exits immediately, goroutine never completes
}
Salida:
Main finished!
La goroutine nunca llega a imprimir porque el programa finaliza cuando main() retorna.
Cambiaremos esto para que Go espere pacientemente a que todas las goroutines terminen:
Nueva salida:
Main finished!
Goroutine finished!
Paso 1: Entender la Función Main del Runtime
La función main() del runtime de Go en runtime/proc.go es la responsable de ejecutar la función main() de tu programa. Examinemos cómo funciona:
cd go/src/runtime
Abre proc.go y busca la función main(). Cerca del principio (alrededor de las líneas 136-137), verás cómo el runtime se enlaza con el main de tu programa:
//go:linkname main_main main.main
func main_main()
Esta directiva //go:linkname le dice al linker que conecte la función main_main del runtime con la función main.main de tu programa. Así es como el runtime puede llamar a código de tu paquete main.
Más abajo en la misma función main() (alrededor de la línea 289), verás dónde se llama:
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
... // tear-down process continues
Cómo funciona:
- Se ejecuta el proceso de bootstrap del runtime de Go
- La función
main()del runtime se ejecuta primero - Un poco más de proceso de bootstrap
- Se llama a
main_main(que es la funciónmain()de tu programa vía linkname) - Tu función
main()se ejecuta - la responsabilidad se delega a tu código - Cuando tu
main()retorna, el control vuelve a la funciónmain()del runtime - El runtime continúa con el proceso de desmontaje del programa (limpieza y salida)
Actualmente, el desmontaje comienza inmediatamente después de que tu main() retorna, sin esperar a otras goroutines.
Paso 2: Añadir la Lógica de Espera de Goroutines
Añadiremos código para esperar hasta que solo quede 1 goroutine (la propia goroutine principal).
Edita runtime/proc.go:
Busca la sección alrededor de las líneas 289-290 donde se llama a main_main:
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
Añade la lógica de espera justo después de la llamada a fn():
fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn()
// Wait until only 1 goroutine is running (the main goroutine)
for gcount(false) > 1 {
Gosched()
}
Entendiendo el Código
gcount(false)- Función del runtime que devuelve el número de goroutines activas (el argumentofalseexcluye las goroutines del sistema del conteo)gcount(false) > 1- Mientras haya más goroutines aparte de la principal ejecutándoseGosched()- Cede el procesador, permitiendo que otras goroutines se ejecuten- El bucle termina - Cuando solo queda la goroutine principal (conteo = 1)
Paso 3: Recompilar la Cadena de Herramientas de Go
cd go/src
./make.bash
Esto recompila el runtime con tu lógica de espera paciente de goroutines.
Paso 4: Probar la Espera Básica de Goroutines
Crea un archivo de prueba para verificar el comportamiento:
Crea patient_demo.go:
package main
import "time"
func main() {
println("Main starting...")
go func() {
time.Sleep(1 * time.Second)
println("Goroutine 1 finished!")
}()
go func() {
time.Sleep(2 * time.Second)
println("Goroutine 2 finished!")
}()
println("Main finished, but Go will wait...")
}
Ejecuta con tu Go modificado:
./bin/go run patient_demo.go
Salida esperada:
Main starting...
Main finished, but Go will wait...
Goroutine 1 finished!
Goroutine 2 finished!
¡Go ahora espera a que todas las goroutines terminen!
Lo que Aprendimos
- Terminación de Programas: Cómo los programas Go finalizan y hacen limpieza
- Seguimiento de Goroutines: La función
gcount()rastrea las goroutines activas - Planificación Cooperativa:
Gosched()cede para permitir que otras goroutines se ejecuten - Modificación del Runtime: Cómo un pequeño cambio afecta a todos los programas Go
- Compromisos de Diseño: Beneficios e inconvenientes de la espera automática
Ideas de Extensión
Prueba estas modificaciones adicionales:
- Añadir un timeout: Esperar un máximo de 10 segundos a las goroutines
- Añadir registro: Imprimir cuando comienza la espera y qué goroutines quedan
- Hacerlo configurable: Usar una variable de entorno para activar/desactivar
- Añadir una advertencia: Detectar bucles infinitos en goroutines
Limpieza
Para restaurar el comportamiento estándar de Go:
cd go/src/runtime
git checkout proc.go
cd ..
./make.bash
Resumen
¡Has modificado con éxito el runtime de Go para que sea “paciente” y espere a todas las goroutines!
Antes: main() retorna → salida inmediata → goroutines abandonadas
Después: main() retorna → espera a las goroutines → todas terminan → salida
Cambios: función main() en runtime/proc.go
Resultado: ¡Ninguna goroutine se queda atrás!
Esta modificación demuestra:
- Comprensión profunda del runtime de Go
- Cómo funciona la terminación de programas
- La relación entre main() y las goroutines
- Compromisos reales en el diseño de lenguajes
Tu Go ahora es paciente.
Continúa con el Ejercicio 8 o vuelve al taller principal