GO Scheduler: maintenant pas coopératif?

Si vous lisez les notes de publication de la version GO 1.14, vous avez probablement remarqué des changements assez intéressants dans l'exécution du langage. J'ai donc été très intéressé par l'article: "Les goroutines sont désormais préemptives de manière asynchrone." Il s'avère que GO Scheduler (Scheduler) n'est désormais plus coopératif? Eh bien, après avoir lu la proposition correspondante en diagonale, la curiosité était satisfaite.

Cependant, après un certain temps, j'ai décidé de rechercher plus en détail les innovations. Je voudrais partager les résultats de ces études.

image

Configuration requise


Les choses décrites ci-dessous nécessitent du lecteur, en plus de la connaissance de la langue GO, des connaissances supplémentaires, à savoir:

  • compréhension des principes de l'ordonnanceur (bien que j'essaierai d'expliquer ci-dessous, «sur les doigts»)
  • comprendre le fonctionnement du ramasse-miettes
  • comprendre ce qu'est l'assembleur GO

En fin de compte, je vais laisser quelques liens qui, à mon avis, couvrent bien ces sujets.

En bref sur le planificateur


Tout d'abord, permettez-moi de vous rappeler ce qu'est le multitâche coopératif et non coopératif.

Avec le multitâche non coopératif (éviction), nous connaissons tous l'exemple du planificateur du système d'exploitation. Ce planificateur fonctionne en arrière-plan, décharge les threads basés sur diverses heuristiques, et au lieu du temps CPU déchargé, d'autres threads commencent à recevoir.

Le planificateur coopératif se caractérise par un comportement différent - il dort jusqu'à ce que l'un des goroutins le réveille clairement avec un soupçon de disponibilité à donner sa place à un autre. Le planificateur décidera alors par lui-même s'il est nécessaire de retirer le goroutine actuel du contexte, et si oui, qui mettre à sa place. C’est ainsi que le planificateur GO a fonctionné.

De plus, nous considérons les pierres angulaires avec lesquelles le planificateur fonctionne:

  • P - processeurs logiques (nous pouvons changer leur nombre avec la fonction runtime.GOMAXPROCS), sur chaque processeur logique une goroutine peut être exécutée indépendamment à la fois.
  • Threads M - OS. Chaque P s'exécute sur un thread de M. Notez que P n'est pas toujours égal à M, par exemple, un thread peut être bloqué par syscall puis un autre thread sera alloué pour son P. Et il y a CGO et d'autres nuances.
  • G - gorutins. Eh bien ici, c'est clair, G doit être exécuté sur chaque P et le planificateur le surveille.

Et la dernière chose que vous devez savoir, et quand l'ordonnanceur appelle-t-il réellement goroutine? C'est simple, généralement des instructions pour l'appel sont insérées par le compilateur au début du corps (prologue) de la fonction (un peu plus loin nous en parlerons plus en détail).

Et quel est le problème en fait?


image

Depuis le début de l'article, vous avez déjà compris que le principe du travail de l'ordonnanceur a changé dans GO, examinons les raisons pour lesquelles ces modifications ont été apportées. Jetez un œil au code:

sous le 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 vous le compilez avec la version GO <1.14, alors la ligne "go 1.13 n'a jamais été là" que vous ne verrez pas à l'écran. Cela se produit car, dès que le planificateur donne du temps processeur au goroutine avec une boucle infinie, il capture complètement P, aucun appel de fonction ne se produit à l'intérieur de ce goroutine, ce qui signifie que nous ne réveillerons plus le planificateur. Et seul un appel explicite à runtime.Gosched () permettra à notre programme de se terminer.

Ceci est juste un exemple où goroutine capture P et empêche pendant longtemps d'autres goroutines de s'exécuter sur ce P. Plus d'options lorsque ce comportement provoque des problèmes peuvent être trouvées en lisant la proposition.

Analyse de la proposition


La solution à ce problème est assez simple. Faisons la même chose que dans le planificateur du système d'exploitation! Laissez simplement GO sortir le goroutine de P et mettez-en un autre, et pour cela, nous utiliserons les outils du système d'exploitation.

