CAP 08 · LEC 01·Concurrencia

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.

● AVANZADO8 min lecturapor Fernando Herrera · actualizado mayo de 2026
¿Encontraste un error o algo que mejorar?Editá esta lección en GitHub →

¿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") }
SalidaHola, Ana Hola, Luis Hola, Marta main termina
main es una goroutine especial

Cuando 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()) }
SalidaCPUs disponibles: 8 GOMAXPROCS: 8 Goroutines activas tras esperar: 1

Una 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() }
Salidaworker 0 listo (suma=499999500000) worker 1 listo (suma=499999500000) worker 2 listo (suma=499999500000) worker 3 listo (suma=499999500000)
Concurrencia ≠ paralelismo

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() }
Salidan = 2 n = 0 n = 1
Go 1.22+ cambió la semántica del bucle

Desde 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 }
Salidacontador: 973

La 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."