GoSuda

Funcionamiento de las goroutines en Go: pila variable de 2 KB y el planificador M:N GMP

By hamori views ...

Goroutine

Cuando se les pide a los Gophers que hablen sobre las ventajas de Golang, suele aparecer un tema recurrente relacionado con la concurrencia (Concurrency). La base de ese contenido es la goroutine (goroutine), que permite procesar tareas de forma ligera y sencilla. Al respecto, hemos preparado un breve resumen.

Concurrencia (Concurrency) vs. Paralelismo (Parallelism)

Antes de entender las goroutines, queremos repasar dos conceptos que suelen confundirse.

  • Concurrencia: La concurrencia trata sobre manejar muchas tareas a la vez. No significa necesariamente que se ejecuten simultáneamente, sino que es un concepto estructural y lógico que consiste en dividir varias tareas en unidades pequeñas y alternar su ejecución, de modo que, para el usuario, parezca que se procesan múltiples tareas al mismo tiempo. La concurrencia también es posible en un único núcleo.
  • Paralelismo: El paralelismo es "procesar varias tareas al mismo tiempo en varios núcleos". Literalmente, consiste en avanzar en el trabajo de forma paralela, ejecutando otras tareas simultáneamente.

Las goroutines facilitan la implementación de la concurrencia a través del planificador (scheduler) del runtime de Go, y mediante la configuración de GOMAXPROCS aprovechan también el paralelismo de forma natural.

El multihilo (Multi thread) de Java, de uso muy extendido, es un concepto representativo del paralelismo.

¿Por qué son buenas las goroutines?

Ligereza (lightweight)

El coste de creación es muy bajo en comparación con otros lenguajes. Aquí surge la duda de por qué Golang utiliza tan pocos recursos; la razón es que su ubicación de creación es gestionada internamente por el runtime de Go. Esto se debe a que se trata de un hilo lógico ligero, más pequeño que la unidad de un hilo del sistema operativo (OS thread), que requiere un tamaño de pila inicial de aproximadamente 2 KB y que, según la implementación del usuario, puede ampliar su pila dinámicamente.

Como se gestionan por unidades de pila, su creación y eliminación son muy rápidas y económicas, por lo que es posible ejecutar millones de goroutines sin que resulte una carga excesiva. Gracias al planificador del runtime, las goroutines pueden minimizar la intervención del kernel del sistema operativo.

Rendimiento (performance)

En primer lugar, como se explicó anteriormente, las goroutines implican poca intervención del kernel del SO, por lo que el cambio de contexto a nivel de usuario (User-Level) es más económico que a nivel de hilo del SO, lo que permite cambiar de tarea rápidamente.

Además, se utiliza el modelo M:N para asignar las goroutines a los hilos del SO y gestionarlas. Mediante un pool de hilos del SO, se puede procesar la carga con pocos hilos sin necesidad de crear muchos. Por ejemplo, cuando se produce un estado de espera, como una llamada al sistema, el runtime de Go ejecuta otra goroutine en el hilo del SO, de modo que este no se detiene y utiliza la CPU de forma eficiente, logrando un procesamiento rápido.

Gracias a ello, Golang puede alcanzar un rendimiento superior al de otros lenguajes, especialmente en operaciones de E/S (I/O).

Concisión (concise)

Otra gran ventaja es que, cuando se necesita concurrencia, basta con una sola palabra clave go para ejecutar una función de forma sencilla.

Con Mutex, Semaphore y otros Locks complejos, es necesario considerar el estado de interbloqueo (DeadLock) que inevitablemente surge al usar locks, lo que obliga a incorporar una fase compleja desde la etapa de diseño, antes del desarrollo.

Goroutine sigue la filosofía de "no comunicarse compartiendo memoria, sino compartir memoria comunicándose", por lo que recomienda la transferencia de datos a través de Channel (Channel). Además, SELECT se combina con los canales para procesar primero el canal que tenga datos listos. Asimismo, con sync.WaitGroup se puede esperar de forma sencilla a que terminen todas las goroutines, lo que facilita la gestión del flujo de trabajo. Gracias a estas herramientas, se previenen los problemas de carrera de datos entre hilos y la concurrencia puede manejarse de forma más segura.

