Entendiendo el runtime de Go: El Bootstrap

El Bootstrap

Cuando escribes Go, pasan muchas cosas entre bastidores. Las goroutines son ligeras, los channels simplemente funcionan, la memoria se gestiona sola y nunca piensas en pools de threads. Todo eso lo hace posible el runtime de Go—una sofisticada infraestructura que se compila dentro de cada binario de Go.

Este es el primer artículo de una serie en la que vamos a explorar el runtime de Go desde dentro. Veremos cómo el scheduler multiplexa goroutines sobre threads del sistema operativo, cómo el memory allocator consigue asignaciones rápidas sin locks, cómo el garbage collector funciona de forma concurrente reduciendo las pausas de stop-the-world al mínimo, y cómo el system monitor mantiene todo funcionando correctamente. Cada uno de ellos tendrá su propio artículo en profundidad.

Pero antes de que toda esa maquinaria pueda hacer su trabajo, tiene que ser configurada. Eso es el bootstrap—el proceso que ocurre entre que el sistema operativo arranca tu binario y tu func main() toma el control. Y eso es lo que vamos a explorar hoy.

Empecemos con una pregunta: ¿cómo de rápido es Go sin hacer nada?

Aquí tienes un programa en C que no hace absolutamente nada:

int main() {
    return 0;
}

Y aquí el equivalente en Go:

package main

func main() {
}

Compilemos ambos y comparemos:

$ gcc -o nothing_c nothing.c
$ go build -o nothing_go nothing.go

$ ls -lh nothing_c nothing_go
-rwxrwxr-x  1 user  user   16K Feb  7 12:05 nothing_c
-rwxrwxr-x  1 user  user  1.5M Feb  7 12:05 nothing_go

$ time ./nothing_c
real    0m0.001s

$ time ./nothing_go
real    0m0.002s

El binario de Go es casi 100 veces más grande y tarda aproximadamente el doble en ejecutarse. Y no estamos haciendo nada. ¿Qué está pasando?

La respuesta es que Go está haciendo muchas cosas antes de que tu función main se ejecute. Esos 1.5MB de binario extra contienen todo el runtime: un memory allocator, un garbage collector, un scheduler, un system monitor, y toda la maquinaria necesaria para soportar goroutines, channels y maps. Antes de darte el control, Go tiene que configurar todo eso.

Vamos a recorrer todo el proceso de bootstrap—todo lo que ocurre entre que el sistema operativo arranca tu binario y tu func main() finalmente se ejecuta.

Aquí tienes la visión general—cada paso que da el runtime antes de que tu código se ejecute:

Visión general del bootstrap de Go

Iremos paso a paso por cada una de estas etapas. Ten este mapa en mente mientras avanzamos—ayuda saber en qué punto del proceso estamos.

Así que empecemos por el principio—¿qué se ejecuta realmente cuando lanzas un binario de Go?

El punto de entrada: no es tu main()

La primera sorpresa: tu función main no es el punto de entrada. Podemos demostrarlo. Usemos readelf para encontrar el punto de entrada real de nuestro binario que no hace nada:

$ readelf -h nothing_go | grep "Entry point"
  Entry point address:               0x467280

Eso es una dirección de memoria sin más. ¿Qué función vive ahí? go tool nm mapea direcciones a nombres de símbolos:

$ go tool nm nothing_go | grep 467280
  467280 T _rt0_amd64_linux

Ahí está—_rt0_amd64_linux, no main.main. El punto de entrada real es una función en ensamblador dentro del runtime (en src/runtime/rt0_linux_amd64.s ). Hay puntos de entrada equivalentes para cada arquitectura que Go soporta: _rt0_arm64_linux, _rt0_386_linux, y así sucesivamente. Lo único que hacen es coger los argumentos de línea de comandos de la pila y saltar a rt0_go (en src/runtime/asm_amd64.s ), que es donde realmente empieza el bootstrap. Es una función grande en ensamblador que prepara todo antes de que pueda ejecutarse código Go. Esto es lo que hace, más o menos en orden:

