Estamos a punto de cerrar el 2019, y con ello nuestro primer año de vida como comunidad Go hispanohablante. Además de muchos otros hitos, de los que podéis seguir la traza vía Twitter o vía newsletter, hemos ido publicando semana tras semana (casi sin excepción) múltiples artículos relacionados con este lenguaje de programación que tanto nos gusta.
A modo de sorpresa, y aprovechando el Día de los Santos Inocentes, estuvimos dándole vueltas a la descerebrada idea de redactar un artículo no relacionado con Go, sobre otra tecnología: por ejemplo PHP, o Elixir, o incluso BrainFuck. Sin embargo, siendo este el último artículo del año, pensamos que esto podría dejar a algunos de nuestros seguidores algo patidifusos y que incluso podría abrir el cajón de los rumores: ¿será el 2020 el año de Friends of Elixir?
Así que, dispuestos a (no) defraudar, nos decidimos por escribir sobre un tema de los que suele llamar la atención de los ajenos al lenguaje, o incluso de los que ya llevan un tiempo experimentando con él: el planificador de Go (o Go Scheduler). Sin embargo, y para disgusto de algunos, el artículo de hoy es de más bajo nivel de abstracción de lo habitual, así que no nos va a quedar otra que entrar en algo de detalle técnico.
Componentes de un ordenador
Los que seáis algo más curiosos seguro que ya os los sabéis de memoria, pero para los que no, vamos a repasar los componentes principales de un ordenador o computadora.
El procesador (o CPU)
El primero, y quizás el más importante (aunque todos son en mayor o menor medida necesarios), es el procesador, o en este caso la CPU (por las siglas en inglés de unidad central de procesamiento), que será el responsable de ejecutar nuestras aplicaciones. Es decir, sin entrar en mucho detalle, las aplicaciones que nosotros desarollamos (en Go o en cualquier otro lenguaje), serán traducidas (sin entrar a diferenciar los lenguajes compilados de los interpretados) a un lenguaje que este componente entiende.
Sin embargo, si pensáis en cualquiera de las aplicaciones que habéis desarrollado alguna vez, seguro que os daréis cuenta de que con un procesador no tenemos suficiente, pues nuestros programas normalmente se comunican vía red, leen y escriben datos en un dispositivo de almacenamiento (disco duro), dibujan cosas en la pantalla y recogen la entrada del usuario vía periféricos tipo ratón o teclado, entre otros. Es ahí, entonces, dónde entran en juego los siguientes componentes.
Dispositivos de entrada y salida de datos
Si entráramos en detalle, nos daríamos cuenta de que los siguientes dispositivos son muy diferentes:
- Monitor o pantalla integrada.
- Ratón o teclado.
- Impresora.
- Tarjeta de red.
- Disco duro.
Sin embargo, para el propósito de éste artículo, trataremos al conjunto de todos estos dispositivos como el mismo tipo de componente, al final serán los componentes a los que podremos mandar datos y de los que podremos recibir datos.
Memoria principal o memoria RAM
Finalmente, este será el componente en el que se almacenarán todos los datos usados por el procesador. Como ya podéis imaginar, si necesitamos trabajar con datos que están almacenados en el disco o que están al otro lado de la red (en un servidor remoto, por ejemplo), lo lógico será recuperar esos datos y, una vez obtenidos, almacenarlos en un lugar cercano y accesible por el procesador, eso será en la memoria principal. Componente que, a su turno, no dejará de ser un componente más de entrada y salida de datos, pero con esa resaltable particularidad.
El sistema operativo y su planificador
Ahora que ya hemos definido, grosso modo, los componentes principales de nuestro ordenador, ya podemos imaginar cómo estos interoperan entre sí para formar lo que comúnmente denominados un ordenador o computador, ya sea el sobremesa de nuestra casa, el portátil del trabajo, el móvil o el ordenador a bordo que llevan los coches modernos.
Esto, por supuesto, obviando muchos niveles de detalle así como otros componentes de propósito específico (como las tarjetas gráficas, por ejemplo). Así pues, ahora ya podemos imaginar, siguiendo el código de nuestras aplicaciones línea a línea, cómo éstas son ejecutadas:
- Todas las líneas lógicas (condicionales, bucles, etc) o de cálculo (operaciones matemáticas) las llevará a cabo la CPU.
- Todas las líneas que hagan referencia a variables que contengan datos, mayormente harán referencia a datos almacenados en la memoria principal.
- Finalmente, en todas aquellas líneas que representen una comunicación con un componente de entrada salida de datos (petición HTTP, leer/escribir de/en un fichero, etc), lo que sucederá será que nuestra CPU, al interpretar ese tipo de línea, se pondrá en contacto con el componente en cuestión y se quedará a la espera (sin trabajar), a que este responda.
Multiproceso
Lo que hemos analizado hasta ahora ha sido cómo un ordenador interpreta una de las aplicaciones que hemos desarrollado. Sin embargo, eso nunca sucede de forma aislada. Y no, por mucho que algunos lo estéis pensando, un solo procesador no implica un solo proceso.
De hecho, eso es tan fácil de imaginar como hacer un poco de memoria y recordar los primeros smartphones, cuya capacidad de proceso era de un único procesador pero sin embargo eran capaces de realizar múltiples tareas al mismo tiempo, o esa es la impresión que nos daban.
Para lograr ese efecto, nace el concepto de concurrencia, que básicamente podríamos definir como:
La capacidad de diferentes partes o unidades de un programa, algoritmo o problema para ejecutarse fuera de orden o en orden parcial, sin afectar el resultado final.
Entendiendo, en este caso, el sistema operativo como un programa formado por las distintas unidades subyacentes (cada uno de los procesos en marcha).
Entonces, que logremos ejecutar varios procesos de forma concurrente es posible gracias a la tarea que lleva a cabo el sistema operativo (Android, Windows, Linux, MacOS, etc), que no solo supone una abstracción de los componentes anteriormente descritos sino que además se encarga de realizar las gestiones necesarias que nos permiten ejecutar múltiples procesos de forma simultánea con un solo procesador.
El planificador del SO
Para lograrlo, el sistema operativo creará lo que comúnmente denominamos como proceso para cada una de las aplicaciones que ejecutemos en él. De forma que, cada uno de ellos tendrá asignado un espacio de la memoria principal (para poder almacenar la información como anteriormente definimos), y un programa, es decir, un conjunto de líneas de código que la CPU será capaz de interpretar.
Entonces surge la pregunta del millón:
¿Cómo lo hacen los diferentes procesos ejecutándose en nuestro sistema operativo para compartir la única CPU de la que dispone nuestro ordenador?
Y la respuesta:
Por un lado, cada uno de los procesos podrá:
- Adquirir o solicitar la adquisición de la CPU cuándo esté listo para seguir ejecutando líneas de código (instrucciones).
- Liberar la CPU cuándo esté esperando la respuesta de un componente de entrada y/o salida de datos.
Por otro lado, una pieza del sistema operativo (conocida como el planificador del sistema operativo) será el encargado de gestionar (mediante diferentes algoritmos de planificación) esas peticiones de los diferentes procesos y de asignarles (o desasignarles) la CPU en cada caso, de forma que todos los procesos se puedan ir ejecutando de forma concurrente, generando esa sensación de que todo ocurre de manera simultánea. Aunque, en términos estrictos de cómputo, nunca se ejecutarán dos líneas de código en paralelo.
Sistema Operativo vs Go Runtime
Hasta ahora hemos hablado de computadoras, sistemas operativos y procesos, sin embargo, estamos aquí para hablar de Go y de sus gorrutinas, así que, veamos qué relación tienen estas con todo esto.
Cuándo hablamos de concurrencia en Go, solemos decir que las gorrutinas son muy ligeras (seguro que más de uno de vosotros lo ha leído alguna vez), pero ¿qué significa eso exactamente?
Pues, con el contexto que hemos adquirido hasta ahora ya debemos ser capaces de dar una explicación a ese aspecto.
En general, cuándo en el contexto de un lenguaje de programación de propósito general (C++, Java, C#, etc) hablamos de concurrencia, a lo que nos referimos es a la creación de múltiples procesos (o threads) dentro de nuestros programas. De forma qué, manteniendo algunas diferencias que no vamos a comentar ahora, lo que tendremos serán varios procesos de nuestro sistema operativo, cada uno con su espacio de memoria (compartido o no), con sus líneas de código a ejecutar, y con el poder de adquirir o liberar una CPU.
Sin embargo, no sucederá lo mismo cuándo creemos una nueva gorrutina en Go, pues éstas no son exactamente equivalentes a un proceso del sistema operativo. Veámoslo más en detalle.
Los modelos de threading
De hecho, cuándo hablamos de la creación de threads (entendiéndolo por la creción de nuevos procesos o rutinas dentro de nuestros programas), tenemos varias estrategias posibles:
-
Emparejamiento 1-a-1, lo que nos permite aprovechar la concurrencia de nuestro sistema operativo, y más especialmente el paralelismo cuándo disponemos de más de una CPU. Sin embargo, la creación de muchos threads implicará un sobrecoste muy penalizante, especialmente cuándo la proporción de threads por CPU esté muy dispar.
-
Emparejamiento 1-a-N, lo que nos permite crear muchos threads sin preocuparnos excesivamente por el sobrecoste de los mismos, pues en realidad los múltiples threads en realidad solo se representarán en un único proceso del sistema operativo.
En este caso, lo que tendremos será una especie de programa padre (runtime) que ejecutará los programas que nosotros hemos desarrollado, encapsulando esa gestión de los diferentes threads (vamos a decir virtuales), de un modo similar a como lo hace el sistema operativo, pero realmente usando un único proceso del sistema operativo, y consiguientemente una sola CPU en cada momento. Por lo tanto, este tipo de emparejamiento no nos permitirá explotar el potencial de las máquinas multicore (con múltiples procesadores), pues, en realidad, el trabajo nunca se podrá paralelizar entre dos CPUs.
-
Emparejamiento M-a-N, lo que nos permitirá explorar lo mejor de ambos mundos. Reduciendo el sobrecoste de la creación de múltiples threads o rutinas (cada una de ellas no tiene porqué implicar un nuevo proceso del sistema operativo), a la que vez que no nos ponemos límite a la hora de aprovechar las múltiples CPUs de nuestro ordenador.
En este caso, también existirá ese runtime encargado de hacer dicha gestión y de hacer el mapeo de cada thread/rutina (“virtual”) con una CPU. Como podéis imaginar, este es el enfoque que implementa el runtime de Go. De modo que, a la pieza encargada de distribuir las diferentes gorrutinas (“threads virtuales”) entre los diferentes procesos del sistema operativo es a lo que llamamos el planificador de Go (Go Scheduler), por su función análoga a la del planificador del sistema operativo.
Go Scheduler
Bien, ahora que ya sabemos porqué las gorrutinas son más ligeras que los threads comunes en otros lenguajes de programación, y que ya sabemos cuál es el papel que desempeña el Go Scheduler (y el runtime de Go) en toda esta gestión, veamos con un poco más de detalle cómo funciona todo esto.
Lo primero, la nomenclatura básica:
- G, para hacer referencia a las gorrutinas.
- M, para hacer referencia a los procesos del sistema operativo.
- P, para hacer referencia a los procesadores.
Dicho esto, y siguiendo el modelo de threading que explicamos anteriormente (M-a-N), sabemos que:
En una ejecución de una aplicación Go, las M gorrutinas (G) deben planificarse (y mapearse) en N procesos del sistema operativo (M) que se ejecutan sobre GOMAXPROCS procesadores (P).
Dónde GOMAXPROCS
hace referencia a la variable que podemos definir mediante el método con el mismo nombre.
Entonces, las gorrutinas se distribuyen entre:
- Una cola de gorrutinas local específica para cada uno de los procesadores (P).
- Una cola de gorrutinas global.
De forma que la visión global del planificador queda representada del siguiente modo:
Y cuyo algoritmo de planificación podemos encontrar en el propio código fuente:
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}
De forma que cada vez que se encuentra una gorrutina (G) que se pueda ejecutar, se ejecutará hasta que quede bloqueada de nuevo.
Stealing
Eso es, cuando se crea una nueva gorrutina o cuando una ya existente se vuelve a poder ejecutar, esta se inserta en una de las colas locales. Entonces, cuando un procesador P termina de ejecutar la gorrutina en curso, intenta extraer una nueva gorrutina de su propia cola de gorrutinas ejecutables.
Entonces, si la cola está vacía, entonces P elige otro procesador aleatorio (P2) e intenta robar la mitad de las gorrutinas ejecutables de su cola. Este es el comportamiento que comúnmente se conoce como stealing y que busca mejorar el rendimiento del planificador de Go.
Por ejemplo, en el caso anterior, P2 no tiene gorrutinas ejecutables en su cola. Por lo tanto, elige aleatoriamente otro procesador (P1) y roba tres gorrutinas de cola local. Ahora, P2 podrá ejecutar estas gorrutinas, de modo que el trabajo del planificador se distribuirá de manera más equitativa entre los múltiples procesadores.
Spinning threads
Como hemos visto, el planificador de Go intenta distribuir las gorrutinas tanto como puede, hasta el punto de implementar esa estrategia de “robo” entre procesadores. Sin embargo, y en contraposición, el planificador de Go tiene que evitar penalizar el rendimiento en el caso de ejecutar programas de alto coste computacional (esos que pocas veces tienen que parar para esperar a dispositivos de entrada y salida de datos). Para los que, una excesiva tasa de intercambios de gorrutinas entre procesos del sistema operativo, puede ser crítico.
Para ello, el planificador de Go implementa lo que llamamos spinning threads, que básicamente son procesos que consumen un poco más de potencia de CPU, pero minimizan la preferencia de los subprocesos del sistema operativo. Un thread hará spinning (consumir ciclos de CPU para no perder la preferencia) si:
- Un proceso del sistema operativo (M) tiene un procesador asignado (P) y está buscando una gorrutina (G) ejecutable.
- Un proceso del sistema operativo (M) no tiene procesador asignado.
- Cuando el planificador está preparando una gorrutina si hay un procesador asignado (P) inactivo y no hay otros spinning threads.
En la mayoría de los casos, los GOMAXPROCS
procesadores hacen spinning de los procesos del sistema operativo.
Cuando un spinning thread encuentra trabajo, deja de hacer spinning.
Por último, los procesos del sistema operativo inactivos (M) con un procesador asignado (P) no se bloquean si hay otros procesos inactivos sin asignación. Entonces, cuando se crean nuevas gorrutinas o se bloquea un proceso del sistema operativo, el planificador asegura que haya al menos un spinning thread y evita el bloqueo / desbloqueo excesivo de procesos.
Así que, como podéis ver, el Go Scheduler intenta evitar al máximo, por un lado, la preferencia excesiva de los procesos del sistema operativo así como, por otro lado, implementa spinning threads para evitar la alta ocurrencia de transiciones entre gorrutinas. Es decir, en definitiva, busca un equilibrio entre ambas estrategias.
Finalmente, especialmente aquellos más curiosos, lo mejor que podéis hacer es no creer todo lo que os hemos explicado en este artículo y que aprovechar que Go es un lenguaje de programación de código abierto para revisar la implementación del Go Scheduler con vuestros propios ojos o para analizar el documento dónde se describe el diseño de dicho planificador, dónde se entra en más detalle en las diferencias entre un thread común y una gorrutina (el sobrecoste que implica cada uno de los dos).
Y ya para terminar, una sencilla pregunta:
¿Ya sabíais la razón por la que las gorrutinas son tan ligeras?
Como hemos visto, ahora ya podéis empezar a crearlas sin miedo, sin embargo, es importante recordar que un potencial problema de todo este embrollo son las “gorrutine leaks” (fugas de gorrutinas, es decir, cuándo perdemos el control sobre ellas), tema que trataremos en otro artículo, que ya dejamos para el próximo año.
Además, más adelante también veremos el uso del goroutine tracer (go tool trace
),
herramienta que nos permitirá tener una visión global de cómo se produce la distribución y la planificación de las gorrutinas
en las ejecuciones de nuestras aplicaciones.
Ya sabéis, todos los comentarios, feedback o si os ha quedado alguna duda por resolver, será bienvenido en la sección de comentarios del artículo o en nuestro Twitter @FriendsofGoTech.