Go's Concurrency Decoded: Goroutine Scheduling
Lukas Schneider
DevOps Engineer · Leapcell

I. Einführung in Goroutine
Goroutine ist ein sehr ausgeprägtes Design in der Go-Programmiersprache und eines ihrer wichtigsten Highlights. Im Wesentlichen eine Coroutine, ist sie der Schlüssel zur Erreichung paralleler Berechnungen. Die Verwendung von Goroutine ist sehr einfach. Sie können eine Coroutine einfach mit dem Schlüsselwort go
starten, und sie läuft asynchron. Das Programm kann die nachfolgenden Codezeilen weiter ausführen, ohne auf den Abschluss der Goroutine zu warten.
go func() // Startet eine Coroutine, um eine Funktion mit dem Schlüsselwort go auszuführen
II. Interne Prinzipien von Goroutine
Konzepteinführung
Concurrency (Gleichzeitigkeit)
Auf einer einzelnen CPU können mehrere Aufgaben gleichzeitig ausgeführt werden. In einem extrem kurzen Zeitraum wechselt die CPU schnell zwischen Aufgaben (z. B. führt sie Programm A für kurze Zeit aus und wechselt dann schnell zu Programm B). Es gibt eine zeitliche Überschneidung (aus makroskopischer Sicht scheint es gleichzeitig zu sein, aber auf mikroskopischer Ebene ist es immer noch eine sequenzielle Ausführung). Dies erweckt die Illusion, dass mehrere Aufgaben gleichzeitig ausgeführt werden, und das nennen wir Concurrency.
Parallelism (Parallelität)
Wenn ein System über mehrere CPUs verfügt, kann jede CPU Aufgaben gleichzeitig ausführen, ohne um die Ressourcen ihrer eigenen CPU zu konkurrieren. Sie arbeiten gleichzeitig, und dies wird als Parallelität bezeichnet.
Prozess
Wenn die CPU zwischen Programmen wechselt, geht eine Reihe von Zuständen des vorherigen Programms verloren, wenn sie den Zustand des vorherigen Programms (den sogenannten Kontext) nicht speichert und direkt zum nächsten Programm wechselt. Um dieses Problem zu lösen, wird das Konzept eines Prozesses eingeführt, um die für die Programmausführung erforderlichen Ressourcen zuzuweisen. Daher ist ein Prozess die grundlegende Ressourceneinheit, die für die Ausführung eines Programms erforderlich ist (er kann auch als eine Entität der Programmausführung betrachtet werden). Wenn Sie beispielsweise eine Textbearbeitungsanwendung ausführen, verwaltet der Prozess für diese Anwendung alle Ressourcen wie den Speicherplatz für den Textpuffer, Dateiressourcen usw.
Thread
Der Wechsel zwischen mehreren Prozessen durch die CPU verbraucht eine erhebliche Zeit, da der Prozesswechsel einen Übergang in den Kernel-Modus erfordert und jede Zeitplanung das Lesen von Daten im Benutzermodus erfordert. Mit zunehmender Anzahl von Prozessen verbraucht die CPU-Planung eine große Menge an Ressourcen. Daher wird das Konzept eines Threads eingeführt. Threads selbst verbrauchen sehr wenig Ressourcen; sie teilen sich die Ressourcen innerhalb eines Prozesses. Wenn der Kernel Threads plant, verbraucht er nicht so viele Ressourcen wie bei der Planung von Prozessen. In einer Webserveranwendung können beispielsweise mehrere Threads verwendet werden, um verschiedene Client-Anforderungen gleichzeitig zu bearbeiten und sich die Ressourcen des Serverprozesses wie Netzwerkverbindungen und Speicher-Caches zu teilen.
Coroutine
Eine Coroutine hat ihren eigenen Registerkontext und Stack. Wenn die Coroutine zum Wechseln geplant ist, speichert sie den Registerkontext und den Stack an einem anderen Ort. Beim Zurückschalten stellt sie den zuvor gespeicherten Registerkontext und Stack wieder her. Eine Coroutine kann also den Zustand des vorherigen Aufrufs beibehalten (d. h. eine bestimmte Kombination aller lokalen Zustände). Jedes Mal, wenn sie den Prozess erneut betritt, entspricht dies der Rückkehr zum Zustand des vorherigen Aufrufs, mit anderen Worten, der Rückkehr zu der Position im logischen Ablauf, an der sie das letzte Mal verlassen hat. Die Operationen von Threads und Prozessen werden vom Programm über Systemschnittstellen ausgelöst, und der ultimative Ausführer ist das System. Die Operationen von Coroutinen werden jedoch vom eigenen Programm des Benutzers ausgeführt, und Goroutine ist eine Art Coroutine.
Einführung in das Scheduling-Modell
Die leistungsstarke parallele Implementierung von Goroutine wird durch das GPM-Scheduling-Modell erreicht. Im Folgenden wird das Goroutine-Scheduling-Modell erläutert.
Es gibt vier wichtige Strukturen innerhalb des Go-Schedulers: M, P, G und Sched (Sched wird im Diagramm nicht gezeigt).
- M: Repräsentiert einen Kernel-Level-Thread. Ein M ist ein Thread, und Goroutinen laufen auf M. Wenn beispielsweise eine Goroutine gestartet wird, um eine komplexe Berechnung durchzuführen, wird diese Goroutine einem M zur Ausführung zugewiesen. M ist eine große Struktur, die einen Small-Object-Memory-Cache (mcache), die aktuell laufende Goroutine, einen Zufallszahlengenerator und viele andere Informationen verwaltet.
- G: Repräsentiert eine Goroutine. Sie hat ihren eigenen Stack zum Speichern von Funktionsaufrufinformationen, einen Instruction Pointer zur Angabe der Ausführungsposition und andere Informationen wie den Channel, auf den sie wartet, der für die Planung verwendet wird. Wenn beispielsweise eine Goroutine darauf wartet, Daten von einem Channel zu empfangen, werden diese Informationen in der G-Struktur gespeichert.
- P: Der vollständige Name ist Processor. Er wird hauptsächlich zur Ausführung von Goroutinen verwendet. Sie können ihn sich als Task-Dispatcher vorstellen. Er verwaltet auch eine Goroutine-Queue, die alle Goroutinen speichert, die von ihm ausgeführt werden müssen. Wenn beispielsweise mehrere Goroutinen erstellt werden, werden sie der von P verwalteten Queue zur Planung hinzugefügt.
- Sched: Repräsentiert den Scheduler. Er kann als zentrales Scheduling-Center betrachtet werden. Er verwaltet die Queues von M und G sowie einige Zustandsinformationen des Schedulers, um die effiziente Planung des gesamten Systems sicherzustellen.
Scheduling-Implementierung
Wie aus der Abbildung ersichtlich, gibt es 2 physische Threads M, jedes M hat einen Prozessor P, und es gibt eine laufende Goroutine.
- Die Anzahl von P kann über
GOMAXPROCS()
eingestellt werden. Sie stellt tatsächlich den wahren Parallelitätsgrad dar, d. h. die Anzahl der Goroutinen, die gleichzeitig laufen können. - Die grauen Goroutinen in der Abbildung laufen nicht und befinden sich im Bereitschaftszustand und warten auf die Planung. P verwaltet diese Queue (genannt Runqueue).
- In der Go-Sprache ist das Starten einer Goroutine sehr einfach: Verwenden Sie einfach
go function
. Daher wird jedes Mal, wenn einego
-Anweisung ausgeführt wird, eine Goroutine am Ende der Runqueue hinzugefügt. Am nächsten Planungspunkt wird eine Goroutine aus der Runqueue zur Ausführung entnommen (aber wie wird entschieden, welche Goroutine ausgewählt werden soll?).
Wenn ein OS-Thread M0 blockiert wird (wie in der folgenden Abbildung dargestellt), wechselt P zur Ausführung von M1. Das M1 in der Abbildung befindet sich möglicherweise im Prozess der Erstellung oder wird aus dem Thread-Cache entnommen.
Wenn M0 zurückkehrt, muss es versuchen, ein P zu erhalten, um die Goroutine auszuführen. Normalerweise wird es versuchen, ein P von anderen OS-Threads zu erhalten. Wenn es keins erhalten kann, legt es die Goroutine in eine globale Runqueue und geht dann selbst in den Schlaf (wird in den Thread-Cache gelegt). Alle P überprüfen periodisch die globale Runqueue und führen die Goroutinen darin aus; andernfalls werden die Goroutinen in der globalen Runqueue nie ausgeführt.
Eine andere Situation ist, dass die P zugewiesene Aufgabe G schnell abgeschlossen ist (ungleiche Verteilung), was dazu führt, dass dieser Prozessor P im Leerlauf ist, während andere Ps noch Aufgaben haben. Wenn es keine Aufgaben G in der globalen Runqueue gibt, muss P einige G von anderen Ps zur Ausführung erhalten. Im Allgemeinen nimmt P, wenn es Aufgaben von anderen Ps nimmt, normalerweise die Hälfte der Runqueue, um sicherzustellen, dass jeder OS-Thread vollständig genutzt werden kann, wie in der folgenden Abbildung dargestellt:
III. Verwenden von Goroutine
Grundlegende Verwendung
Legen Sie die Anzahl der CPUs fest, auf denen Goroutine ausgeführt werden soll. Die neueste Version von Go hat eine Standardeinstellung.
num := runtime.NumCPU() // Ruft die Anzahl der logischen CPUs des Hosts ab und bereitet die spätere Einstellung des Parallelitätsgrads vor runtime.GOMAXPROCS(num) // Legt die maximale Anzahl von CPUs fest, die gleichzeitig ausgeführt werden können, basierend auf der Anzahl der Host-CPUs, wodurch der Parallelitätsgrad von Goroutinen gesteuert wird
Anwendungsbeispiele
Beispiel 1: Einfache Goroutine-Berechnung
package main import ( "fmt" "time" ) // Die Funktion cal wird verwendet, um die Summe zweier Integer zu berechnen und das Ergebnis auszugeben func cal(a int, b int) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) } func main() { for i := 0; i < 10; i++ { go cal(i, i + 1) // Startet 10 Goroutinen zur Durchführung von Berechnungen } time.Sleep(time.Second * 2) // Sleep wird verwendet, um auf den Abschluss aller Aufgaben zu warten }
Ergebnis:
8 + 9 = 17
9 + 10 = 19
4 + 5 = 9
5 + 6 = 11
0 + 1 = 1
1 + 2 = 3
2 + 3 = 5
3 + 4 = 7
7 + 8 = 15
6 + 7 = 13
Goroutine-Ausnahmebehandlung
Beim Starten mehrerer Goroutinen wird das gesamte Programm beendet, wenn eine von ihnen auf eine Ausnahme stößt und keine Ausnahmebehandlung durchgeführt wird. Daher ist es ratsam, beim Schreiben eines Programms den von jeder Goroutine ausgeführten Funktionen eine Ausnahmebehandlung hinzuzufügen. Die Funktion recover
kann für die Ausnahmebehandlung verwendet werden.
package main import ( "fmt" "time" ) func addele(a []int, i int) { // Verwenden Sie defer, um die Ausführung der anonymen Funktion zu verzögern, die zum Abfangen möglicher Ausnahmen verwendet wird defer func() { // Rufen Sie die Funktion recover auf, um die Ausnahmeinformationen abzurufen err := recover() if err!= nil { // Geben Sie die Ausnahmeinformationen aus fmt.Println("add ele fail") } }() a[i] = i fmt.Println(a) } func main() { Arry := make([]int, 4) for i := 0; i < 10; i++ { go addele(Arry, i) } time.Sleep(time.Second * 2) }
Ergebnis:
add ele fail
[0 0 0 0]
[0 1 0 0]
[0 1 2 0]
[0 1 2 3]
add ele fail
add ele fail
add ele fail
add ele fail
add ele fail
Synchronisierte Goroutinen
Da Goroutinen asynchron ausgeführt werden, ist es möglich, dass einige Goroutinen noch nicht fertig ausgeführt wurden, wenn das Hauptprogramm beendet wird, und diese Goroutinen werden ebenfalls beendet. Wenn Sie warten möchten, bis alle Goroutine-Aufgaben abgeschlossen sind, bevor Sie beenden, stellt Go das Paket sync
und channel
zur Lösung des Synchronisationsproblems bereit. Wenn Sie die Ausführungszeit jeder Goroutine vorhersagen können, können Sie natürlich auch time.Sleep
verwenden, um zu warten, bis sie abgeschlossen sind, bevor Sie das Programm beenden (wie im obigen Beispiel).
Beispiel 1: Verwenden des sync-Pakets zum Synchronisieren von Goroutinen
WaitGroup
wird verwendet, um auf den Abschluss einer Gruppe von Goroutinen zu warten. Das Hauptprogramm ruft Add
auf, um die Anzahl der zu wartenden Goroutinen hinzuzufügen. Jede Goroutine ruft Done
auf, wenn sie die Ausführung beendet, und die Zahl in der Warteschlange wird dann um 1 verringert. Das Hauptprogramm wird durch Wait
blockiert, bis die Warteschlange 0 ist.
package main import ( "fmt" "sync" ) func cal(a int, b int, n *sync.WaitGroup) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) // Wenn die Goroutine abgeschlossen ist, rufen Sie die Methode Done auf, um die Anzahl von WaitGroup um 1 zu verringern defer n.Done() } func main() { var go_sync sync.WaitGroup // Deklarieren Sie eine WaitGroup-Variable for i := 0; i < 10; i++ { // Erhöhen Sie die Anzahl von WaitGroup um 1, bevor Sie die Goroutine starten go_sync.Add(1) go cal(i, i + 1, &go_sync) } // Blockieren und warten Sie, bis die Anzahl von WaitGroup 0 ist, d. h. alle Goroutinen abgeschlossen sind go_sync.Wait() }
Ergebnis:
9 + 10 = 19
2 + 3 = 5
3 + 4 = 7
4 + 5 = 9
5 + 6 = 11
1 + 2 = 3
6 + 7 = 13
7 + 8 = 15
0 + 1 = 1
8 + 9 = 17
Beispiel 2: Implementieren der Synchronisation zwischen Goroutinen über Channel
Implementierungsmethode: Über channel
kann die Kommunikation zwischen mehreren Goroutinen erfolgen. Wenn eine Goroutine abgeschlossen ist, sendet sie ein Ausgangssignal an den channel
. Wenn alle Goroutinen beendet sind, verwenden Sie eine for
-Schleife, um Signale aus dem channel
abzurufen. Wenn keine Daten abgerufen werden können, wird sie blockiert, bis alle Goroutinen abgeschlossen sind. Voraussetzung für die Verwendung dieser Methode ist, die Anzahl der gestarteten Goroutinen zu kennen.
package main import ( "fmt" "time" ) func cal(a int, b int, Exitchan chan bool) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) time.Sleep(time.Second * 2) // Senden Sie ein Signal an den Channel, um anzugeben, dass die Goroutine abgeschlossen ist Exitchan <- true } func main() { // Erstellen Sie einen Bool-Typ-Channel mit einer Kapazität von 10, um die Fertigstellungssignale von Goroutinen zu speichern Exitchan := make(chan bool, 10) for i := 0; i < 10; i++ { go cal(i, i + 1, Exitchan) } for j := 0; j < 10; j++ { // Empfangen Sie Signale vom Channel. Wenn kein Signal verfügbar ist, wird es blockiert, bis eine Goroutine abgeschlossen ist und ein Signal sendet <-Exitchan } // Schließen Sie den Channel close(Exitchan) }
Kommunikation zwischen Goroutinen
Goroutine ist im Wesentlichen eine Coroutine, die als ein Thread verstanden werden kann, der vom Go-Scheduler und nicht vom Kernel verwaltet wird. Die Kommunikation oder Datenfreigabe zwischen Goroutinen kann über channel
erfolgen. Natürlich können auch globale Variablen verwendet werden, um Daten freizugeben.
Beispiel: Verwenden von Channel zur Simulation des Producer-Consumer-Musters
package main import ( "fmt" "sync" ) func Productor(mychan chan int, data int, wait *sync.WaitGroup) { // Senden Sie Daten an den Channel mychan <- data fmt.Println("product data:", data) // Markieren Sie den Producer als abgeschlossen und verringern Sie die Anzahl von WaitGroup um 1 wait.Done() } func Consumer(mychan chan int, wait *sync.WaitGroup) { // Empfangen Sie Daten vom Channel a := <-mychan fmt.Println("consumer data:", a) // Markieren Sie den Consumer als abgeschlossen und verringern Sie die Anzahl von WaitGroup um 1 wait.Done() } func main() { // Erstellen Sie einen Int-Typ-Channel mit einer Kapazität von 100 für die Datenübertragung zwischen dem Producer und dem Consumer datachan := make(chan int, 100) var wg sync.WaitGroup for i := 0; i < 10; i++ { // Starten Sie die Producer-Goroutine, um Daten an den Channel zu senden go Productor(datachan, i, &wg) // Erhöhen Sie die Anzahl von WaitGroup wg.Add(1) } for j := 0; j < 10; j++ { // Starten Sie die Consumer-Goroutine, um Daten vom Channel zu empfangen go Consumer(datachan, &wg) // Erhöhen Sie die Anzahl von WaitGroup wg.Add(1) } // Blockieren und warten Sie, bis sowohl der Producer als auch der Consumer ihre Aufgaben abgeschlossen haben wg.Wait() }
Ergebnis:
consumer data: 4
product data: 5
product data: 6
product data: 7
product data: 8
product data: 9
consumer data: 1
consumer data: 5
consumer data: 6
consumer data: 7
consumer data: 8
consumer data: 9
product data: 2
consumer data: 2
product data: 3
consumer data: 3
product data: 4
consumer data: 0
product data: 0
product data: 1
Leapcell: Die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis
Abschließend möchte ich die am besten geeignete Plattform für die Bereitstellung von Go-Diensten empfehlen: Leapcell
1. Multi-Language-Unterstützung
- Entwickeln Sie mit JavaScript, Python, Go oder Rust.
2. Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung – keine Anfragen, keine Gebühren.
3. Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
4. Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für verwertbare Erkenntnisse.
5. Mühelose Skalierbarkeit und hohe Leistung
- Auto-Scaling zur einfachen Bewältigung hoher Parallelität.
- Kein operativer Overhead – konzentrieren Sie sich einfach auf das Bauen.
Erfahren Sie mehr in der Dokumentation!
Leapcell Twitter: https://x.com/LeapcellHQ