Goroutines vs Threads: El modelo M:N de Go

Una goroutine es la unidad mínima de ejecución gestionada por el runtime de Go, no por el kernel del sistema operativo. A diferencia de un thread tradicional (un hilo de OS), que es un objeto pesado con un stack de tamaño fijo (usualmente 1MB o 2MB), una goroutine comienza con un stack extremadamente pequeño, de apenas 2 a 8 KB. Lo más potente es que este stack es dinámico: si la función que estás ejecutando necesita más memoria, el runtime expande el stack de forma transparente.

Esta diferencia de arquitectura permite que el scheduler de Go implemente un modelo M:N, lo que significa que multiplexa miles (o incluso millones) de goroutines sobre un número muy reducido de threads reales del OS. En un modelo de threading tradicional, el cambio de contexto (context switch) requiere una llamada al sistema (syscall) costosa porque el kernel debe intervenir. En Go, el cambio de contexto ocurre en el espacio de usuario, gestionado por el scheduler de Go, lo que lo hace órdenes de magnitud más rápido.

Debido a esto, puedes lanzar 100,000 goroutines en un servidor moderno sin agotar la memoria RAM de inmediato. En cambio, intentar lanzar 100,000 threads de OS en Java o C++ probablemente colapsaría tu sistema por el consumo de memoria y el overhead del scheduler del kernel. Sin embargo, el modelo de diseño de Go se aleja de la comunicación mediante memoria compartida (locks y mutex) para abrazar el modelo CSP (Communicating Sequential Processes): la idea de que es mejor comunicar datos a través de channels que compartir memoria para comunicarse.

Si te equivocas gestionando estas unidades —por ejemplo, si lanzas goroutines que nunca terminan— no saturarás el sistema por la cantidad de hilos de OS, sino que causarás una fuga de memoria (memory leak) porque cada goroutine “colgada” mantiene su stack ocupando espacio.

package main

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

// worker simula una tarea que consume recursos de forma controlada.
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Notifica al WaitGroup que esta goroutine ha terminado.

	// Simulamos una operación de I/O o un proceso ligero.
	time.Sleep(time.Millisecond * 50)
	
	if id%25000 == 0 {
		fmt.Printf("Worker %d completado\n", id)
	}
}

func main() {
	// GOMAXPROCS define cuántos threads de OS el runtime usará para ejecutar código en paralelo.
	// No es el límite de goroutines, sino el paralelismo real de CPU.
	maxThreads := runtime.GOMAXPROCS(0)
	fmt.Printf("Threads de OS configurados para paralelismo: %d\n", maxThreads)

	const totalGoroutines = 100_000
	var wg sync.WaitGroup

	start := time.Now()

	for i := 0; i < totalGoroutines; i++ {
		wg.Add(1)
		// Lanzamos la goroutine. Pasamos 'i' como parámetro para evitar 
		// problemas de captura de la variable del loop en versiones antiguas.
		go worker(i, &wg)
	}

	// Wait bloquea la ejecución de main hasta que el contador de wg sea cero.
	wg.Wait()

	duration := time.Since(start)

	fmt.Printf("\n--- Resultados ---\n")
	fmt.Printf("Goroutines procesadas: %d\n", totalGoroutines)
	fmt.Printf("Tiempo total:          %v\n", duration)
	fmt.Printf("Goroutines activas:    %d\n", runtime.NumGoroutine())
	fmt.Printf("Threads de OS (aprox): %d\n", runtime.NumGoroutine()) // Nota: NumGoroutine cuenta goroutines, no threads.
}

Desglose del ejemplo

Fíjate en cómo utilizamos sync.WaitGroup para coordinar la finalización. Si no llamáramos a wg.Wait(), la función main terminaría inmediatamente y todas las goroutines se abortarían sin completar su trabajo, ya que main es la goroutine raíz.

En el bucle for, lanzamos 100,000 goroutines de forma casi instantánea. Si estas fueran threads de OS, tu máquina sufriría un estrés masivo en el scheduler del kernel. Sin embargo, gracias al modelo M:N, el runtime de Go reparte este trabajo masivo entre los pocos threads definidos por runtime.GOMAXPROCS(0).

Es importante notar la llamada worker(i, &wg). Al pasar wg como un puntero (*sync.WaitGroup), nos aseguramos de que todas las goroutines operen sobre la misma estructura de control de sincronización en memoria. Si pasáramos wg por valor, cada goroutine tendría su propia copia local, el contador nunca llegaría a cero en la función main, y entraríamos en un deadlock.

El tiempo de ejecución que ves al final es extremadamente bajo para el volumen de tareas. Esto ocurre porque la mayoría de las goroutines están en estado de espera (time.Sleep), y el scheduler de Go las pone en un estado de “bloqueado” de forma eficiente, permitiendo que los hilos de OS se dediquen a procesar otras goroutines que sí tienen trabajo pendiente.

El error frecuente

El error más peligroso con las goroutines no es que la aplicación consuma mucha CPU, sino la goroutine leak. Esto sucede cuando una goroutine se queda bloqueada esperando un evento que nunca ocurrirá.

func leakExample() {
    ch := make(chan int)

    go func() {
        // Esta goroutine se queda bloqueada aquí para siempre 
        // porque nadie nunca enviará nada al canal 'ch'.
        val := <-ch 
        fmt.Println(val)
    }()

    // La función leakExample termina, pero la goroutine de arriba 
    // sigue viva en memoria, con su stack ocupando espacio, 
    // esperando indefinidamente. Si esto ocurre en un loop, 
    // tu servidor morirá por falta de memoria.
}

Si lanzas esto dentro de un servidor que procesa peticiones HTTP, cada petición dejará una goroutine “zombie” en memoria. Eventualmente, el Garbage Collector no podrá liberar esa memoria porque la goroutine sigue siendo una raíz de ejecución activa.

128

Dejar un comentario

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

Scroll al inicio