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:

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:

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:

  1. Se ejecuta el proceso de bootstrap del runtime de Go
  2. La función main() del runtime se ejecuta primero
  3. Un poco más de proceso de bootstrap
  4. Se llama a main_main (que es la función main() de tu programa vía linkname)
  5. Tu función main() se ejecuta - la responsabilidad se delega a tu código
  6. Cuando tu main() retorna, el control vuelve a la función main() del runtime
  7. 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

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

Ideas de Extensión

Prueba estas modificaciones adicionales:

  1. Añadir un timeout: Esperar un máximo de 10 segundos a las goroutines
  2. Añadir registro: Imprimir cuando comienza la espera y qué goroutines quedan
  3. Hacerlo configurable: Usar una variable de entorno para activar/desactivar
  4. 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:

Tu Go ahora es paciente.


Continúa con el Ejercicio 8 o vuelve al taller principal