Planificación en Go: Parte II - El programador de Go

Hola Habr! Esta es la segunda publicación de una serie de tres partes, que dará una idea de la mecánica y la semántica del trabajo del planificador en Go. Esta publicación es sobre el planificador Go.

En la primera parte de esta serie, expliqué aspectos del programador del sistema operativo que, en mi opinión, son importantes para comprender y evaluar la semántica del programador Go. En esta publicación, explicaré a nivel semántico cómo funciona el planificador Go. El Go Scheduler es un sistema complejo y los pequeños detalles mecánicos no son importantes. Es importante tener un buen modelo de cómo funciona y se comporta todo. Esto le permitirá tomar las mejores decisiones de ingeniería.

Tu programa está comenzando


Cuando se inicia su programa Go, se le asigna un procesador lógico (P) para cada núcleo virtual definido en la máquina host. Si tiene un procesador con varios hilos de hardware por núcleo físico (Hyper-Threading), cada hilo de hardware se presentará a su programa como un núcleo virtual. Para comprender mejor esto, eche un vistazo al informe del sistema para mi MacBook Pro.

imagen

Puedes ver que tengo un procesador con 4 núcleos físicos. Este informe no revela la cantidad de subprocesos de hardware por núcleo físico. El procesador Intel Core i7 tiene tecnología Hyper-Threading, lo que significa que el núcleo físico tiene 2 hilos de hardware. Esto le dice a Go que hay 8 núcleos virtuales disponibles para ejecutar subprocesos del sistema operativo en paralelo. Para verificar esto, considere el siguiente programa:

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

Cuando ejecuto este programa en mi computadora, el resultado de llamar a la función NumCPU () será 8. Cualquier programa Go que ejecute en mi computadora obtendrá 8 (P).
A cada P se le asigna un flujo del sistema operativo ( M ). El sistema operativo todavía administra este subproceso y el sistema operativo sigue siendo responsable de colocar el subproceso en el núcleo para su ejecución. Esto significa que cuando ejecuto Go en mi computadora, tengo 8 hilos disponibles para hacer mi trabajo, cada uno vinculado individualmente a P.

Cada programa Go también recibe una Goroutine inicial ( G) Goroutine es esencialmente Coroutine, pero es Go, por lo que reemplazamos la letra C con G y obtenemos la palabra Goroutine. Puede pensar en Goroutines como subprocesos a nivel de aplicación, y se parecen mucho a los subprocesos del sistema operativo. Del mismo modo que el kernel activa y desactiva los hilos del sistema operativo, el contexto activa y desactiva los programas contextuales.

El último rompecabezas son las colas de ejecución. Hay dos colas de ejecución diferentes en el planificador Go: la cola de ejecución global (GRQ) y la cola de ejecución local (LRQ). A cada P se le asigna un LRQ que controla las goroutinas asignadas para ejecutarse en el contexto de P. Estas goroutinas se encienden y apagan desde el contexto M asignado a esta P. GRQ es para goroutinas que no se han asignado a P. Hay un proceso para mover las goroutinas de GRQ a LRQ, que discutiremos más adelante.

La imagen muestra todos estos componentes juntos.

imagen

Planificador cooperativo


Como dijimos en la primera publicación, el planificador del sistema operativo es un planificador preventivo. Esencialmente, esto significa que no puede predecir lo que hará el planificador en un momento dado. El núcleo toma decisiones y todo no es determinista. Las aplicaciones que se ejecutan en la parte superior del sistema operativo no controlan lo que sucede dentro del núcleo con la programación a menos que usen primitivas de sincronización, como instrucciones atómicas y llamadas mutex.

Go Scheduler es parte de Go Runtime, y Go Runtime está integrado en su aplicación. Esto significa que el planificador Go funciona en el espacio del usuario en el núcleo. La implementación actual del planificador Go no es preventiva, sino un planificador interactivo. Ser un planificador cooperativo significa que el planificador necesita eventos claramente definidos en el espacio del usuario que ocurran en puntos seguros del código para tomar decisiones de planificación.

Lo bueno del planificador colaborativo de Go es que se ve y se siente proactivo. No puede predecir qué va a hacer el planificador Go. Esto se debe al hecho de que la toma de decisiones para este planificador no depende de los desarrolladores, sino del tiempo de ejecución de Go. Es importante pensar en el planificador Go como un planificador proactivo, y dado que el planificador no es determinista, no es demasiado difícil.

Estados Gorutin


Al igual que las corrientes, las goroutinas tienen los mismos tres estados de alto nivel. Determinan el papel que juega el planificador Go con cualquier rutina. Goroutin puede estar en uno de tres estados: en espera, listo o cumpliendo.

