ProgramaciónDesarrollador Backend

¿Cómo funcionan las goroutines y el planificador de Go, y por qué es importante gestionar correctamente la ejecución concurrente de tareas?

Supere entrevistas con el asistente de IA Hintsage

Respuesta.

Las goroutines son hilos de ejecución ligeros, incorporados en la arquitectura de Go desde sus primeras versiones para lograr una concurrencia eficiente. Históricamente, la idea del hilo ligero surgió como un intento de eludir el costo de los hilos del sistema, así como por la alta demanda de aplicaciones de servidor escalables. Go fue diseñado originalmente como un lenguaje para sistemas de servidor y redes, donde millones de tareas deben ser procesadas en paralelo.

Problema: La concurrencia puede llevar rápidamente a condiciones de carrera, bloqueos y un aumento en el consumo de memoria si no se controla el ciclo de vida de las goroutines, no se considera su planificación y no se gestiona su finalización.

Solución: Las goroutines se inician a través de la palabra clave go. El trabajo de las goroutines es planeado por el planificador de Go, que utiliza el modelo M:N (M hilos del sistema operativo atienden N goroutines del lenguaje Go). Para gestionar el ciclo de vida se utilizan canales, WaitGroup, context y control de cierre de canales.

Ejemplo de código:

package main import ("fmt"; "time") func worker(id int) { fmt.Printf("Trabajador %d iniciado ", id) time.Sleep(time.Second) fmt.Printf("Trabajador %d terminado ", id) } func main() { for i := 1; i <= 3; i++ { go worker(i) } time.Sleep(2 * time.Second) }

Características clave:

  • Creación instantánea y barata de goroutines (decenas de miles de veces más barato que un hilo del sistema operativo).
  • Interacción directa a través de canales, asegurando sincronización e intercambio de datos.
  • Necesidad de control manual sobre la finalización del trabajo (qué goroutines están en espera, quién las interrumpe, cómo se señala la detención).

Preguntas capciosas.

Si no se espera explícitamente a una goroutine en main, ¿siempre se ejecutará?

No, la ejecución de main se completa: el proceso terminará independientemente del estado de las goroutines hijas, y no todas las tareas se completarán.

¿Es el lanzamiento de go func(...) desde un bucle una garantía de que cada goroutine obtendrá su propio valor de las variables del bucle?

No, surge el problema de captura de la variable del bucle, las goroutines pueden trabajar con el mismo valor de un slice/variable. Se debe utilizar la copia de la variable, por ejemplo, pasándola como argumento:

for i := 0; i < 3; i++ { go func(n int) { fmt.Println(n) }(i) }

¿Puede una goroutine bloquear el planificador de Go y evitar que otras se ejecuten?

Sí, si inicia un ciclo infinito o muy pesado sin puntos de cambio (por ejemplo, sin llamadas de función que esperen o yield), puede mantener el hilo del sistema operativo — aunque esto contraviene la ideología de Go sobre "multitarea cooperativa". Por ejemplo, una función pesada sin bloqueos:

func busy() { for { // No hay esperas ni llamadas bloqueantes } }

Errores comunes y antipatrón

  • Lanzar goroutines sin controlar su finalización.
  • Captura de variables del bucle sin pasarlas dentro de funciones anónimas.
  • Sobrecarga del sistema debido a "goroutines con fugas" (fugas de goroutines que no finalizan).
  • Ignorar errores de sincronización al intercambiar a través de canales.

Ejemplo de la vida real

Caso negativo

En un microservicio se lanza periódicamente una goroutine para leer de la base de datos, pero se olvida de finalizarla al cancelar la solicitud. Como resultado, quedan goroutines "colgadas" que con el tiempo llevan al consumo de toda la memoria RAM.

Pros:

  • Alta velocidad de inicio.
  • Facilidad de escalado.

Contras:

  • Fuga de memoria.
  • Aumento del tiempo de respuesta.
  • Finalización impredecible.

Caso positivo

Se utiliza context para controlar la cancelación de tareas, WaitGroup para gestionar la finalización de todas las goroutines antes de detener la aplicación, y canales para la correcta transmisión de datos entre los ejecutores.

Pros:

  • Ciclo de vida predecible.
  • Gestión de finalización.
  • Fácil de escalar.

Contras:

  • Es necesario escribir explícitamente la lógica de cancelación y sincronización.
  • Arquitectura del programa un poco más compleja.