OK, comment implémenter cela? Nous allons permettre au runtime d'envoyer un signal au flux sur lequel goroutine fonctionne. Nous enregistrerons le processeur de ce signal sur chaque flux de M, la tâche du processeur est de déterminer si le goroutine actuel peut être supplanté. Si c'est le cas, nous enregistrerons son état actuel (registres et état de la pile) et donnerons des ressources à un autre, sinon nous continuerons à exécuter le goroutine actuel. Il convient de noter que le concept avec un signal est une solution pour les systèmes de base UNIX, alors que, par exemple, l'implémentation pour Windows est légèrement différente. Soit dit en passant, SIGURG a été sélectionné comme signal pour l'envoi.

La partie la plus difficile de cette mise en œuvre consiste à déterminer si le goroutine peut être expulsé. Le fait est que certains endroits de notre code devraient être atomiques, du point de vue du garbage collector. Nous appelons ces endroits des points dangereux. Si nous pressons goroutine au moment de l'exécution du code à partir de unsafe-point, puis que GC démarre, alors il remplacera l'état de notre goroutine, pris en unsafe-point'e, et peut faire des choses. Examinons de plus près le concept sûr / dangereux.

Êtes-vous allé là-bas, GC?


image

Dans les versions antérieures à 1.12, le runtime Gosched utilisait des points de sécurité dans des endroits où vous pouvez certainement appeler le planificateur sans craindre de nous retrouver dans la section atomique du code pour GC. Comme nous l'avons déjà dit, les données des points de sécurité sont situées dans le prologue d'une fonction (mais pas de chaque fonction, pensez-vous). Si vous avez désassemblé l'assembleur go-shn, vous pourriez vous opposer - aucun appel de planificateur évident n'y est visible. Oui, mais vous pouvez y trouver l'instruction d'appel runtime.morestack, et si vous regardez à l'intérieur de cette fonction, un appel de planificateur sera trouvé. Sous le spoiler, je cacherai le commentaire des sources GO, ou vous pouvez trouver l'assembleur pour morestack vous-même.

trouvé dans la source
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.

De toute évidence, lors du passage à un concept d'éviction, un signal d'éviction peut attraper notre gorutin n'importe où. Mais les auteurs de GO ont décidé de ne pas laisser de points de sécurité, mais de déclarer des points de sécurité partout! Eh bien, bien sûr, il y a un hic, presque partout en fait. Comme mentionné ci-dessus, il y a des points dangereux où nous ne forcerons personne. Écrivons un simple point dangereux.


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

Pour comprendre quel est le problème, essayons sur la peau d'un garbage collector. Chaque fois que nous allons travailler, nous devons trouver les nœuds racine (pointeurs sur la pile et dans les registres), avec lesquels nous commencerons à marquer. Puisqu'il est impossible de dire au moment de l'exécution si 64 octets en mémoire sont un pointeur ou juste un nombre, nous nous tournons vers les cartes de pile et d'enregistrement (un cache avec des métadonnées), aimablement fournies par le compilateur GO. Les informations de ces cartes nous permettent de trouver des pointeurs. Nous avons donc été réveillés et envoyés au travail lorsque GO a exécuté la ligne numéro 4. En arrivant à l'endroit et en regardant les cartes, nous avons constaté qu'il était vide (et cela est vrai, car uintptr du point de vue de GC est un nombre et non un pointeur). Eh bien, hier, nous avons entendu parler de l'allocation de mémoire pour j, car maintenant nous ne pouvons pas accéder à cette mémoire - nous devons la nettoyer, et après avoir retiré la mémoire, nous nous endormons.Et après? Eh bien, les autorités se sont réveillées, la nuit, en criant, eh bien, vous avez vous-même compris.

C'est tout avec la théorie, je propose de considérer dans la pratique comment fonctionnent tous ces signaux, points dangereux et registres de cartes et piles.

Passons à la pratique


J'ai exécuté deux fois (passez à 1.14 et 1.13) un exemple du début de l'article du perf profiler afin de voir quels appels système se produisent et de les comparer. L'appel système requis dans la 14e version a été trouvé assez rapidement:

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

