Una goroutine arranca con apenas 2-8 KB de stack. Un thread de sistema operativo reserva entre 1 y 8 MB desde el primer momento y no puede reducirse. Esta diferencia de cuatro órdenes de magnitud es lo que permite a Go mantener cientos de miles de goroutines activas en un portátil sin que la memoria se agote, algo imposible con el modelo de concurrencia basado en OS threads.
La diferencia de arranque
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
const N = 100_000
var wg sync.WaitGroup
antes := runtime.NumGoroutine()
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// goroutine vacía: mínima memoria
}()
}
fmt.Printf("Goroutines activas: %dn", runtime.NumGoroutine()-antes)
wg.Wait()
fmt.Println("100.000 goroutines completadas")
}
// Goroutines activas: 100000
// 100.000 goroutines completadas (en ~200ms en un portátil normal)
Con threads del sistema operativo, crear 100.000 threads consumiría entre 100 y 800 GB de memoria virtual reservada, lo que haría el programa inviable.
El scheduler M:N de Go
Go usa un modelo M:N: M goroutines se ejecutan sobre N OS threads, donde N está limitado por GOMAXPROCS. El runtime de Go tiene su propio scheduler que decide qué goroutine se ejecuta en qué thread:
import "runtime"
func main() {
// por defecto es el número de núcleos lógicos de la CPU
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("CPUs disponibles:", runtime.NumCPU())
// forzar a usar solo 2 threads (rara vez necesario)
runtime.GOMAXPROCS(2)
}
Work stealing: equilibrio automático de carga
Cada OS thread tiene una cola local de goroutines listas para ejecutar. Cuando un thread se queda sin trabajo, roba goroutines de la cola de otro thread. Este mecanismo, llamado work stealing, mantiene todos los núcleos ocupados sin intervención del programador.
// las goroutines se distribuyen automáticamente entre núcleos
for i := 0; i < runtime.NumCPU()*2; i++ {
go func(id int) {
// el scheduler decide en qué núcleo ejecutar cada una
calcularIntensivo(id)
}(i)
}
Stack que crece dinámicamente
El stack de una goroutine empieza pequeño y crece automáticamente cuando es necesario. El runtime hace una copia del stack a un espacio mayor, actualiza todos los punteros y continúa. El programador no necesita preocuparse por el tamaño del stack:
func recursionProfunda(n int) int {
if n == 0 {
return 0
}
return n + recursionProfunda(n-1)
}
func main() {
// el stack crece automáticamente; sin stack overflow hasta límites muy altos
fmt.Println(recursionProfunda(100_000))
}
Goroutines vs threads: comparativa real
func BenchmarkGoroutines(b *testing.B) {
for i := 0; i < b.N; i++ {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
}()
wg.Wait()
}
}
// ~1500 ns/op microsegundos para crear y destruir una goroutine
Crear y destruir un OS thread con pthread_create suele costar entre 10 y 30 microsegundos, diez veces más. Para un servidor que recibe miles de peticiones por segundo, esta diferencia se acumula.
Cuándo usar goroutines libremente
Puedes lanzar una goroutine por cada petición HTTP entrante, por cada conexión de cliente en un servidor TCP, por cada elemento de un lote de trabajo o por cada llamada a una API externa. El overhead es tan bajo que la regla práctica es: si el trabajo podría hacerse en paralelo, usa una goroutine.
El límite real: los recursos que comparten
El límite no son las goroutines en sí, sino los recursos que comparten: descriptores de fichero del sistema operativo, conexiones a bases de datos, memoria para los datos que procesan. Un pool de conexiones con 20 entradas limita la concurrencia real a 20 aunque tengas 10.000 goroutines, porque 9.980 esperarán en el canal del pool.
