GO Scheduler: ¿Ahora no es cooperativo?

Si leyó las notas de la versión GO 1.14, probablemente notó algunos cambios bastante interesantes en el tiempo de ejecución del lenguaje. Así que estaba muy interesado en el ítem: "Las goroutinas ahora son asincrónicamente preventivas". Resulta que el planificador GO (planificador) ahora no es cooperativo? Bueno, después de leer la propuesta correspondiente en diagonal, la curiosidad quedó satisfecha.

Sin embargo, después de un tiempo decidí investigar las innovaciones con más detalle. Me gustaría compartir los resultados de estos estudios.

imagen

Requisitos del sistema


Las cosas que se describen a continuación requieren del lector, además del conocimiento del lenguaje GO, conocimiento adicional, a saber:

  • comprensión de los principios del planificador (aunque trataré de explicar a continuación, "en los dedos")
  • entender cómo funciona el recolector de basura
  • entendiendo qué es el ensamblador GO

Al final, dejaré un par de enlaces que, en mi opinión, cubren bien estos temas.

Brevemente sobre el planificador


Primero, permíteme recordarte qué es la multitarea cooperativa y no cooperativa.

Con la multitarea no cooperativa (desplazamiento), todos estamos familiarizados con el ejemplo del planificador del sistema operativo. Este programador funciona en segundo plano, descarga subprocesos basados ​​en varias heurísticas y, en lugar del tiempo de CPU descargado, comienzan a recibir otros subprocesos.

El planificador cooperativo se caracteriza por un comportamiento diferente: duerme hasta que una de las gorutinas lo despierta claramente con un toque de disposición para darle su lugar a otro. El planificador decidirá por sí mismo si es necesario eliminar la gorutina actual del contexto y, de ser así, a quién colocar en su lugar. Así funcionaba el planificador GO.

Además, consideramos las piedras angulares con las que opera el planificador:

  • P - procesadores lógicos (podemos cambiar su número con el tiempo de ejecución. Función GOMAXPROCS), en cada procesador lógico se puede ejecutar una rutina de forma independiente a la vez.
  • M: subprocesos del sistema operativo. Cada P se ejecuta en un subproceso de M. Tenga en cuenta que P no siempre es igual a M, por ejemplo, un subproceso puede ser bloqueado por syscall y luego se asignará otro subproceso para su P. Y hay CGO y otros y otros matices.
  • G - gorutinas. Bueno, aquí está claro, G debe ejecutarse en cada P y el planificador lo monitorea.

Y lo último que necesita saber, ¿y cuándo llama realmente el planificador goroutine? Es simple, por lo general, el compilador inserta instrucciones para llamar al principio del cuerpo (prólogo) de la función (un poco más adelante hablaremos de esto con más detalle).

¿Y cuál es el problema en realidad?


imagen

Desde el comienzo del artículo, ya entendió que el principio del trabajo del planificador ha cambiado en GO, veamos las razones por las que se hicieron estos cambios. Echa un vistazo al código:

debajo del spoiler
func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		var u int
		for {
			u -= 2
			if u == 1 {
				break
			}
		}
	}()
	<-time.After(time.Millisecond * 5) //    main   ,         

	fmt.Println("go 1.13 has never been here")
}


Si lo compila con la versión GO <1.14, entonces la línea "go 1.13 nunca ha estado aquí" no verá en la pantalla. Esto sucede porque, tan pronto como el planificador le da tiempo al procesador a la rutina con un bucle infinito, captura completamente P, no se producen llamadas de función dentro de esta rutina, lo que significa que ya no activaremos al planificador. Y solo una llamada explícita a runtime.Gosched () permitirá que nuestro programa finalice.

Este es solo un ejemplo en el que goroutine captura P y durante mucho tiempo evita que otras goroutines se ejecuten en este P. Se pueden encontrar más opciones cuando este comportamiento causa problemas leyendo la propuesta.

Propuesta Parse


La solución a este problema es bastante simple. ¡Hagamos lo mismo que en el planificador del sistema operativo! Solo deje que GO agote la rutina de P y ponga otra allí, y para esto usaremos las herramientas del sistema operativo.

OK, ¿cómo implementar esto? Permitiremos que el tiempo de ejecución envíe una señal al flujo en el que trabaja la rutina. Registraremos el procesador de esta señal en cada flujo desde M, la tarea del procesador es determinar si la actual rutina puede ser suplantada. Si es así, guardaremos su estado actual (registros y el estado de la pila) y le daremos recursos a otro, de lo contrario, continuaremos ejecutando la rutina actual. Vale la pena señalar que el concepto con una señal es una solución para sistemas basados ​​en UNIX, mientras que, por ejemplo, la implementación para Windows es ligeramente diferente. Por cierto, SIGURG fue seleccionado como una señal para enviar.