Esperando : Esto significa que la gorutina se detiene y espera que algo continúe. Esto puede suceder por razones como la espera del sistema operativo (llamadas del sistema) o la sincronización de llamadas (operaciones atómicas y mutex). Estos tipos de retrasos son la causa principal del bajo rendimiento.

Preparación: esto significa que goroutine quiere tiempo para seguir las instrucciones asignadas. Si tienes muchas gorutinas que necesitan tiempo, las gorutinas tendrán que esperar más tiempo para tener tiempo. Además, la cantidad de tiempo individual que recibe cualquier gorutina se reduce a medida que más gorutinas compiten por el tiempo. Este tipo de retraso de programación también puede causar un bajo rendimiento.

Cumplimiento : esto significa que se ha colocado gorutina en M y está siguiendo sus instrucciones. El trabajo asociado con la aplicación se ha completado. Esto es lo que todos quieren.

Cambio de contexto


Go Scheduler requiere eventos de espacio de usuario bien definidos que ocurran en puntos seguros del código para cambiar de contexto. Estos eventos y puntos seguros aparecen en las llamadas a funciones. Las llamadas a funciones son críticas para el rendimiento del Go Scheduler. Si ejecuta bucles estrechos que no realizan llamadas a funciones, provocará demoras en el programador y la recolección de elementos no utilizados. Es imperativo que las llamadas a funciones ocurran dentro de un período de tiempo razonable.

Hay cuatro clases de eventos que ocurren en sus programas Go que le permiten al planificador tomar decisiones de planificación. Esto no significa que esto siempre sucederá en uno de estos eventos. Esto significa que el planificador tiene la oportunidad.

  • Usando la palabra clave go
  • Recolector de basura
  • Sistema de llamadas
  • Sincronización

Uso de la
palabra clave go La palabra clave go es cómo se crea goroutine. Tan pronto como se crea una nueva gorutina, le da al planificador la oportunidad de tomar una decisión de planificación.

Recolector de basura (GC)
Dado que el GC funciona con su propio conjunto de gorutinas, estas gorutinas necesitan tiempo en M para funcionar. Esto obliga al GC a crear mucho caos en la planificación. Sin embargo, el planificador es muy inteligente en lo que hace goroutine, y lo usará para tomar decisiones. Una solución razonable es cambiar el contexto a goroutine, que quiere acceder al recurso del sistema, y ​​a nadie más que a él durante la recolección de basura. Cuando el GC funciona, se toman muchas decisiones de planificación.

Sistema de llamadas
Si goroutine realiza una llamada al sistema que bloqueará M, el planificador puede cambiar el contexto a otra goroutine, a la misma M.

Sincronización
Si una llamada a una operación atómica, un mutex o un canal hace que se bloquee la goroutine, el planificador puede cambiar el contexto para iniciar una nueva goroutine. Una vez que la rutina puede funcionar nuevamente, se puede poner en cola y eventualmente volver a M.

Llamadas asincrónicas del sistema


Cuando el sistema operativo en el que se está ejecutando tiene la capacidad de procesar una llamada del sistema de forma asincrónica, lo que se llama un sondeo de red se puede utilizar para procesar la llamada del sistema de manera más eficiente. Esto se logra usando kqueue (MacOS), epoll (Linux) o iocp (Windows) en estos respectivos sistemas operativos.

Las llamadas al sistema de red pueden ser manejadas de forma asíncrona por muchos de los sistemas operativos que usamos hoy. Aquí es donde se muestra el sondeo de red, ya que su objetivo principal es procesar las operaciones de la red. Usando el sondeo de red para llamadas al sistema de red, el programador puede evitar que las rutinas bloqueen M durante estas llamadas al sistema. Esto ayuda a mantener M disponible para ejecutar otras rutinas en LRQ P sin la necesidad de crear una nueva M. Esto ayuda a reducir la carga de planificación en el sistema operativo.

La mejor manera de ver cómo funciona esto es mirar un ejemplo. La figura muestra nuestro esquema de planificación básico. Gorutin-1 se ejecuta en M, y 3 Gorutins más están esperando en LRQ para obtener su tiempo en M. El encuestador de red está inactivo y no tiene nada que hacer.

imagen

En la siguiente figura, Gorutin-1 (G1) desea realizar una llamada al sistema de red, por lo que G1 se mueve al sondeo de red y se trata como una llamada asincrónica del sistema de red. Una vez que G1 se ha movido a Network poller, M ahora está disponible para ejecutar otra rutina desde LRQ. En este caso, Gorutin-2 cambia a M.

imagen

