Wie man einen Goroutine-Pool in Go implementiert
James Reed
Infrastructure Engineer · Leapcell

0. Einleitung
Zuvor wurde erwähnt, dass der native HTTP-Server in Go beim Verarbeiten von Client-Verbindungen für jede Verbindung eine Goroutine erzeugt, was ein eher brachialer Ansatz ist. Um ein tieferes Verständnis zu erlangen, wollen wir uns den Go-Quellcode ansehen. Definieren wir zunächst den einfachsten HTTP-Server wie folgt:
func myHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hallo! ") } func main() { http.HandleFunc("/", myHandler) // Set the access route log.Fatal(http.ListenAndServe(":8080", nil)) }
Folgen Sie dem Einstiegspunkt http.ListenAndServe
Funktion.
// file: net/http/server.go func ListenAndServe(addr string, handler Handler) error { server := &Server{Addr: addr, Handler: handler} return server.ListenAndServe() } func (srv *Server) ListenAndServe() error { addr := srv.Addr if addr == "" { addr = ":http" } ln, err := net.Listen("tcp", addr) if err!= nil { return err } return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)}) } func (srv *Server) Serve(l net.Listener) error { defer l.Close() ... for { rw, e := l.Accept() if e!= nil { // error handle return e } tempDelay = 0 c, err := srv.newConn(rw) if err!= nil { continue } c.setState(c.rwc, StateNew) // before Serve can return go c.serve() } }
Zunächst ist net.Listen
für das Überwachen des Netzwerkports zuständig. rw, e := l.Accept()
ruft dann die TCP-Verbindung vom Netzwerkport ab, und go c.server()
erzeugt für jede TCP-Verbindung eine Goroutine, um sie zu verarbeiten. Ich habe auch erwähnt, dass das fasthttp-Netzwerk-Framework eine bessere Leistung als das native net/http
-Framework aufweist, und einer der Gründe dafür ist die Verwendung eines Goroutine-Pools. Wenn wir also selbst einen Goroutine-Pool implementieren würden, wie würden wir das machen? Beginnen wir mit der einfachsten Implementierung.
1. Schwache Version
In Go werden Goroutinen mit dem Schlüsselwort go
gestartet. Goroutine-Ressourcen unterscheiden sich von temporären Objektpools; sie können nicht zurückgelegt und wieder abgerufen werden. Goroutinen sollten also kontinuierlich laufen. Sie laufen bei Bedarf und blockieren, wenn sie nicht benötigt werden, was wenig Einfluss auf die Planung anderer Goroutinen hat. Und die Aufgaben von Goroutinen können über Kanäle weitergegeben werden. Hier kommt eine einfache, schwache Version:
func Gopool() { start := time.Now() wg := new(sync.WaitGroup) data := make(chan int, 100) for i := 0; i < 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() for _ = range data { fmt.Println("goroutine:", n, i) } }(i) } for i := 0; i < 10000; i++ { data <- i } close(data) wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) }
Der obige Code berechnet auch die Laufzeit des Programms. Zum Vergleich hier eine Version ohne Pool:
func Nopool() { start := time.Now() wg := new(sync.WaitGroup) for i := 0; i < 10000; i++ { wg.Add(1) go func(n int) { defer wg.Done() //fmt.Println("goroutine", n) }(i) } wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) }
Schließlich läuft der Code mit dem Goroutine-Pool etwa 2/3 der Zeit des Codes ohne Pool. Natürlich ist dieser Test noch etwas grob. Als Nächstes verwenden wir die in dem Reflect-Artikel vorgestellte Go-Benchmark-Testmethode zum Testen. Der Testcode ist wie folgt (viele irrelevante Codes wurden entfernt):
package pool import ( "sync" "testing" ) func Gopool() { wg := new(sync.WaitGroup) data := make(chan int, 100) for i := 0; i < 10; i++ { wg.Add(1) go func(n int) { defer wg.Done() for _ = range data { } }(i) } for i := 0; i < 10000; i++ { data <- i } close(data) wg.Wait() } func Nopool() { wg := new(sync.WaitGroup) for i := 0; i < 10000; i++ { wg.Add(1) go func(n int) { defer wg.Done() }(i) } wg.Wait() } func BenchmarkGopool(b *testing.B) { for i := 0; i < b.N; i++ { Gopool() } } func BenchmarkNopool(b *testing.B) { for i := 0; i < b.N; i++ { Nopool() } }
Die endgültigen Testergebnisse sind wie folgt. Der Code mit dem Goroutine-Pool hat tatsächlich eine kürzere Ausführungszeit.
$ go test -bench='.' gopool_test.go
BenchmarkGopool-8 500 2696750 ns/op
BenchmarkNopool-8 500 3204035 ns/op
PASS
2. Verbesserte Version
Für einen guten Thread-Pool haben wir oft mehr Anforderungen. Eine der dringendsten Anforderungen ist die Möglichkeit, die Funktion anzupassen, die die Goroutine ausführt. Eine Funktion ist nichts weiter als eine Funktionsadresse und Funktionsparameter. Was ist, wenn die zu übergebenden Funktionen unterschiedliche Formen haben (unterschiedliche Parameter oder Rückgabewerte)? Eine relativ einfache Methode ist die Einführung von Reflection.
type worker struct { Func interface{} Args []reflect.Value } func main() { var wg sync.WaitGroup channels := make(chan worker, 10) for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for ch := range channels { reflect.ValueOf(ch.Func).Call(ch.Args) } }() } for i := 0; i < 100; i++ { wk := worker{ Func: func(x, y int) { fmt.Println(x + y) }, Args: []reflect.Value{reflect.ValueOf(i), reflect.ValueOf(i)}, } channels <- wk } close(channels) wg.Wait() }
Die Einführung von Reflection bringt jedoch auch Leistungsprobleme mit sich. Der Goroutine-Pool wurde ursprünglich entwickelt, um Leistungsprobleme zu lösen, aber jetzt wurde ein neues Leistungsproblem eingeführt. Was sollen wir also tun? Closures.
type worker struct { Func func() } func main() { var wg sync.WaitGroup channels := make(chan worker, 10) for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for ch := range channels { //reflect.ValueOf(ch.Func).Call(ch.Args) ch.Func() } }() } for i := 0; i < 100; i++ { j := i wk := worker{ Func: func() { fmt.Println(j + j) }, } channels <- wk } close(channels) wg.Wait() }
Es ist erwähnenswert, dass in Go Closures leicht zu Problemen führen können, wenn sie nicht richtig verwendet werden. Ein wichtiger Punkt beim Verständnis von Closures ist der Bezug zu einem Objekt und nicht zu einer Kopie. Dies ist nur eine vereinfachte Version der Goroutine-Pool-Implementierung. Bei der tatsächlichen Implementierung müssen viele Details berücksichtigt werden, z. B. das Einrichten eines Stoppkanals, um den Pool zu stoppen. Aber der Kern des Goroutine-Pools liegt hier.
3. Beziehung zwischen Goroutine-Pool und CPU-Kernen
Besteht also ein Zusammenhang zwischen der Anzahl der Goroutinen im Goroutine-Pool und der Anzahl der CPU-Kerne? Dies muss tatsächlich in verschiedenen Fällen erörtert werden.
1. Goroutine-Pool wird nicht vollständig ausgelastet
Dies bedeutet, dass sobald sich Daten im Channel Data
befinden, diese von der Goroutine entfernt werden. In diesem Fall ist es natürlich optimal, wenn die CPU dies planen kann, d. h. die Anzahl der Goroutinen im Pool und die Anzahl der CPU-Kerne übereinstimmen. Tests haben dies bestätigt.
2. Daten im Channel Data
werden blockiert
Dies bedeutet, dass es nicht genügend Goroutinen gibt. Wenn die laufenden Aufgaben von Goroutinen nicht CPU-intensiv sind (die meisten Fälle sind es nicht) und nur durch E/A blockiert werden, gilt im Allgemeinen: Je mehr Goroutinen innerhalb eines bestimmten Bereichs vorhanden sind, desto besser. Der spezifische Bereich muss natürlich anhand der jeweiligen Situation analysiert werden.
Leapcell: Die Serverless-Plattform der nächsten Generation für das Golang-App-Hosting
Schließlich möchte ich eine Plattform empfehlen, Leapell, die sich am besten für die Bereitstellung von Golang-Diensten eignet.
1. Unterstützung mehrerer Sprachen
- 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üheloses Setup.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
5. Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Parallelität.
- Kein Betriebsaufwand – konzentrieren Sie sich einfach auf den Aufbau.
Erfahren Sie mehr in der Dokumentation!
Leapcell Twitter: https://x.com/LeapcellHQ