GoSuda

Fundamentos de las Gorutinas

By hamori
views ...

Goroutine

Hay un artículo recurrente relacionado con la concurrencia (Concurrency) que surge a menudo cuando se les pide a los Gophers que hablen sobre las ventajas de golang. La base de ese contenido es la goroutine, que es ligera y fácil de manejar. He escrito brevemente sobre esto.

Concurrencia (Concurrency) vs Paralelismo (Parallelism)

Antes de entender las goroutines, me gustaría abordar dos conceptos que a menudo se confunden.

  • Concurrencia: La concurrencia se refiere a manejar muchas tareas a la vez. No significa necesariamente que se ejecuten simultáneamente en la práctica, sino que es un concepto estructural y lógico en el que múltiples tareas se dividen en unidades pequeñas y se ejecutan alternativamente, haciendo que parezca que varias tareas se están procesando simultáneamente para el usuario. La concurrencia es posible incluso en un solo core.
  • Paralelismo: El paralelismo es "el procesamiento simultáneo de múltiples tareas en múltiples cores". Literalmente, implica el avance de tareas de manera paralela y la ejecución simultánea de diferentes tareas.

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

El multi-thread (hilo múltiple) de Java, que es comúnmente utilizado y tiene una alta tasa de utilización, es un concepto representativo del paralelismo.

¿Por qué es buena la Goroutine?

Es ligera (lightweight)

El costo de creación es muy bajo en comparación con otros lenguajes. Surge la pregunta de por qué golang utiliza tan poco; esto se debe a que la ubicación de la creación se gestiona internamente dentro del Go runtime. Es porque es un hilo lógico ligero, como se mencionó anteriormente, es más pequeño que una unidad de hilo del OS, requiere un tamaño de pila inicial de aproximadamente 2KB y varía dinámicamente al agregar pila según la implementación del usuario.

Al gestionarse por unidad de pila, la creación y eliminación son muy rápidas y de bajo costo, lo que permite un procesamiento que no resulta oneroso incluso al ejecutar millones de goroutines. Gracias a esto, Goroutine puede minimizar la intervención del kernel del OS debido al planificador de tiempo de ejecución (runtime scheduler).

Tiene buen rendimiento (performance)

En primer lugar, como se explicó anteriormente, Goroutine tiene menos intervención del kernel del OS, lo que hace que el costo sea menor que el de una unidad de hilo del OS al realizar el cambio de contexto a nivel de usuario (User-Level), permitiendo un cambio rápido de tarea.

Además, utiliza el modelo M:N para asignar y gestionar los hilos del OS. Se puede procesar con menos hilos sin necesidad de muchos hilos al crear un pool de hilos del OS. Por ejemplo, si se entra en un estado de espera como una llamada al sistema, el Go runtime ejecuta otra goroutine en el hilo del OS, lo que permite que el hilo del OS no descanse y utilice la CPU de manera eficiente para un procesamiento rápido.

Debido a esto, Golang puede ofrecer un rendimiento superior en operaciones de I/O en comparación con otros lenguajes.

Es concisa (concise)

Poder manejar fácilmente una función con solo la palabra clave go cuando se requiere concurrencia es una gran ventaja.

Se deben utilizar Locks complejos como Mutex y Semaphore, y al usar Locks, es inevitable considerar el estado de DeadLock, lo que requiere pasos complejos desde la fase de diseño previa al desarrollo.

Goroutine recomienda la transferencia de datos a través de Channel (Canal), siguiendo la filosofía de "no comunicarse compartiendo memoria, sino compartir memoria comunicándose", y SELECT incluso admite la funcionalidad de procesar datos del canal que esté listo, al combinarse con Channel. Además, al usar sync.WaitGroup, se puede esperar simplemente hasta que todas las goroutines hayan finalizado, lo que facilita la gestión del flujo de trabajo. Gracias a estas herramientas, se puede prevenir el problema de la competencia de datos entre hilos y la concurrencia se puede manejar de forma más segura.

Además, al utilizar context, se puede controlar el ciclo de vida, la cancelación, el timeout, el deadline y el alcance de la solicitud a nivel de usuario (User-Level), lo que garantiza un cierto nivel de estabilidad.