En la siguiente figura, la llamada de red del sistema finaliza con una llamada de red asíncrona, y G1 vuelve a LRQ para P. Después de que G1 se puede volver a cambiar a M, el código asociado con Go, para el cual él responde puede ejecutar de nuevo. La gran victoria es que no se necesita más Sra. Para hacer llamadas al sistema de red. Network Poller tiene un subproceso de sistema operativo y procesa a través de un bucle de eventos.

Llamadas al sistema sincrónico


¿Qué sucede cuando goroutine quiere hacer una llamada al sistema que no se puede ejecutar de forma asincrónica? En este caso, el sondeo de red no se puede utilizar, y la rutina que realiza la llamada al sistema bloqueará M. Esto es malo, pero no hay forma de evitarlo. Un ejemplo de una llamada al sistema que no se puede realizar de forma asíncrona son las llamadas al sistema basadas en archivos. Si usa CGO, puede haber otras situaciones en las que llamar a las funciones de C también bloquea M.
El sistema operativo Windows puede realizar llamadas de sistema asincrónicas basadas en archivos. Técnicamente, cuando trabajas en Windows, puedes usar Network Poller.
Veamos qué sucede con una llamada al sistema síncrono (por ejemplo, E / S de archivo) que bloqueará M. La figura muestra nuestro diagrama de planificación básico, pero esta vez G1 realizará una llamada al sistema síncrono que bloqueará M1.

imagen

En la siguiente figura, el planificador puede determinar que G1 provocó un bloqueo M. En este punto, el planificador desconecta M1 de P con un bloqueo G1 aún conectado. Luego, el planificador introduce un nuevo M2 para servir a P. En este punto, G2 puede seleccionarse de LRQ e incluirse en el contexto M2. Si M ya existe debido a un intercambio previo, esta transición es más rápida que la necesidad de crear una nueva M.

imagen

El siguiente paso completa la llamada al sistema de bloqueo realizada por G1. En este punto, G1 puede regresar a LRQ y ser atendido nuevamente por P. M1 luego se deja de lado para uso futuro si este escenario se repite.

imagen

Robar trabajo


Otro aspecto del planificador es que es un planificador de robos de rutina. Esto ayuda en varias áreas para apoyar una planificación efectiva. En primer lugar, lo último que necesita es que M pase al estado de espera, porque tan pronto como esto suceda, el sistema operativo cambiará M del núcleo usando el contexto. Esto significa que P no puede hacer ningún trabajo, incluso si hay una Goroutine en buen estado, hasta que M vuelva al kernel. Gorutin Theft también ayuda a equilibrar los intervalos de tiempo entre todos los Ps para que el trabajo se distribuya mejor y se realice de manera más eficiente.

En la figura, tenemos un programa Go multiproceso con dos Ps que sirven cuatro G cada una y una G en GRQ. ¿Qué sucede si uno de P sirve rápidamente a todo su G?

imagen

Además, P1 ya no tiene gorutinas para ejecutar. Pero hay gorutinas en condiciones de trabajo, tanto en LRQ para P2 como en GRQ. Este es el momento en que P1 necesita robar gorutina.

imagen

Las reglas para robar goroutines son las siguientes. Todo el código se puede ver en las fuentes de tiempo de ejecución.

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.
}

Por lo tanto, con base en estas reglas, P1 debe verificar la presencia de gorutinas en su LRQ y tomar la mitad de lo que encuentra.

imagen

¿Qué sucede si P2 termina de servir todos sus programas y a P1 no le queda nada en LRQ?

P2 ha completado todo su trabajo y ahora debe robar las gorutinas. Primero, mirará el LRQ P1, pero no encontrará Goroutines. A continuación, mirará a GRQ. Allí encontrará el G9.

imagen

P2 roba G9 de GRQ y comienza a hacer el trabajo. Lo bueno de todo este robo es que le permite a M mantenerse ocupado y no estar inactivo.

imagen

Ejemplo práctico


Con la mecánica y la semántica, quiero mostrarles cómo se combina todo esto para que el planificador Go pueda hacer más trabajo con el tiempo. Imagine una aplicación multiproceso escrita en C, en la que el programa administra dos subprocesos del sistema operativo que se envían mensajes entre sí. Hay 2 hilos en la imagen que envían el mensaje de un lado a otro. El hilo 1 recibe el núcleo 1 con cambio de contexto y ahora se está ejecutando, lo que permite que el hilo 1 envíe su mensaje al hilo 2.

imagen

Además, cuando el hilo 1 termina de enviar el mensaje, ahora debe esperar una respuesta. Esto hará que el hilo 1 se desconecte del contexto del núcleo 1 y se ponga en estado de espera. Tan pronto como el hilo 2 recibe una notificación de mensaje, pasa a un estado saludable. Ahora el sistema operativo puede realizar un cambio de contexto y ejecutar el hilo 2 en el núcleo, que resulta ser el núcleo 2. Luego, el hilo 2 procesa el mensaje y envía un nuevo mensaje al hilo 1.