También, mediante el contexto (context) se pueden controlar a nivel de usuario (User-Level) el ciclo de vida, la cancelación, los tiempos de espera, los plazos y el alcance de las solicitudes, lo que permite garantizar cierto grado de estabilidad.

Paralelismo de Goroutine (GOMAXPROCS)

Tras explicar las ventajas de la concurrencia de las goroutines, seguramente surge la pregunta de si no se admite también el paralelismo. Es así porque, en la actualidad, el número de núcleos de las CPU ha superado las dos cifras, a diferencia del pasado, y los PC domésticos también incorporan una cantidad considerable de núcleos.

Sin embargo, las goroutines también realizan trabajo en paralelo; eso es GOMAXPROCS.

Si no se configura GOMAXPROCS, su valor predeterminado varía según la versión.

  1. Antes de 1.5: el valor predeterminado es 1; para usar más, es obligatorio configurarlo de forma como runtime.GOMAXPOCS(runtime.NumCPU())

  2. De 1.5 a 1.24: cambió para usar todos los núcleos lógicos disponibles. A partir de entonces, en general no es necesario configurarlo, salvo que existan restricciones particulares para el desarrollador.

  3. 1.25: como corresponde a un lenguaje muy utilizado en entornos de contenedores, consulta el cgroup de Linux para comprobar el límite de CPU configurado en el contenedor.

    Así, si hay 10 núcleos lógicos y el límite de CPU es 5, GOMAXPROCS se establece en 5, que es el valor menor.

La modificación de 1.25 tiene una gran relevancia, porque mejora el uso del lenguaje en entornos de contenedores. Gracias a ello, se reducen la creación innecesaria de hilos y el cambio de contexto, lo que permite prevenir el throttling de CPU (throttling).

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: inicio\n", name)
14	time.Sleep(10 * time.Millisecond) // retardo para simular la tarea
15	fmt.Printf("Goroutine %d: inicio\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // usar solo 2 núcleos de CPU
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("Esperando a que terminen todas las goroutines...")
29	wg.Wait()
30	fmt.Println("Todas las tareas han finalizado.")
31
32}
33

Planificador de Goroutine (modelo M:N)

En cuanto al punto anterior, que menciona que se asignan a hilos del SO mediante el modelo M:N, si profundizamos un poco más, existe el modelo GMP de las goroutines.

  • G (Goroutine): la unidad de trabajo más pequeña que se ejecuta en Go
  • M (Machine): hilo del SO (donde se realiza el trabajo real)
  • P (Processor): proceso lógico gestionado por el runtime de Go

P dispone además de una cola local de ejecución (Local Run Queue) y cumple la función de planificador que asigna las G a las M. En resumen, las goroutines funcionan del siguiente modo.

El proceso de GMP es el siguiente:

  1. Cuando se crea una G (Goroutine), se asigna a la cola local de ejecución de un P (Processor).
  2. El P (Processor) asigna a una M (Machine) la G (Goroutine) que se encuentra en su cola local de ejecución.
  3. La M (Machine) devuelve el estado de la G (Goroutine), que puede ser block, complete o preempted.
  4. Work-Stealing (robo de trabajo): si la cola local de ejecución de un P se vacía, el otro P consulta la cola global. Si tampoco hay G (Goroutine) allí, roba trabajo de la cola local de otro P (Processor), de modo que todas las M funcionen sin descanso.
  5. Gestión de llamadas al sistema (Blocking): si se produce un bloqueo durante la ejecución de una G (Goroutine), la M (Machine) entra en estado de espera. En ese momento, el P (Processor) se separa de la M bloqueada y se asocia a otra M para ejecutar la siguiente G (Goroutine). Así, durante la espera de operaciones de E/S (I/O) no hay desperdicio de CPU.
  6. Si una G (Goroutine) ocupa el procesador durante mucho tiempo (preempted), se da oportunidad de ejecución a otra G (Goroutine).

Golang también ejecuta el GC (Garbage Collector) sobre las goroutines, lo que permite limpiar la memoria en paralelo interrumpiendo la aplicación lo mínimo posible (STW), y así utiliza los recursos del sistema de forma eficiente.

Para terminar, Golang es una de las grandes fortalezas del lenguaje, y hay muchas más; espero que muchos desarrolladores disfruten programando en Go.

Gracias.