Ejecución paralela de Goroutine (GOMAXPROCS)

Aunque se ha hablado de los beneficios de la concurrencia de goroutine, podría surgir la pregunta de si no soporta el paralelismo. Esto se debe a que el número de cores de las CPU recientes supera las dos cifras, a diferencia del pasado, e incluso las PC domésticas tienen un número considerable de cores.

Sin embargo, Goroutine también realiza trabajo paralelo, y eso es GOMAXPROCS.

Si no se configura GOMAXPROCS, se establecerá de manera diferente según la versión.

  1. Antes de la 1.5: Valor predeterminado 1, es esencial configurarlo de la forma runtime.GOMAXPOCS(runtime.NumCPU()) si se necesita más de 1.

  2. 1.5 a 1.24: Se cambió al número de todos los cores lógicos disponibles. A partir de este momento, no es necesario que el desarrollador lo configure a menos que se requiera una restricción significativa.

  3. 1.25: Como lenguaje conocido en entornos de contenedores, verifica el CPU limit configurado en el contenedor al revisar el cGroup en linux.

    Entonces, si el número de cores lógicos es 10 y el valor del límite de CPU es 5, GOMAXPROCS se establece en el número inferior de 5.

La modificación de la versión 1.25 representa un cambio muy significativo. Esto se debe a que la utilización del lenguaje en entornos de contenedores ha aumentado. Como resultado, se ha podido reducir la creación innecesaria de hilos y el cambio de contexto, previniendo el estrangulamiento de la CPU (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: 시작\n", name) // Goroutine %d: inicio
14	time.Sleep(10 * time.Millisecond) // 지연 para simular el trabajo
15	fmt.Printf("Goroutine %d: 시작\n", name) // Goroutine %d: inicio
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Usar solo 2 cores 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("모든 goroutine이 끝날 때까지 대기합니다...") // Esperando a que todas las goroutines terminen...
29	wg.Wait()
30	fmt.Println("모든 작업이 완료되었습니다.") // Todas las tareas han sido completadas.
31
32}
33

Planificador de Goroutine (Modelo M:N)

Si examinamos con un poco más de detalle la parte anterior que indica que se utiliza el modelo M:N para asignar y gestionar los hilos del OS, encontramos el modelo GMP de goroutine.

  • G (Goroutine): La unidad de trabajo más pequeña ejecutada en Go.
  • M (Machine): Hilo del OS (la ubicación real del trabajo).
  • P (Processor): Proceso lógico gestionado por el Go runtime.

P adicionalmente tiene una cola de ejecución local (Local Run Queue) y actúa como un planificador que asigna la G asignada a M. Simplemente, la goroutine es

El proceso de funcionamiento de GMP es el siguiente:

  1. Cuando se crea una G (Goroutine), se asigna a la cola de ejecución local de P (Processor).
  2. P (Processor) asigna la G (Goroutine) en la cola de ejecución local a M (Machine).
  3. M (Machine) devuelve el estado de G (Goroutine), que puede ser block, complete o preempted.
  4. Work-Stealing (Robo de trabajo): Si la cola de ejecución local de P se vacía, otro P verifica la cola global. Si tampoco hay G (Goroutine) allí, roba el trabajo de otro P (Processsor) local para que todos los M sigan funcionando sin descanso.
  5. Procesamiento de llamadas al sistema (Blocking): Si ocurre un Block mientras se ejecuta una G (Goroutine), M (Machine) entra en estado de espera. En ese momento, P (Processor) se separa del M (Machine) que está en Block y se combina con otro M (Machine) para ejecutar la siguiente G (Goroutine). En este caso, no hay desperdicio de CPU incluso durante el tiempo de espera en una operación de I/O.
  6. Si una G (Goroutine) permanece preempted (expropiada) por mucho tiempo, se le da la oportunidad de ejecución a otra G (Goroutine).

El GC (Garbage Collector) de Golang también se ejecuta sobre Goroutine, lo que permite limpiar la memoria de forma paralela con una interrupción mínima de la ejecución de la aplicación (STW), utilizando los recursos del sistema de manera eficiente.

Finalmente, Golang es una de las mayores fortalezas del lenguaje, y hay muchas más, así que espero que muchos desarrolladores disfruten de Go.

Gracias.