Primero, crea dos cosas que Go necesita desde el primer momento: g0 y m0. Piénsalo así—Go ejecuta tu código en goroutines, y las goroutines se ejecutan sobre threads del sistema operativo. Así que antes de que pueda pasar cualquier otra cosa, el runtime necesita al menos una de cada. Eso son g0 y m0: la primera goroutine y el primer thread. Aunque g0 es un poco especial—no ejecutará tu código. Está reservada para las tareas internas del runtime, como planificar otras goroutines.

Después configura el Thread-Local Storage (TLS). El TLS es un mecanismo del sistema operativo que da a cada thread su propia zona de almacenamiento privada—diferentes threads pueden leer el mismo slot de TLS y obtener valores distintos. Go lo usa para guardar un puntero a la goroutine que se está ejecutando en cada thread, de forma que el runtime siempre pueda responder a la pregunta “¿qué goroutine soy ahora mismo?” de forma rápida y sin locks. Esto es tan crítico que el runtime lo comprueba inmediatamente escribiendo un valor mágico y leyéndolo de vuelta—si el TLS no funciona, el programa aborta en el acto.

También comprueba qué tipo de CPU tiene debajo—qué fabricante es y qué características soporta. Los binarios de Go pueden compilarse para aprovechar instrucciones más nuevas de la CPU y obtener mejor rendimiento, así que el runtime verifica que la CPU realmente tiene esas características. Si no las tiene, el binario imprime un error y sale, en lugar de fallar con una instrucción ilegal más adelante.

Si el binario fue compilado con soporte de CGO, hay un paso extra para inicializar el runtime de C antes de continuar.

Con todo el trabajo a nivel de ensamblador hecho—TLS funcionando, características de la CPU conocidas, g0 y m0 enlazados—rt0_go pasa a código Go con cuatro llamadas a funciones: check() verifica que las suposiciones del compilador son correctas, args() guarda los argumentos de línea de comandos, osinit() detecta el número de CPUs (que se convierte en el valor por defecto de GOMAXPROCS), y finalmente schedinit()—donde ocurre el trabajo de verdad.

Inicialización del Scheduler (schedinit)

Ahora viene lo gordo. schedinit() (en src/runtime/proc.go ) es la función principal de inicialización que configura todos los subsistemas críticos del runtime. Veamos qué hace, paso a paso.

Stop the World

Lo primero que hace schedinit() es marcar el mundo como parado. “Stop the world” es un término que oirás mucho en las discusiones sobre el runtime de Go—significa pausar todas las goroutines para que el runtime pueda hacer trabajo de forma segura sin que haya nada más ejecutándose al mismo tiempo. En este caso, todavía no existe ninguna goroutine, así que el mundo ya está parado por definición. Pero el runtime lo marca explícitamente, porque varios subsistemas se comportan de forma diferente según si puede haber goroutines ejecutándose concurrentemente o no.

Piensa en ello como montar un restaurante antes de abrir: colocas las mesas, preparas la cocina, abasteces los ingredientes—todo antes de que entre el primer cliente. Entonces, ¿qué hay que preparar?

Inicialización del Stack Pool

Bueno, las goroutines necesitan stacks para ejecutarse. Las goroutines de Go empiezan con stacks diminutos de 2KB que crecen dinámicamente, y el runtime mantiene pools de segmentos de stack preasignados organizados por tamaño para que crear una nueva goroutine sea rápido. stackinit() configura estos pools.

Cuando una goroutine termina y su stack se libera, vuelve al pool para ser reutilizado en lugar de devolverse al sistema operativo. Esto es crítico para el rendimiento—la creación de goroutines tiene que ser barata, y pedir memoria al sistema operativo en cada sentencia go sería demasiado lento.

Pero los stacks son solo un tipo de memoria que las goroutines usan. También necesitan memoria del heap—para cualquier cosa que escape del stack, como slices, maps o valores devueltos por puntero.

Inicialización del Memory Allocator

De eso se encarga mallocinit(). Configura el memory allocator de Go, y la idea central es bastante intuitiva: en lugar de pedir memoria al sistema operativo cada vez que tu código hace un make([]byte, 100), Go reserva grandes bloques de memoria por adelantado y luego reparte trozos pequeños de esos bloques. Mucho más rápido.

