GoSuda

Fundamentos de las GoRoutines

By hamori
views ...

Goroutine

Cuando se les pide a los Gophers que expliquen las ventajas de Go, a menudo se menciona la concurrencia. La base de este concepto es la goroutine, que puede manejarse de manera ligera y sencilla. A continuación, he redactado una breve descripción al respecto.

Concurrencia vs. Paralelismo

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

  • Concurrencia: La concurrencia se refiere a la capacidad de gestionar muchas tareas a la vez. No significa necesariamente que se ejecuten simultáneamente, sino que es un concepto estructural y lógico donde múltiples tareas se dividen en unidades más pequeñas y se ejecutan de forma alternada, lo que da la impresión al usuario de que varias tareas se están procesando al mismo tiempo. La concurrencia es posible incluso en un solo núcleo.
  • Paralelismo: El paralelismo se refiere a "la ejecución simultánea de múltiples tareas en múltiples núcleos". Literalmente, implica el progreso de tareas en paralelo, ejecutando diferentes trabajos al mismo tiempo.

Las goroutines facilitan la implementación de la concurrencia a través del programador de tiempo de ejecución de Go y aprovechan naturalmente el paralelismo mediante la configuración de GOMAXPROCS.

Los Multi-threads de Java, que son comúnmente utilizados y de alta demanda, son un concepto representativo del paralelismo.

¿Por qué las Goroutines son beneficiosas?

Ligeras (lightweight)

El costo de creación es muy bajo en comparación con otros lenguajes. Surge la pregunta de por qué Go utiliza menos recursos, y la razón es que su creación es gestionada internamente por el tiempo de ejecución de Go. Esto se debe a que son hilos lógicos ligeros, más pequeños que las unidades de hilo del sistema operativo, requieren un tamaño de pila inicial de aproximadamente 2KB y pueden variar dinámicamente al añadir pila según la implementación del usuario.

Al ser gestionadas en unidades de pila, su creación y eliminación son muy rápidas y económicas, lo que permite manejar millones de goroutines sin una carga excesiva. Como resultado, las Goroutines pueden minimizar la intervención del kernel del sistema operativo gracias al planificador de tiempo de ejecución.

Rendimiento óptimo (performance)

En primer lugar, como se explicó anteriormente, Goroutine requiere menos intervención del kernel del sistema operativo, lo que hace que el cambio de contexto a nivel de usuario sea menos costoso que a nivel de hilo del sistema operativo, permitiendo un cambio rápido de tareas.

Además, utiliza el modelo M:N para asignar y gestionar hilos del sistema operativo. Al crear un pool de hilos del sistema operativo, es posible procesar con menos hilos sin necesidad de muchos. Por ejemplo, si un Goroutine entra en un estado de espera, como una llamada al sistema, el tiempo de ejecución de Go ejecutará otra Goroutine en el hilo del sistema operativo, asegurando que el hilo del sistema operativo no permanezca inactivo y utilice la CPU de manera eficiente para un procesamiento rápido.

Esto permite que Golang logre un alto rendimiento en operaciones de I/O en comparación con otros lenguajes.

Concisas (concise)

Una gran ventaja es la facilidad con la que se puede manejar una función con la palabra clave go cuando se requiere concurrencia.

Es necesario utilizar Locks complejos como Mutex y Semaphore, y al usarlos, es inevitable considerar el estado de DeadLock, lo que requiere fases de diseño complejas desde la etapa previa al desarrollo.

Goroutine, siguiendo la filosofía de "no te comuniques compartiendo memoria, comparte memoria comunicándote", recomienda la transmisión de datos a través de Channel y SELECT se combina con Channel para permitir el procesamiento desde el canal con datos listos. Además, al usar sync.WaitGroup, se puede esperar fácilmente a que todas las goroutines terminen, lo que simplifica la gestión del flujo de trabajo. Gracias a estas herramientas, se previene el problema de la competencia de datos entre hilos y se logra un procesamiento concurrente más seguro.

