GO Scheduler: Jetzt nicht kooperativ?

Wenn Sie die Versionshinweise für Version GO 1.14 gelesen haben, haben Sie wahrscheinlich einige ziemlich interessante Änderungen in der Laufzeit der Sprache bemerkt. Daher interessierte mich der Artikel sehr: "Goroutinen sind jetzt asynchron präemptibel." Es stellt sich heraus, dass GO Scheduler (Scheduler) jetzt nicht kooperativ ist? Nun, nachdem ich den entsprechenden Vorschlag diagonal gelesen hatte, war die Neugier befriedigt.

Nach einer Weile entschied ich mich jedoch, die Innovationen genauer zu untersuchen. Ich möchte die Ergebnisse dieser Studien teilen.

Bild

System Anforderungen


Die nachfolgend beschriebenen Dinge erfordern vom Leser zusätzlich zu den Kenntnissen der GO-Sprache zusätzliche Kenntnisse, nämlich:

  • Verständnis der Prinzipien des Schedulers (obwohl ich versuchen werde, unten "an den Fingern" zu erklären)
  • Verstehen, wie der Garbage Collector funktioniert
  • Verstehen, was GO Assembler ist

Am Ende werde ich ein paar Links hinterlassen, die meiner Meinung nach diese Themen gut abdecken.

Kurz über den Planer


Lassen Sie mich zunächst daran erinnern, was kooperatives und nicht kooperatives Multitasking ist.

Mit nicht kooperativem (verdrängendem) Multitasking kennen wir alle das Beispiel des OS-Schedulers. Dieser Scheduler arbeitet im Hintergrund, entlädt Threads basierend auf verschiedenen Heuristiken und anstelle der entladenen CPU-Zeit beginnen andere Threads zu empfangen.

Der Genossenschaftsplaner zeichnet sich durch ein anderes Verhalten aus - er schläft, bis eine der Goroutinen ihn mit einem Hauch von Bereitschaft weckt, seinen Platz einem anderen zu geben. Der Planer entscheidet dann selbst, ob es notwendig ist, die aktuelle Goroutine aus dem Kontext zu entfernen, und wenn ja, wen er an ihre Stelle setzen soll. So funktionierte der GO-Scheduler.

Darüber hinaus betrachten wir die Eckpfeiler, mit denen der Scheduler arbeitet:

  • P - logische Prozessoren (wir können ihre Nummer mit der Funktion runtime.GOMAXPROCS ändern), auf jedem logischen Prozessor kann jeweils eine Goroutine unabhängig ausgeführt werden.
  • M-OS-Threads. Jedes P läuft auf einem Thread von M. Beachten Sie, dass P nicht immer gleich M ist. Beispielsweise kann ein Thread durch Syscall blockiert werden, und dann wird ein anderer Thread für sein P zugewiesen. Und es gibt CGO und andere und andere Nuancen.
  • G - Gorutine. Nun, hier ist klar, dass G auf jedem P ausgeführt werden muss und der Scheduler dies überwacht.

Und das Letzte, was Sie wissen müssen, und wann ruft der Scheduler tatsächlich Goroutine auf? Es ist einfach, normalerweise werden Anweisungen zum Aufrufen vom Compiler am Anfang des Hauptteils (Prolog) der Funktion eingefügt (etwas später werden wir genauer darauf eingehen).

Und was ist eigentlich das Problem?


Bild

Zu Beginn des Artikels haben Sie bereits festgestellt, dass sich das Prinzip der Arbeit des Schedulers in GO geändert hat. Betrachten wir die Gründe, warum diese Änderungen vorgenommen wurden. Schauen Sie sich den Code an:

unter dem 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")
}


Wenn Sie es mit der Version GO <1.14 kompilieren, wird die Zeile "go 1.13 war noch nie hier" nicht auf dem Bildschirm angezeigt. Dies geschieht, weil, sobald der Scheduler der Goroutine mit einer Endlosschleife Prozessorzeit gibt, P vollständig erfasst wird, keine Funktionsaufrufe innerhalb dieser Goroutine auftreten, was bedeutet, dass wir den Scheduler nicht mehr aktivieren. Und nur ein expliziter Aufruf von runtime.Gosched () lässt unser Programm beenden.

Dies ist nur ein Beispiel, bei dem Goroutine P erfasst und lange Zeit verhindert, dass andere Goroutinen auf diesem P ausgeführt werden. Weitere Optionen, wenn dieses Verhalten Probleme verursacht, finden Sie im Artikel.

Vorschlag analysieren


Die Lösung für dieses Problem ist recht einfach. Machen wir dasselbe wie im OS-Scheduler! Lassen Sie GO einfach die Goroutine von P auslaufen und setzen Sie eine weitere dort ein, und dafür werden wir die OS-Tools verwenden.