La parte más difícil de esta implementación es determinar si la gorutina puede ser expulsada. El hecho es que algunos lugares en nuestro código deben ser atómicos, desde el punto de vista del recolector de basura. Llamamos a estos lugares puntos inseguros. Si exprimimos goroutine en el momento de la ejecución del código desde un punto inseguro, y luego GC se inicia, reemplazará el estado de nuestra goroutine, tomado en un punto inseguro, y puede hacer cosas. Echemos un vistazo más de cerca al concepto seguro / inseguro.

¿Fuiste allí, GC?


imagen

En versiones anteriores a 1.12, el tiempo de ejecución Gosched utilizaba puntos seguros en lugares donde definitivamente se podía llamar al programador sin temor a que terminara en la sección atómica del código para GC. Como ya dijimos, los datos de puntos seguros se encuentran en el prólogo de una función (pero no de cada función, eso sí). Si desarmó el ensamblador go-shn, podría objetar: no hay llamadas obvias del planificador visibles allí. Sí, lo es, pero puede encontrar la instrucción de llamada runtime.morestack allí, y si mira dentro de esta función, se encontrará una llamada del planificador. Debajo del spoiler, ocultaré el comentario de las fuentes de GO, o puedes encontrar el ensamblador para morestack tú mismo.

encontrado en la fuente
Synchronous safe-points are implemented by overloading the stack bound check in function prologues. To preempt a goroutine at the next synchronous safe-point, the runtime poisons the goroutine's stack bound to a value that will cause the next stack bound check to fail and enter the stack growth implementation, which will detect that it was actually a preemption and redirect to preemption handling.

Obviamente, al cambiar a un concepto de desplazamiento, una señal de desplazamiento puede atrapar a nuestro gorutin en cualquier lugar. ¡Pero los autores de GO decidieron no dejar puntos seguros, sino declarar puntos seguros en todas partes! Bueno, por supuesto, hay una trampa, de hecho, casi en todas partes. Como se mencionó anteriormente, hay algunos puntos inseguros en los que no forzaremos a nadie. Escribamos un punto inseguro simple.


j := &someStruct{}
p := unsafe.Pointer(j)
// unsafe-point start
u := uintptr(p)
//do some stuff here
p = unsafe.Pointer(u)
// unsafe-point end

Para entender cuál es el problema, probémosle la piel a un recolector de basura. Cada vez que vamos a trabajar, necesitamos encontrar los nodos raíz (punteros en la pila y en los registros), con los cuales comenzaremos a marcar. Como es imposible decir en tiempo de ejecución si 64 bytes en la memoria son un puntero o simplemente un número, recurrimos a la pila y registramos las tarjetas (algo de caché con metainformación), amablemente proporcionadas por el compilador GO. La información en estos mapas nos permite encontrar punteros. Entonces, nos despertaron y nos enviaron a trabajar cuando GO realizó la línea número 4. Al llegar al lugar y mirar las cartas, descubrimos que estaba vacío (y esto es cierto, porque uintptr desde el punto de vista de GC es un número y no un puntero). Bueno, ayer nos enteramos de la asignación de memoria para j, ya que ahora no podemos acceder a esta memoria, tenemos que limpiarla, y después de eliminar la memoria nos vamos a dormir.¿Que sigue? Bueno, las autoridades se despertaron, por la noche, gritando, bueno, tú mismo entendiste.

Todo eso es teoría, propongo considerar en la práctica cómo funcionan todas estas señales, puntos inseguros y tarjetas de registro y pilas.

Pasemos a practicar


Ejecuté dos veces (vaya a 1.14 y vaya a 1.13) un ejemplo del principio del artículo del perfilador perf para ver qué llamadas al sistema están sucediendo y compararlas. La llamada al sistema requerida en la versión 14 se encontró con bastante rapidez:

15.652 ( 0.003 ms): main/29614 tgkill(tgid: 29613 (main), pid: 29613 (main), sig: URG                ) = 0

Bueno, obviamente el tiempo de ejecución envió a SIGURG al hilo en el que gira la rutina. Tomando este conocimiento como punto de partida, fui a ver los commits en GO para encontrar dónde y por qué se envía esta señal, y también para encontrar el lugar donde está instalado el controlador de señal. Comencemos con el envío, encontraremos la función de envío de señal en runtime / os_linux.go


func signalM(mp *m, sig int) {
	tgkill(getpid(), int(mp.procid), sig)
}

