Goroutines: el modelo M:N de Go
Una goroutine es una función ejecutándose concurrentemente con otras goroutines en el mismo espacio de direcciones. Son extremadamente baratas — puedes lanzar miles sin agotar la máquina.
¿Qué es una goroutine?
Una goroutine es una unidad de ejecución concurrente gestionada por el runtime de Go, no por el sistema operativo. Se crea con la palabra clave go seguida de una llamada a función. La función se ejecuta en paralelo con el resto del programa.
package main
import (
"fmt"
"time"
)
func saludar(nombre string) {
fmt.Println("Hola,", nombre)
}
func main() {
// Llamada normal: bloquea hasta terminar
saludar("Ana")
// Con go: lanza la función en una goroutine separada
go saludar("Luis")
go saludar("Marta")
// main es también una goroutine. Si termina, el programa muere
// y las otras goroutines no llegan a imprimir. Damos tiempo.
time.Sleep(100 * time.Millisecond)
fmt.Println("main termina")
}Hola, Ana
Hola, Luis
Hola, Marta
main terminaCuando la goroutine main termina, el programa entero termina — sin esperar a las demás. Por eso necesitas mecanismos de sincronización como canales o sync.WaitGroup (no time.Sleep, que solo sirve para ejemplos).
El modelo M:N — goroutines vs threads
Los lenguajes tradicionales mapean cada thread del lenguaje a un thread del sistema operativo (1:1). Crear un thread del SO cuesta megabytes de memoria y microsegundos de tiempo. Lanzar miles es inviable.
Go usa un modelo M:N: muchas goroutines (M) se multiplexan sobre pocos threads del SO (N). El runtime tiene su propio scheduler que mueve goroutines entre threads según haga falta.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
// Cuántos threads del SO puede usar Go simultáneamente
fmt.Println("CPUs disponibles:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// Lanzamos 10 000 goroutines — algo impensable con threads del SO
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
_ = n * 2
}(i)
}
wg.Wait()
fmt.Println("Goroutines activas tras esperar:", runtime.NumGoroutine())
}CPUs disponibles: 8
GOMAXPROCS: 8
Goroutines activas tras esperar: 1Una goroutine empieza con un stack de ~2 KB que crece y se contrae dinámicamente. Un thread del SO empieza con 1-2 MB de stack fijo. Por eso lanzar 100 000 goroutines es razonable y lanzar 100 000 threads no lo es.
GOMAXPROCS y paralelismo real
runtime.GOMAXPROCS(n) controla cuántos threads del SO ejecutan goroutines simultáneamente. Por defecto es igual al número de CPUs lógicas. Con GOMAXPROCS=1 el programa sigue siendo concurrente (las goroutines se turnan) pero no paralelo (solo una corre a la vez).
package main
import (
"fmt"
"runtime"
"sync"
)
func trabajo(id int, wg *sync.WaitGroup) {
defer wg.Done()
suma := 0
for i := 0; i < 1_000_000; i++ {
suma += i
}
fmt.Printf("worker %d listo (suma=%d)\n", id, suma)
}
func main() {
// Limitamos a 1 CPU: las goroutines se ejecutan en serie
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go trabajo(i, &wg)
}
wg.Wait()
}worker 0 listo (suma=499999500000)
worker 1 listo (suma=499999500000)
worker 2 listo (suma=499999500000)
worker 3 listo (suma=499999500000)Concurrencia es la estructura del programa: lidiar con muchas cosas a la vez. Paralelismo es la ejecución: hacer muchas cosas a la vez. Go te da concurrencia con goroutines; el paralelismo depende de GOMAXPROCS y de las CPUs reales.
Closures y data races
Cuando una goroutine captura variables del scope exterior, hay que tener cuidado. Un bug clásico es capturar la variable del bucle por referencia.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// ❌ Mal (Go < 1.22): todas las goroutines comparten 'i'
// for i := 0; i < 3; i++ {
// wg.Add(1)
// go func() {
// defer wg.Done()
// fmt.Println("i =", i) // probablemente imprime 3, 3, 3
// }()
// }
// ✅ Bien: pasar 'i' como argumento crea una copia
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("n =", n)
}(i)
}
wg.Wait()
}n = 2
n = 0
n = 1Desde Go 1.22, cada iteración del for tiene su propia variable, por lo que el primer caso ya funciona correctamente. Aun así, pasar valores como argumento es más explícito y portable entre versiones.
Cuándo lanzar muchas goroutines
Las goroutines brillan cuando tienes muchas tareas que pasan tiempo esperando (red, disco, base de datos). Lanzar una goroutine por conexión entrante es idiomático en Go — los servidores HTTP de la stdlib lo hacen.
Para CPU-bound puro, no ganas nada lanzando más goroutines que CPUs tienes. Y si dos goroutines acceden a la misma memoria sin sincronización, tienes un data race: usa el flag -race al compilar para detectarlos.
package main
import (
"fmt"
"sync"
)
func main() {
// ❌ Data race: dos goroutines escriben en 'contador' sin proteger
var contador int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
contador++ // lectura + escritura sin lock
}()
}
wg.Wait()
// El valor final NO es 1000 fiablemente — depende del scheduling
fmt.Println("contador:", contador)
// Ejecuta con: go run -race main.go para verlo reportado
}contador: 973La solución son canales (capítulo siguiente) o primitivas de sync (mutex, atomic). El lema de Go: "don't communicate by sharing memory; share memory by communicating."