OK, wie implementiert man das? Wir werden der Laufzeit erlauben, ein Signal an den Fluss zu senden, an dem Goroutine arbeitet. Wir werden den Prozessor dieses Signals in jedem Strom von M registrieren. Die Aufgabe des Prozessors besteht darin, zu bestimmen, ob die aktuelle Goroutine ersetzt werden kann. In diesem Fall speichern wir den aktuellen Status (Register und den Status des Stapels) und geben Ressourcen an einen anderen weiter. Andernfalls führen wir die aktuelle Goroutine weiter aus. Es ist erwähnenswert, dass das Konzept mit einem Signal eine Lösung für UNIX-Basissysteme darstellt, während sich beispielsweise die Implementierung für Windows geringfügig unterscheidet. Übrigens wurde SIGURG als Signal zum Senden ausgewählt.

Der schwierigste Teil dieser Implementierung besteht darin, festzustellen, ob Goroutine herausgedrückt werden kann. Tatsache ist, dass einige Stellen in unserem Code aus Sicht des Garbage Collectors atomar sein sollten. Wir nennen solche Orte unsichere Punkte. Wenn wir die Goroutine im Moment der Codeausführung von einem unsicheren Punkt aus drücken und dann GC startet, ersetzt sie den Status unserer Goroutine, aufgenommen in einem unsicheren Punkt, und kann Dinge tun. Schauen wir uns das sichere / unsichere Konzept genauer an.

Bist du dorthin gegangen, GC?


Bild

In Versionen vor 1.12 verwendete Gosched zur Laufzeit Sicherheitspunkte an Stellen, an denen Sie den Scheduler definitiv aufrufen können, ohne befürchten zu müssen, dass wir im atomaren Abschnitt des Codes für GC landen. Wie bereits erwähnt, befinden sich Safe-Points-Daten im Prolog einer Funktion (wohlgemerkt jedoch nicht jeder Funktion). Wenn Sie den go-shn-Assembler zerlegen, können Sie Einwände erheben - dort sind keine offensichtlichen Scheduler-Aufrufe sichtbar. Ja, aber Sie finden dort die Anweisung runtime.morestack call. Wenn Sie sich diese Funktion ansehen, wird ein Scheduler-Aufruf gefunden. Unter dem Spoiler werde ich den Kommentar vor den GO-Quellen verbergen, oder Sie können den Assembler für Morestack selbst finden.

in der Quelle gefunden
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.

Wenn Sie zu einem Verdrängungskonzept wechseln, kann ein Verdrängungssignal unseren Gorutin natürlich überall fangen. Aber die GO-Autoren haben beschlossen, keine sicheren Punkte zu hinterlassen, sondern überall sichere Punkte zu deklarieren! Natürlich gibt es einen Haken, fast überall. Wie oben erwähnt, gibt es einige unsichere Punkte, an denen wir niemanden verdrängen werden. Schreiben wir einen einfachen unsicheren Punkt.


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

Um zu verstehen, wo das Problem liegt, probieren wir die Haut eines Müllsammlers an. Jedes Mal, wenn wir zur Arbeit gehen, müssen wir die Wurzelknoten (Zeiger auf dem Stapel und in den Registern) herausfinden, mit denen wir mit dem Markieren beginnen. Da es zur Laufzeit unmöglich ist zu sagen, ob 64 Bytes im Speicher ein Zeiger oder nur eine Zahl sind, wenden wir uns dem Stapel zu und registrieren Karten (einige Caches mit Metainformationen), die uns freundlicherweise vom GO-Compiler zur Verfügung gestellt wurden. Die Informationen in diesen Karten ermöglichen es uns, Zeiger zu finden. Also wurden wir geweckt und zur Arbeit geschickt, als GO Zeile 4 ausführte. Als wir am Ort ankamen und die Karten betrachteten, stellten wir fest, dass sie leer waren (und dies ist wahr, da uintptr aus Sicht von GC eine Zahl und kein Zeiger ist). Nun, gestern haben wir von der Speicherzuweisung für j gehört, da wir jetzt nicht auf diesen Speicher zugreifen können - wir müssen ihn bereinigen und nachdem wir den Speicher entfernt haben, gehen wir schlafen.Was weiter? Nun, die Behörden sind nachts aufgewacht und haben geschrien. Nun, Sie haben es selbst verstanden.

Das ist alles mit Theorie, ich schlage vor, in der Praxis zu überlegen, wie all diese Signale, unsicheren Punkte und Registerkarten und Stapel funktionieren.

Lass uns weiter üben


Ich habe ein Beispiel vom Anfang des Artikels durch den Perf-Profiler doppelt ausgeführt (go 1.14 und go 1.13), um zu sehen, welche Systemaufrufe stattfinden, und sie zu vergleichen. Der erforderliche Systemaufruf in der 14. Version wurde ziemlich schnell gefunden:

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