Ahora encontramos lugares en el código de tiempo de ejecución, desde donde enviamos la señal:

  1. cuando goroutine se suspende, si está en estado de ejecución. La solicitud de suspensión proviene del recolector de basura. Aquí, quizás, no agregaré código, pero se puede encontrar en el archivo runtime / preempt.go (suspendG)
  2. si el programador decide que goroutine funciona demasiado tiempo, runtime / proc.go (retomar)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS: constante igual a 10 ms, pd.schedwhen: hora en que se llamó por última vez al programador de la secuencia pd
  3. Además de todas las transmisiones, esta señal se envía durante un pánico, StopTheWorld (GC) y algunos casos más (que tengo que omitir, porque el tamaño del artículo ya irá más allá de los límites)

Descubrimos cómo y cuándo el tiempo de ejecución envía una señal a M. Ahora busquemos el controlador para esta señal y veamos qué hace el flujo cuando se recibe.


func doSigPreempt(gp *g, ctxt *sigctxt) {
	if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
		// Inject a call to asyncPreempt.
		ctxt.pushCall(funcPC(asyncPreempt))
	}
}

A partir de esta función, está claro que para "bloquear" debe pasar por 2 verificaciones:

  1. wantAsyncPreempt: verificamos si G quiere ser expulsado, aquí, por ejemplo, se verificará la validez del estado actual de la rutina.
  2. isAsyncSafePoint: compruebe si se puede desplazar en este momento. Lo más interesante de las comprobaciones aquí es si G está en un punto seguro o inseguro. Además, debemos estar seguros de que el subproceso en el que se ejecuta G también está listo para evitar G.

Si se pasan ambas comprobaciones, se invocarán instrucciones desde el código ejecutable que guarda el estado G y llama al planificador.

Y más sobre inseguro


Propongo analizar un nuevo ejemplo, ilustrará otro caso con punto inseguro:

otro programa sin fin

//go:nosplit
func infiniteLoop() {
	var u int
	for {
		u -= 2
		if u == 1 {
			break
		}
	}
}

func main() {
	runtime.GOMAXPROCS(1)
	go infiniteLoop()
	<-time.After(time.Millisecond * 5)

	fmt.Println("go 1.13 and 1.14 has never been here")
}


Como puede suponer, la inscripción "vaya 1.13 y 1.14 nunca estuvo aquí" no la veremos en GO 1.14. Esto se debe a que hemos prohibido explícitamente interrumpir la función infiniteLoop (go: nosplit). Dicha prohibición se implementa simplemente usando un punto inseguro, que es el cuerpo completo de la función. Veamos qué generó el compilador para la función infiniteLoop.

Ensamblador de precaución

        0x0000 00000 (main.go:10)   TEXT    "".infiniteLoop(SB), NOSPLIT|ABIInternal, $0-0
        0x0000 00000 (main.go:10)   PCDATA  $0, $-2
        0x0000 00000 (main.go:10)   PCDATA  $1, $-2
        0x0000 00000 (main.go:10)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   FUNCDATA        $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   XORL    AX, AX
        0x0002 00002 (main.go:12)   JMP     8
        0x0004 00004 (main.go:13)   ADDQ    $-2, AX
        0x0008 00008 (main.go:14)   CMPQ    AX, $3
        0x000c 00012 (main.go:14)   JNE     4
        0x000e 00014 (main.go:15)   PCDATA  $0, $-1
        0x000e 00014 (main.go:15)   PCDATA  $1, $-1
        0x000e 00014 (main.go:15)   RET


En nuestro caso, la instrucción PCDATA es de interés. Cuando el vinculador ve esta instrucción, no la convierte en un ensamblador "real". En cambio, el valor del segundo argumento con la clave igual al contador del programa correspondiente (el número que se puede observar a la izquierda del nombre de la función + línea) se colocará en el mapa de registro o pila (determinado por el primer argumento).

Como vemos en las líneas 10 y 15, colocamos los valores $ 2 y -1 en el mapa $ 0 y $ 1, respectivamente. Recordemos este momento y echemos un vistazo dentro de la función isAsyncSafePoint, a la que ya he llamado su atención. Allí veremos las siguientes líneas:

isAsyncSafePoint

	smi := pcdatavalue(f, _PCDATA_RegMapIndex, pc, nil)
	if smi == -2 {
		return false
	}


Es en este lugar donde verificamos si goroutine se encuentra actualmente en el punto seguro. Pasamos al mapa de registros (_PCDATA_RegMapIndex = 0), y pasándolo al PC actual verificamos el valor, si -2 entonces G no está en un punto seguro 'e, lo que significa que no puede ser desplazado.

Conclusión


Detuve mi "investigación" sobre esto, espero que el artículo sea útil para usted también.
Publico los enlaces prometidos, pero tenga cuidado, porque parte de la información en estos artículos podría estar desactualizada.

Planificador GO: una y dos veces .

Ensamblador GO.

All Articles