imagen

Luego, la secuencia vuelve al contexto cuando el mensaje 1 de la secuencia 2 es recibido por la secuencia 1. Ahora, la secuencia 2 cambia del estado de ejecución al estado de espera, y la secuencia 1 cambia del estado de espera al estado listo y finalmente regresa al estado de ejecución, lo que le permite procesar y enviar un nuevo mensaje de vuelta. Todos estos cambios de contexto y cambios de estado tardan en completarse, lo que limita la velocidad del trabajo. Dado que cada cambio de contexto implica un retraso de ~ 1000 nanosegundos, y esperamos que el hardware ejecute 12 instrucciones por nanosegundo, observará 12,000 instrucciones que no se ejecutan más o menos durante estos cambios de contexto. Dado que estos flujos también se cruzan entre diferentes núcleos,La probabilidad de un retraso adicional de pérdidas de línea de caché también es alta.

imagen

En la figura hay dos gorutins que están en armonía unos con otros, pasando el mensaje de un lado a otro. G1 obtiene el cambio de contexto M1, que se ejecuta en Core 1, que le permite a G1 hacer su trabajo.

imagen

Además, cuando G1 termina de enviar el mensaje, ahora necesita esperar una respuesta. Esto hará que G1 se desconecte del contexto M1 y se ponga en estado inactivo. Tan pronto como G2 es notificado del mensaje, pasa a un estado saludable. Ahora el planificador Go puede realizar cambios de contexto y ejecutar G2 en M1, que aún se ejecuta en Core 1. Luego, G2 procesa el mensaje y envía un nuevo mensaje a G1.

imagen

En el siguiente paso, todo cambia nuevamente cuando el mensaje enviado por G2 es recibido por G1. Ahora, el contexto G2 cambia del estado de ejecución al estado de espera, y el contexto G1 cambia del estado de espera al estado de ejecución y finalmente regresa al estado de ejecución, lo que le permite procesar y enviar un nuevo mensaje.

imagen

Las cosas en la superficie no parecen ser diferentes. Todos los mismos cambios de contexto y cambios de estado ocurren independientemente de si usa Streams o Goroutines. Sin embargo, existe una gran diferencia entre el uso de Streams y Gorutin, que puede no ser obvio a primera vista.

Si se usa goroutine, se utilizan los mismos hilos y kernel del sistema operativo para todo el procesamiento. Esto significa que desde el punto de vista del sistema operativo, OS Flow nunca pasa a un estado de espera; Nunca. Como resultado, todas esas instrucciones que perdimos al cambiar de contexto al usar flujos no se pierden al usar goroutin.

Esencialmente, Go convirtió el trabajo de IO / Bloqueo en un trabajo vinculado al procesador a nivel del sistema operativo. Dado que todo el cambio de contexto se produce a nivel de aplicación, no perdemos las mismas ~ 12 mil instrucciones (en promedio) sobre el cambio de contexto que perdimos al usar secuencias. En Go, los mismos cambios de contexto le cuestan ~ 200 nanosegundos o ~ 2.4 mil comandos. El planificador también ayuda a mejorar el rendimiento de las cadenas de almacenamiento en caché y NUMA. Es por eso que no necesitamos más hilos de los que tenemos núcleos virtuales. Go puede hacer más trabajo con el tiempo porque el programador Go intenta usar menos hilos y hacer más en cada hilo, lo que ayuda a reducir la carga en el sistema operativo y el hardware.

Conclusión


Go Scheduler es realmente sorprendente en cuanto a cómo tiene en cuenta las complejidades del sistema operativo y el hardware. La capacidad de convertir la operación de E / S / bloqueo en una operación vinculada al procesador a nivel del sistema operativo es donde obtenemos grandes ganancias al usar más potencia del procesador con el tiempo. Es por eso que no necesita más subprocesos del sistema operativo que los núcleos virtuales. Puede esperar razonablemente que todo su trabajo se realizará (con enlace de CPU y E / S / bloqueos) con un hilo del sistema operativo por núcleo virtual. Esto es posible para aplicaciones de red y otras aplicaciones que no necesitan llamadas al sistema que bloqueen los hilos del sistema operativo.

Como desarrollador, aún debe comprender lo que hace su aplicación en términos de tipo de trabajo. No puede crear una cantidad ilimitada de goroutines y esperar un rendimiento increíble. Menos es siempre más, pero con una comprensión de esta semántica del planificador Go, puede tomar mejores decisiones de ingeniería.

All Articles