Nun, offensichtlich hat die Laufzeit SIGURG an den Thread gesendet, auf dem sich die Goroutine dreht. Ausgehend von diesem Wissen habe ich mir die Commits in GO angesehen, um herauszufinden, wohin und aus welchem ​​Grund dieses Signal gesendet wird, und um den Ort zu finden, an dem der Signalhandler installiert ist. Beginnen wir mit dem Senden. Die Funktion zum Senden von Signalen finden Sie in runtime / os_linux.go


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

Jetzt finden wir Stellen im Laufzeitcode, von denen aus wir das Signal senden:

  1. Wenn Goroutine ausgesetzt ist, wenn es sich in einem laufenden Zustand befindet. Die Suspend-Anforderung kommt vom Garbage Collector. Hier werde ich vielleicht keinen Code hinzufügen, aber er befindet sich in der Datei runtime / preempt.go (suspendG)
  2. Wenn der Scheduler entscheidet, dass Goroutine zu lange arbeitet, wird runtime / proc.go (Wiederholung)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS - Konstante gleich 10 ms, pd.schedwhen - Zeitpunkt, zu dem der Scheduler für den pd-Stream das letzte Mal aufgerufen wurde
  3. Neben allen Streams wird dieses Signal während einer Panik, StopTheWorld (GC) und einigen weiteren Fällen gesendet (die ich umgehen muss, da die Größe des Artikels bereits über den Rahmen hinausgeht).

Wir haben herausgefunden, wie und wann die Laufzeit ein Signal an M sendet. Lassen Sie uns nun den Handler für dieses Signal finden und sehen, was der Stream tut, wenn er empfangen wird.


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

Aus dieser Funktion geht hervor, dass Sie zum „Einrasten“ zwei Überprüfungen durchführen müssen:

  1. wantAsyncPreempt - Wir prüfen, ob G erzwungen werden soll. Hier wird beispielsweise die Gültigkeit des aktuellen Goroutine-Status überprüft.
  2. isAsyncSafePoint - Überprüfen Sie, ob es jetzt verdrängt werden kann. Die interessanteste Überprüfung hier ist, ob sich G an einem sicheren oder unsicheren Punkt befindet. Außerdem müssen wir sicher sein, dass der Thread, auf dem G ausgeführt wird, auch bereit ist, G zu verhindern.

Wenn beide Prüfungen bestanden sind, werden Anweisungen aus dem ausführbaren Code aufgerufen, der den Status G speichert und den Scheduler aufruft.

Und mehr über unsichere


Ich schlage vor, ein neues Beispiel zu analysieren, es wird einen anderen Fall mit unsicherem Punkt veranschaulichen:

ein weiteres endloses Programm

//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")
}


Wie Sie vielleicht erraten haben, wird die Inschrift „go 1.13 und 1.14 war noch nie hier“ in GO 1.14 nicht zu sehen sein. Dies liegt daran, dass wir ausdrücklich verboten haben, die Funktion infiniteLoop (go: nosplit) zu unterbrechen. Ein solches Verbot wird nur unter Verwendung eines unsicheren Punktes implementiert, der den gesamten Funktionsumfang darstellt. Mal sehen, was der Compiler für die Funktion infiniteLoop generiert hat.

Vorsicht Assembler

        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


In unserem Fall ist der PCDATA-Befehl von Interesse. Wenn der Linker diese Anweisung sieht, konvertiert er sie nicht in einen "echten" Assembler. Stattdessen wird der Wert des 2. Arguments mit dem Schlüssel gleich dem entsprechenden Programmzähler (die Zahl, die links vom Funktionsnamen + Zeile zu sehen ist) in das Register oder die Stapelzuordnung eingefügt (bestimmt durch das 1. Argument).

Wie wir in den Zeilen 10 und 15 sehen, setzen wir die Werte $ 2 und -1 in die Karte $ 0 bzw. $ 1. Erinnern wir uns an diesen Moment und werfen wir einen Blick in die isAsyncSafePoint-Funktion, auf die ich Sie bereits aufmerksam gemacht habe. Dort sehen wir folgende Zeilen:

isAsyncSafePoint

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


An dieser Stelle prüfen wir, ob sich Goroutine derzeit im sicheren Punkt befindet. Wir wenden uns der Registerkarte zu (_PCDATA_RegMapIndex = 0) und übergeben dem aktuellen PC den Wert. Wenn -2, dann ist G nicht im sicheren Punkt, was bedeutet, dass es nicht verdrängt werden kann.

Fazit


Ich habe meine "Forschung" dazu eingestellt, ich hoffe, der Artikel war auch für Sie nützlich.
Ich poste die versprochenen Links, aber bitte seien Sie vorsichtig, da einige der Informationen in diesen Artikeln veraltet sein könnten.

GO Scheduler - ein- und zweimal .

Assembler GO.

All Articles