El allocator organiza la memoria por clases de tamaño—hay 68, desde 8 bytes hasta 32KB. Cuando asignas un objeto de 50 bytes, Go no te da exactamente 50 bytes. Redondea hacia arriba a la clase de tamaño más cercana (64 bytes en este caso) y te da un slot de un bloque predividido de slots de 64 bytes. Esto mantiene las cosas simples y evita la fragmentación. Para cualquier cosa mayor de 32KB, el allocator se salta las clases de tamaño por completo y asigna directamente del heap.

La parte realmente ingeniosa viene después, cuando se crean los Ps. Cada P obtiene su propia caché local de memoria, de forma que la mayoría de las asignaciones no necesitan ningún lock—una goroutine simplemente coge memoria de la caché de su propio P. Solo cuando esa caché se vacía necesita ir a las listas centrales compartidas para rellenarla. Esta es una de las grandes razones por las que Go puede asignar memoria tan rápido incluso con muchas goroutines ejecutándose concurrentemente.

Con las dos piezas más grandes en su sitio—stacks y memoria del heap—el runtime pasa a unos cuantos detalles más pequeños pero importantes.

Inicialización de CPU Flags y Hash

cpuinit() hace una comprobación más detallada de las capacidades de la CPU más allá de lo que el código en ensamblador ya detectó—averiguando exactamente qué extensiones del conjunto de instrucciones están disponibles.

Después, alginit() elige la función hash que usarán los maps de Go. Si la CPU soporta instrucciones AES por hardware, Go las usa para el hashing—es significativamente más rápido. Si no, recurre a una implementación por software. Esta elección afecta a cada operación con maps en tu programa, así que merece la pena decidirla pronto.

Ahora que el runtime sabe lo que el hardware puede hacer, es momento de montar la infraestructura de software que se apoya en él.

Módulos, Tipos y el Thread Principal

Aquí es donde el runtime construye las tablas internas que hacen funcionar el sistema de tipos de Go. modulesinit() construye las tablas de todos los paquetes compilados—cada uno conteniendo información de tipos, metadatos de funciones y bitmaps del GC. typelinksinit() e itabsinit() configuran las tablas de dispatch de interfaces que hacen que las interfaces de Go funcionen. Y mcommoninit() termina de configurar m0 (nuestro thread principal) y lo registra en la lista global de todos los threads.

Con la fontanería interna en su sitio, el runtime puede por fin empezar a mirar hacia fuera—a las entradas que tu programa recibió del mundo exterior.

Argumentos, Entorno y Seguridad

goargs() convierte el argv en estilo C en el slice de strings de Go que se convertirá en os.Args. goenvs() hace lo mismo con las variables de entorno. Después, secure() realiza comprobaciones de seguridad, y checkfds() se asegura de que stdin, stdout y stderr estén realmente abiertos—previniendo un tipo de problemas de seguridad que pueden surgir con descriptores de fichero estándar cerrados.

Una de esas variables de entorno merece atención especial.

Entorno de Debug

La variable GODEBUG controla todo tipo de comportamientos del runtime. Algunas útiles:

  • GODEBUG=inittrace=1 — imprime el tiempo de cada función init de cada paquete
  • GODEBUG=schedtrace=1000 — imprime el estado del scheduler cada segundo
  • GODEBUG=gctrace=1 — imprime eventos del GC

Se parsean y aplican aquí para que tengan efecto durante el resto del bootstrap.

A estas alturas, todas las piezas de soporte están en su sitio. El runtime ahora se centra en los dos grandes subsistemas que quedan.

Inicialización del Garbage Collector

gcinit() prepara el garbage collector de Go—el sistema que libera automáticamente la memoria que tu programa ya no necesita. Go usa un GC de tipo concurrent mark-and-sweep, lo que significa que hace la mayor parte de su trabajo mientras tu programa sigue ejecutándose, en lugar de pararlo todo.

