DEV Community

Cover image for Cómo Go implementa goroutines y qué pasa bajo el capó
Roberto Morais
Roberto Morais

Posted on

Cómo Go implementa goroutines y qué pasa bajo el capó

¡Hola, colegas Go-geeks! 🚀

En este artículo vamos a destripar el pilar de la concurrencia en Go: las goroutines. Mucho se habla de lo fáciles que son de usar, pero… ¿qué sucede realmente cuando escribes go f()? ¿Cómo gestiona Go miles (¡o millones!) de esas “ligeras” hebras sin petar el sistema operativo?

Veremos:

  1. El modelo M:N de goroutines vs. hilos OS.
  2. Las estructuras internas: G, M y P.
  3. El scheduler: work-stealing, parking/unparking.
  4. Costes reales y ejemplos de benchmark.
  5. Consejos prácticos y recomendaciones.

1. ¿Por qué goroutines y no hilos “a pelo”?

En lenguajes tradicionales (Java, C++), cada hilo OS consume decenas de kilobytes o más de stack, y el cambio de contexto es caro. Go introdujo en 2009 un modelo más ligero:

  • Goroutine: hebra del lenguaje, con un stack inicial de apenas 2 KB (que crece y decrece dinámicamente).
  • Hilo OS: recurso del sistema que maneja la ejecución real en CPU.
  • Scheduler M:N: multiplexa M hilos OS para ejecutar N goroutines.

Esta capa intermedia permite lanzar miles de goroutines sin arruinar tu RAM o explotar el scheduler del kernel.


2. G, M y P: las tres siglas mágicas

El runtime de Go gira alrededor de tres estructuras clave:

Símbolo Nombre Rol principal
G Goroutine Contiene estado de la goroutine: stack, programa, args.
M Machine (hilo OS) Representa un hilo del sistema operativo.
P Processor (contexto) Guarda la “capacidad de ejecutar”: una cola de G listos.
  1. G (goroutine)

    • Estructura runtime.g: puntero al PC, stack, estado (running, runnable, waiting…).
    • Stack dinámico: empieza en ~2 KB y se expande hasta varios MB si hay recursión profunda.
  2. M (machine)

    • Estructura runtime.m: mapea a un hilo real del SO.
    • Cada M puede ejecutar cero o una P a la vez.
  3. P (processor)

    • Estructura runtime.p: puente entre G y M.
    • Contiene una cola local de goroutines (runqueue) y acceso a la cola global.

Cuando arranca tu programa, Go crea GOMAXPROCS Ps (por defecto igual al número de CPUs), y un pool inicial de Ms crece o desciende según la carga.


3. El corazón del scheduler M:N

3.1 Asignación de G a M vía P

  1. Una nueva goroutine se inserta en la cola local de la P que la lanzó.
  2. Si la P está siendo ejecutada por un M, éste saca una G de su cola y la ejecuta.
  3. Cuando la cola local se vacía, la P “roba” trabajo de otras Ps (work-stealing) o de la cola global.

3.2 Work-stealing

  • Cada P mantiene un array circular de tamaño fijo (256) para su cola local.
  • Si tu P no tiene Gs, revisa otra P (aleatoria o en ronda) para “robar” la mitad de su cola.
  • Esto equilibra la carga sin necesidad de bloqueo global casi siempre.

3.3 Parking y unparking

Cuando una G se bloquea (por ejemplo, lectura de canal, bloqueo en syscall, sleep):

  1. Se marca como waiting y se mueve a la cola de espera adecuada.
  2. El M que la ejecutaba “desvincula” la P y busca otra P con trabajo o crea una nueva M si no hay Ps libres y estamos bajo el límite.
  3. Cuando el evento (IO, timer, canal) se dispara, la G se re-activa: va a la cola del P que la despierte (a veces la global).

4. Consejos prácticos y “gotchas” de rendimiento

Evita goroutines “huérfanas”: siempre acompáñalas de un contexto o WaitGroup, o acaban vivas consumiendo memoria.

No blockees tu P: llamadas de bloqueo largas (ej. IO sin usar el paquete net/http de Go) pueden consumir un M completo; mejor usa las primitivas del runtime o goroutines dedicadas.

Tweak de GOMAXPROCS: por defecto vale runtime.NumCPU(). Jugar con este valor puede mejorar throughput en programas IO-bound vs CPU-bound.

Perfilado: usa go test -bench y pprof para ver cuántas goroutines están vivas y analizar cuellos de botella.


Conclusión

Las goroutines no son magia negra, sino el resultado de un diseño cuidadoso en el runtime de Go.
Con un scheduler M:N, work-stealing y stacks dinámicos, Go te permite escribir código concurrente simple y eficiente sin renunciar al rendimiento.

Si te ha molado el viaje al interior del runtime, ¡dale ❤️, comenta tus dudas y comparte tus propios benchmarks! Y, por supuesto, sigue explorando: el recolector de basura, el modelo de memoria y el linker de Go tienen aún más secretos por descubrir.


Si prefieres un soporte visual, por aquí te dejo un video hablando de la concurrencia en Go:


¡Nos leemos en el próximo artículo! 👋

Top comments (0)