Asimismo, al utilizar un context, es posible controlar el ciclo de vida, la cancelación, los tiempos de espera, los plazos y los alcances de la solicitud a nivel de usuario, lo que garantiza cierto grado de estabilidad.

Operaciones paralelas de Goroutine (GOMAXPROCS)

Aunque se ha destacado la concurrencia de goroutine, es posible que se pregunte si no admite el paralelismo. Esto se debe a que el número de núcleos de CPU actuales supera con creces los de antaño, e incluso las PC domésticas cuentan con un número considerable de núcleos.

Sin embargo, Goroutine también realiza tareas paralelas, y esto es GOMAXPROCS.

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

  1. Antes de la versión 1.5: El valor predeterminado era 1; era obligatorio configurarlo de manera explícita, como runtime.GOMAXPOCS(runtime.NumCPU()), si se necesitaba más de 1.

  2. De la versión 1.5 a la 1.24: Se cambió al número de núcleos lógicos disponibles. A partir de este momento, los desarrolladores no necesitan configurarlo a menos que sea estrictamente necesario.

  3. Versión 1.25: Fiel a su reputación como lenguaje popular en entornos de contenedores, verifica cGroup en Linux para determinar las restricciones de CPU configuradas para el contenedor.

    Por lo tanto, si el número de núcleos lógicos es 10 y el límite de CPU es 5, GOMAXPROCS se establecerá en el número más bajo, 5.

La modificación de la versión 1.25 representa un cambio muy significativo. Esto se debe a que la utilidad del lenguaje en entornos de contenedores ha aumentado. Como resultado, se evita la creación innecesaria de hilos y el cambio de contexto, lo que previene el estrangulamiento (throttling) de la CPU.

 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() // Indica al WaitGroup que esta goroutine ha terminado.
12
13	fmt.Printf("Goroutine %d: 시작\n", name) // Imprime el inicio de la goroutine.
14	time.Sleep(10 * time.Millisecond) // Simula un retraso para la operación.
15	fmt.Printf("Goroutine %d: 시작\n", name) // Imprime el inicio de la goroutine nuevamente (posiblemente un error tipográfico, debería ser "fin").
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Utiliza solo 2 núcleos de CPU.
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount) // Añade el número de goroutines al WaitGroup.
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg) // Inicia una nueva goroutine.
26	}
27
28	fmt.Println("모든 goroutine이 끝날 때까지 대기합니다...") // Imprime un mensaje de espera.
29	wg.Wait() // Espera a que todas las goroutines terminen.
30	fmt.Println("모든 작업이 완료되었습니다.") // Imprime un mensaje de finalización.
31
32}
33

Planificador de Goroutine (Modelo M:N)

En la sección anterior, donde se mencionó que el modelo M:N se utiliza para asignar y gestionar hilos del sistema operativo, se profundiza un poco más en el modelo GMP de goroutine.

  • G (Goroutine): La unidad de trabajo más pequeña que se ejecuta en Go.
  • M (Machine): Hilo del sistema operativo (ubicación real de la tarea).
  • P (Processor): Procesador lógico gestionado por el tiempo de ejecución de Go.

P adicionalmente tiene una cola de ejecución local (Local Run Queue) y actúa como un planificador que asigna las G asignadas a M. En pocas palabras, goroutine es

El proceso de operación 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 su cola de ejecución local a M (Machine).
  3. M (Machine) devuelve el estado de la G (Goroutine): 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" trabajo de la P (Processor) local de otro para asegurar que todos los M no permanezcan inactivos.
  5. Manejo de llamadas al sistema (Blocking): Si una G (Goroutine) se bloquea durante la ejecución, M (Machine) entra en un estado de espera. En este momento, P (Processor) se separa de la M (Machine) bloqueada y se combina con otra M (Machine) para ejecutar la siguiente G (Goroutine). Esto evita el desperdicio de CPU incluso durante los tiempos de espera en operaciones de E/S.
  6. Si una G (Goroutine) monopoliza (preempted) el tiempo de ejecución durante mucho tiempo, se le da la oportunidad de ejecutarse a otras G (Goroutine).

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

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

Gracias.