Eh bien, évidemment, le runtime a envoyé SIGURG au thread sur lequel goroutine tourne. Prenant cette connaissance comme point de départ, je suis allé regarder les commits dans GO pour trouver où et pour quelle raison ce signal est envoyé, et aussi pour trouver l'endroit où le gestionnaire de signal est installé. Commençons par l'envoi, nous trouverons la fonction d'envoi de signal dans runtime / os_linux.go


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

Maintenant, nous trouvons des endroits dans le code d'exécution, d'où nous envoyons le signal:

  1. lorsque goroutine est suspendu, s'il est en état de marche. La demande de suspension provient du garbage collector. Ici, peut-être, je n'ajouterai pas de code, mais il peut être trouvé dans le fichier runtime / preempt.go (suspendG)
  2. si le planificateur décide que goroutine fonctionne trop longtemps, runtime / proc.go (retake)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS - constante égale à 10 ms, pd.schedwhen - heure à laquelle le planificateur du flux pd a été appelé la dernière fois
  3. ainsi que tous les flux, ce signal est envoyé lors d'une panique, StopTheWorld (GC) et quelques autres cas (que je dois contourner, car la taille de l'article dépassera déjà les limites)

Nous avons compris comment et quand le runtime envoie un signal à M. Maintenant, trouvons le gestionnaire de ce signal et voyons ce que fait le flux lorsqu'il est reçu.


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 cette fonction, il est clair que pour "verrouiller" vous devez passer par 2 contrôles:

  1. wantAsyncPreempt - nous vérifions si G veut être expulsé, ici, par exemple, la validité de l'état actuel du goroutine sera vérifiée.
  2. isAsyncSafePoint - vérifiez s'il peut être évincé en ce moment. Le plus intéressant des contrôles ici est de savoir si G est dans un point sûr ou non. De plus, nous devons être sûrs que le thread sur lequel G s'exécute est également prêt à préempter G.

Si les deux vérifications sont réussies, des instructions seront appelées à partir du code exécutable qui enregistre l'état G et appelle l'ordonnanceur.

Et plus sur dangereux


Je propose d'analyser un nouvel exemple, il illustrera un autre cas avec unsafe-point:

un autre programme sans 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")
}


Comme vous pouvez le deviner, l'inscription «go 1.13 et 1.14 n'a jamais été ici» que nous ne verrons pas dans GO 1.14. C'est parce que nous avons explicitement interdit d'interrompre la fonction infiniteLoop (go: nosplit). Une telle interdiction est mise en œuvre uniquement à l'aide de unsafe-point, qui est le corps entier de la fonction. Voyons ce que le compilateur a généré pour la fonction infiniteLoop.

Assembleur Attention

        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


Dans notre cas, l'instruction PCDATA est intéressante. Lorsque l'éditeur de liens voit cette instruction, il ne la convertit pas en un "véritable" assembleur. Au lieu de cela, la valeur du 2e argument avec la clé égale au compteur de programme correspondant (le nombre qui peut être observé à gauche du nom de la fonction + ligne) sera placée dans le registre ou la mappe de pile (déterminée par le 1er argument).

Comme nous le voyons sur les lignes 10 et 15, nous mettons les valeurs $ 2 et -1 dans la carte $ 0 et $ 1, respectivement. Souvenons-nous de ce moment et jetons un œil à l'intérieur de la fonction isAsyncSafePoint, sur laquelle j'ai déjà attiré votre attention. Là, nous verrons les lignes suivantes:

isAsyncSafePoint

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


C'est à cet endroit que l'on vérifie si la goroutine est actuellement au point de sécurité. Nous passons à la carte des registres (_PCDATA_RegMapIndex = 0), et en lui passant le pc actuel, nous vérifions la valeur, si -2 alors G n'est pas en point sûr, ce qui signifie qu'il ne peut pas être évincé.

Conclusion


J'ai arrêté mes «recherches» là-dessus, j'espère que l'article vous a été utile aussi.
Je poste les liens promis, mais soyez prudent, car certaines des informations contenues dans ces articles peuvent être obsolètes.

Planificateur GO - une et deux fois .

Assembleur GO.

All Articles