Durante la inicialización, el runtime configura la maquinaria que el GC necesitará después: el pacer, que decide cuándo lanzar una recolección (por defecto, cuando el heap ha duplicado su tamaño); el sweeper, que reclama memoria no utilizada después de una recolección; y las work queues por P que los workers del GC usarán para rastrear qué objetos siguen vivos.

Pero hay un detalle importante: el GC está inicializado pero aún no habilitado. No empezará a funcionar realmente hasta que se llame a gcenable() más adelante en runtime.main(). ¿Por qué? Porque habilitar el GC implica crear goroutines (para el barrido en segundo plano y la recuperación de memoria) y crear channels—y nada de eso funciona hasta que el scheduler y el resto del runtime estén completamente configurados. Además, lanzar un ciclo de GC antes de que los metadatos de tipos y los mapas de punteros estén listos podría hacer que el recolector escanease estructuras de datos incompletas.

Con las estructuras del GC listas, queda una última pieza del puzzle.

Inicialización de los Procesadores (P)

El runtime necesita crear las estructuras P (Processor). Piensa en un P como un puesto de trabajo: una goroutine necesita sentarse en uno para poder hacer algo, y un thread del sistema operativo es el trabajador que lo opera. Cada P viene con su propia cola de goroutines esperando a ejecutarse, su propia caché de memoria (para que las asignaciones sean rápidas y no necesiten locks), y sus propios timers y estado de workers del GC.

El número de Ps lo determina GOMAXPROCS, que por defecto es el número de núcleos de CPU detectados antes. Así que en una máquina de 8 núcleos, tienes 8 Ps—lo que significa que hasta 8 goroutines pueden ejecutarse verdaderamente en paralelo en cualquier momento.

Start the World

Y con eso, schedinit() llama a worldStarted(). El “mundo” ahora se considera arrancado—toda la infraestructura está lista para que las goroutines se ejecuten concurrentemente. El restaurante está abierto al público.

Con el scheduler, el allocator, el GC y toda la infraestructura de soporte en su sitio, el runtime está por fin listo para crear su primera goroutine real.

Creando la Goroutine Principal

Piensa en todo lo que hemos hecho hasta ahora como construir un coche—hemos montado el motor (scheduler), el sistema de combustible (memory allocator) y el escape (GC). Ahora toca girar la llave.

De vuelta en rt0_go, después de que schedinit() retorna, el runtime crea su primera goroutine. Pero fíjate—esta goroutine no ejecuta tu main.main. Ejecuta runtime.main, que es la función main propia del runtime. Tu código viene después.

Esta goroutine obtiene un stack inicial de 2KB (de los stack pools que configuramos antes) y se coloca en la cola de ejecución del primer P, lista para arrancar. Entonces el runtime pone en marcha el bucle de planificación en m0—eso es girar la llave. El scheduler inmediatamente coge la goroutine y empieza a ejecutar runtime.main().

El motor está en marcha. Ya casi llegamos a tu código—pero todavía no.

runtime.main: La Última Milla

Por fin estamos en código Go ejecutándonos como una goroutine propiamente dicha. Pero aún queda trabajo por hacer antes de que tu código se ejecute. Esto es runtime.main() (en src/runtime/proc.go ):

Tamaño Máximo del Stack y System Monitor

Primero, runtime.main() establece un límite de cuánto puede crecer el stack de cualquier goroutine—1GB en sistemas de 64 bits. Si una goroutine supera ese límite (normalmente por recursión infinita), el programa entra en panic con un stack overflow.

Después arranca el system monitor (sysmon)—un thread dedicado en segundo plano que actúa como el vigilante del runtime. Funciona de forma independiente al scheduler, vigilando las cosas: si una goroutine ha estado acaparando un P durante demasiado tiempo, sysmon la fuerza a ceder. Si un thread del sistema operativo ha estado bloqueado en una llamada al sistema, sysmon le quita su P y se lo da a otro thread para que otras goroutines puedan seguir ejecutándose. También le da un empujón al GC si no se ha ejecutado en un rato, comprueba si hay I/O de red listo, y devuelve memoria no utilizada al sistema operativo.

