Costo real de una goroutine vs un thread del SO

Cuando diseñas sistemas de alta disponibilidad, la eficiencia en el uso de recursos es lo que separa un servicio estable de uno que colapsa bajo carga. En Go, la unidad fundamental de ejecución es la goroutine. A diferencia de un thread (hilo) del sistema operativo, una goroutine es una unidad de ejecución gestionada por el runtime de Go, lo que la hace extremadamente ligera.

Para entender por qué esto es vital, debemos mirar la gestión de memoria y el tiempo de CPU. Un hilo del sistema operativo suele tener un stack (pila) fijo y preasignado, que suele rondar entre 1 y 8 MB. Si intentas levantar 100.000 hilos en una máquina con 16 GB de RAM, el sistema operativo agotará la memoria antes de terminar debido al enorme desperdicio de espacio en cada hilo. Por el contrario, una goroutine comienza con un stack muy pequeño, de apenas 2 KB [disponible desde Go 1.23], que crece o se encoge dinámicamente según sea necesario mediante un proceso de stack copying (copia de la pila a un nuevo bloque de memoria más grande).

El segundo factor es el context switch (cambio de contexto). Cambiar de un hilo a otro a nivel de sistema operativo requiere una llamada al kernel (syscall), lo que implica guardar y restaurar una cantidad masiva de registros de CPU y limpiar la caché. El scheduler de Go realiza el cambio de contexto en userspace (espacio de usuario), es decir, sin involucrar al kernel, lo que reduce drásticamente el costo de CPU.

Debido a esto, puedes levantar cientos de miles de goroutines de forma casi instantánea; el problema real no será la memoria o el scheduler, sino la lógica de tu aplicación que las gestione. Sin embargo, si lanzas goroutines sin control o sin una estrategia de limitación, puedes sufrir goroutine leaks (fugas de goroutines), donde estas quedan bloqueadas eternamente consumiendo memoria y recursos de scheduler.

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

// simularTrabajo representa una tarea ligera que podríamos ejecutar en producción.
func simularTrabajo(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Notifica al WaitGroup que la goroutine terminó.

	// Realizamos un cálculo simple para evitar que el compilador optimice la variable.
	resultado := id * id
	_ = resultado 
}

func main() {
	// Definimos un volumen de carga que mataría a un sistema basado en hilos de OS.
	// 100.000 hilos de 1MB cada uno = ~100GB de RAM.
	// 100.000 goroutines de 2KB cada una = ~200MB de RAM.
	const numGoroutines = 100_000

	var wg sync.WaitGroup
	start := time.Now()

	for i := 0; i < numGoroutines; i++ {
		wg.Add(1)
		
		// Lanzamos la goroutine. El scheduler de Go se encarga de 
		// distribuirla entre los hilos de OS disponibles (M:P:G model).
		go simularTrabajo(i, &wg)
	}

	// Esperamos a que todas las goroutines completen su ejecución.
	wg.Wait()

	elapsed := time.Since(start)

	fmt.Printf("Ejecución completada:\n")
	fmt.Printf("  Goroutines lanzadas: %d\n", numGoroutines)
	fmt.Printf  ("  Tiempo total:        %v\n", elapsed)
	fmt.Printf("  CPUs detectados:     %d\n", runtime.NumCPU())
}

En el código anterior, vemos cómo la creación de 100.000 goroutines es una operación trivial para el runtime. Al llamar a go simularTrabajo(...), no estamos pidiendo permiso al kernel para crear un hilo; simplemente estamos añadiendo una tarea a la cola del scheduler de Go.

Fíjate en el uso de sync.WaitGroup. Es indispensable para sincronizar la ejecución. Sin wg.Wait(), el programa main terminaría antes de que las goroutines siquiera empezen a ejecutarse, ya que main corre en su propia goroutine. La variable wg actúa como un contador: wg.Add(1) incrementa el contador por cada tarea, y wg.Done() lo decrementa al finalizar cada función.

El tiempo que ves en la salida (elapsed) es sorprendentemente bajo porque el costo de instanciar la estructura de la goroutine y asignarle sus 2 KB iniciales es mínimo. El sistema no colapsa porque el número de hilos reales del sistema operativo (gestionados por el runtime para ejecutar las goroutines) se mantiene limitado al número de núcleos lógicos o al GOMAXPROCS configurado, evitando el agotamiento de recursos del sistema.

El error frecuente

Un error clásico en sistemas de alta concurrencia es el goroutine leak. Esto ocurre cuando lanzas una goroutine que espera una señal (normalmente a través de un canal) que nunca llega.

// EJEMPLO CON ERROR: Esto causará una fuga de memoria.
func leakEjemplo(c chan int) {
    go func() {
        // Si el canal 'c' nunca recibe datos, esta goroutine 
        // se queda bloqueada para siempre, reteniendo su stack.
        val := <-c 
        fmt.Println(val)
    }()
}

Si llamas a leakEjemplo repetidamente en un servidor que recibe peticiones HTTP, cada llamada dejará una goroutine “zombie” en memoria. Aunque solo ocupen 2 KB inicialmente, con el tiempo, estas goroutines acumulan stacks más grandes si su lógica es compleja, terminando por agotar la memoria del servidor de forma silenciosa y difícil de diagnosticar.

130

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio