El scheduler de Go ha evolucionado de un modelo puramente cooperativo a uno con capacidad de preemption asíncrona [disponible desde Go 1.14]. En el modelo cooperativo original, una goroutine solo cedía el control del procesador (P) en puntos específicos, como llamadas a funciones, operaciones de canales o llamadas al sistema. Si una goroutine ejecutaba un bucle infinito de cálculos matemáticos sin realizar ninguna de estas acciones, podía “secuestrar” el hilo de ejecución (M) y el procesador, impidiendo que otras goroutines se ejecutaran, incluso si el sistema tenía otros núcleos disponibles.
La preemption asíncrona resuelve esto permitiendo que el runtime interrumpa a una goroutine en cualquier momento para que otra pueda tomar su lugar. El mecanismo técnico utiliza señales del sistema operativo (específicamente SIGURG en sistemas Unix-like) para forzar una interrupción en el hilo de ejecución. El sysmon (system monitor), un hilo especial del runtime, supervisa la ejecución y, si detecta que una goroutine está monopolizando un P por demasiado tiempo, envía la señal para “desalojar” a la goroutine actual de forma segura. Esto ocurre en un “punto seguro” donde el runtime puede manipular la pila (stack) de la goroutine sin corromper la memoria.
En la práctica, esto significa que ya no tienes que preocuparte por insertar manualmente llamadas de “yield” en tus bucles de alto rendimiento para ser un “buen ciudadano” del scheduler. Sin embargo, si no entiendes cómo funciona, podrías cometer errores de diseño al asumir que el control del CPU siempre es equitativo o, en casos extremos, podrías interferir con el mecanismo si manejas tus propias señales de OS.
Para observar el efecto real de este cambio, lo mejor es forzar un escenario de recursos limitados.
package main
import (
"fmt"
"runtime"
"time"
)
// heavyCompute simula una carga de trabajo intensa que no realiza
// llamadas a funciones ni operaciones de I/O, lo que en versiones
// anteriores a Go 1.14 causaría starvation del scheduler.
func heavyCompute() {
for {
// Un bucle matemático simple y extremadamente ajustado.
// No hay llamadas a funciones externas que permitan
// el desalojo cooperativo.
_ = 1 + 1
}
}
// reportero es una goroutine que simplemente imprime un mensaje
// periódicamente para demostrar que el scheduler sigue vivo.
func reportero() {
for i := 1; i <= 5; i++ {
time.Sleep(time.Second)
fmt.Printf("Reportero: %d segundo(s) transcurridos...\n", i)
}
}
func main() {
// Forzamos a que el runtime use un solo procesador lógico (P).
// Esto es crucial para demostrar que, incluso con un solo recurso,
// la preemption asíncrona permite que el reportero se ejecute.
runtime.GOMAXPROCS(1)
fmt.Println("Iniciando cómputo intensivo y reportero...")
// Lanzamos la tarea que "secuestra" el CPU.
go heavyCompute()
// Lanzamos la tarea que depende de que el CPU se libere.
go reportero()
// Esperamos el tiempo suficiente para ver la salida del reportero.
time.Sleep(6 * time.Second)
fmt.Println("Ejecución finalizada.")
}
En el código anterior, la función heavyCompute es un bucle “ajustado” (tight loop). No llama a fmt.Println, no usa canales y no hace time.Sleep. Históricamente, en Go 1.13 o versiones anteriores, al ejecutar esto con runtime.GOMAXPROCS(1), el reportero nunca llegaría a imprimir nada porque la goroutine de cómputo nunca alcanzaría un punto de cooperatividad para ceder el P.
Sin embargo, gracias a la preemption asíncrona, el sysmon del runtime detecta que heavyCompute está ocupando el P de forma desproporcionada. El runtime envía una señal SIGURG, el hilo M es interrumpido, el scheduler salva el estado de la goroutine de cómputo y la mueve de nuevo a la cola de ejecución, permitiendo que reportero tome el control. Por eso, verás los mensajes del reportero intercalados con el tiempo de ejecución, a pesar de que el cómputo sea infinito y el CPU esté al 100%.
Es importante notar que el runtime utiliza un mecanismo de “safe points”. Cuando llega la señal, el hilo no se detiene en cualquier instrucción arbitraria, sino en un punto donde el estado de los registros y la pila son consistentes para que el scheduler pueda gestionar la goroutine sin causar un crash.
El error frecuente
Un error sutil ocurre cuando trabajas con código de muy bajo nivel o implementas llamadas a funciones mediante ensamblador (asm) que utilizan la directiva //go:nosplit.
La directiva nosplit le indica al compilador que la función no necesita realizar comprobaciones de desbordamiento de pila (stack overflow checks) para ganar rendimiento. Si bien esto es común en el núcleo del runtime de Go, es extremadamente peligroso en código de usuario. Si una función marcada como nosplit entra en un bucle infinito, se convierte en una excepción a la regla de preemption: al no haber puntos de chequeo de la pila, el runtime tiene dificultades para encontrar un punto seguro para desalojar la goroutine, lo que puede llevar a un bloqueo real del hilo y, en consecuencia, de todo el P.
N° 169