La goroutine principal también queda fijada al thread principal del sistema operativo en este punto. Esto es necesario por compatibilidad con ciertas librerías de C y frameworks de GUI que esperan que operaciones específicas ocurran siempre en el thread “principal”.

Con el vigilante en marcha y el thread principal asegurado, el runtime ya puede empezar a ejecutar tu código—bueno, casi. Aún quedan algunas cosas.

Funciones init() del Runtime

Primero, el runtime ejecuta sus propias funciones init() internas—las que pertenecen al paquete runtime y sus dependencias. Estas terminan de configurar estructuras de datos internas que no estaban del todo listas durante schedinit().

Con la inicialización propia del runtime completada, el scheduler está completamente operativo, los metadatos de tipos están en su sitio y los channels funcionan. Eso significa que por fin es seguro activar el GC.

Habilitando el Garbage Collector

¿Recuerdas que dijimos que el GC estaba inicializado pero no habilitado? Aquí es donde por fin se activa. gcenable() lanza las goroutines del sweeper y del scavenger en segundo plano, y a partir de ahora, el GC funciona recogiendo memoria no utilizada cuando el heap crece lo suficiente.

¿Por qué habilitarlo ahora y no después? Porque el siguiente paso—ejecutar las funciones init de tus paquetes—puede asignar grandes cantidades de memoria. El GC necesita estar activo para entonces.

Ejecutando las Funciones init() de los Paquetes

Ahora viene algo que probablemente te resulte familiar: las funciones init(). El runtime recorre cada paquete de tu programa y ejecuta sus funciones init() en orden de dependencias—si tu paquete importa fmt, entonces fmt (y todo de lo que fmt depende) se inicializa antes que tu paquete.

Aquí es también cuando se inicializan las variables a nivel de paquete. Así que si tienes algo como var db = connectToDB() al principio de un fichero, eso se ejecuta ahora, durante el bootstrap—no cuando empieza main().

Y ahora, por fin, no queda nada entre el runtime y tu código.

Por Fin: Tu main()

Después de todo eso—punto de entrada en ensamblador, TLS, detección de CPU, memory allocator, scheduler, GC, system monitor, funciones init—el runtime finalmente llama a tu función main.

¿Pero qué pasa cuando retorna?

Después de que main() Retorna

Cuando tu main retorna, el runtime no sale inmediatamente. Da un breve período de gracia para que las goroutines que estén en medio de la gestión de panics puedan terminar su limpieza con defer. ¿Pero cualquier otra goroutine que siga ejecutándose? Simplemente se mata—sin aviso, sin limpieza. Si necesitas que terminen, tienes que sincronizar explícitamente (por ejemplo, con un sync.WaitGroup).

Resumen

Volviendo a la pregunta con la que empezamos: ¿por qué Go es “lento” sin hacer nada? Ahora lo sabes—no está sin hacer nada en absoluto. Para cuando tu función main se ejecuta, el runtime ya ha construido todo un entorno de ejecución desde cero: una primera goroutine y un thread, thread-local storage, stack pools, un memory allocator, funciones hash para maps, un garbage collector, un scheduler con un P por núcleo de CPU, un thread de system monitor, y todas las funciones init de tus paquetes.

Es mucha maquinaria. Pero también es lo que hace que Go resulte tan natural cuando lo escribes—las goroutines son baratas porque los stack pools y el allocator ya están ahí, la gestión de memoria es invisible porque el GC ya está funcionando, y la concurrencia simplemente funciona porque el scheduler se configuró antes de tu primera línea de código.

Hemos cubierto el bootstrap a alto nivel en este artículo, tocando muchas piezas sin profundizar demasiado en ninguna de ellas. En los próximos artículos de esta serie, eso va a cambiar. Echaremos un vistazo en detalle al Scheduler y cómo reparte goroutines entre threads, al Memory Allocator y por qué la mayoría de las asignaciones no necesitan locks, y al Garbage Collector y cómo limpia la memoria manteniendo las pausas de stop-the-world lo más cortas posible. ¡Nos vemos allí!