GoでGoroutineプールを実装する方法
James Reed
Infrastructure Engineer · Leapcell

0. はじめに
以前、GoのネイティブHTTPサーバーがクライアント接続を処理する際、接続ごとにgoroutineを生成すると述べましたが、これはかなり強引なアプローチです。より深く理解するために、Goのソースコードを見てみましょう。まず、最もシンプルなHTTPサーバーを次のように定義します。
func myHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello there!\n") } func main() { http.HandleFunc("/", myHandler) // アクセスルートの設定 log.Fatal(http.ListenAndServe(":8080", nil)) }
エントリーポイントhttp.ListenAndServe
関数を追ってみましょう。
// 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() } }
まず、net.Listen
はネットワークポートでのリスンを担当します。次に、rw, e := l.Accept()
はネットワークポートからTCP接続を取得し、go c.server()
は各TCP接続を処理するためにgoroutineを生成します。また、fasthttpネットワークフレームワークはネイティブのnet/http
フレームワークよりもパフォーマンスが優れていると述べましたが、その理由の一つはgoroutineプールを使用していることです。では、自分でgoroutineプールを実装するとしたら、どのように実装するでしょうか?最もシンプルな実装から始めましょう。
1. 弱いバージョン
Goでは、goroutineはgo
キーワードを使って起動されます。Goroutineのリソースは一時的なオブジェクトプールとは異なり、戻して再利用することはできません。したがって、goroutineは継続的に実行されるべきです。必要なときに実行され、必要でないときはブロックされ、他のgoroutineのスケジューリングにほとんど影響を与えません。そして、goroutineのタスクはチャネルを介して渡すことができます。ここにシンプルな弱いバージョンがあります。
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)) }
上記のコードでは、プログラムの実行時間も計算しています。比較のために、プールを使用しないバージョンを次に示します。
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)) }
最後に、実行時間を比較すると、goroutineプールを使用するコードは、プールを使用しないコードの約2/3の時間で実行されます。もちろん、このテストはまだ少し粗いです。次に、reflectの記事で紹介したGoのベンチマークテスト方法を使用してテストします。テストコードは次のとおりです(多くの無関係なコードは削除されています)。
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() } }
最終的なテスト結果は次のとおりです。goroutineプールを使用するコードの方が、確かに実行時間が短くなっています。
$ go test -bench='.' gopool_test.go
BenchmarkGopool-8 500 2696750 ns/op
BenchmarkNopool-8 500 3204035 ns/op
PASS
2. アップグレード版
優れたスレッドプールには、より多くの要件があることがよくあります。最も緊急のニーズの1つは、goroutineが実行する関数をカスタマイズできることです。関数は、関数アドレスと関数パラメータにすぎません。渡される関数が異なる形式(異なるパラメータまたは戻り値)の場合はどうなるでしょうか?比較的簡単な方法は、リフレクションを導入することです。
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() }
ただし、リフレクションを導入すると、パフォーマンスの問題も発生します。goroutineプールは元々パフォーマンスの問題を解決するために設計されましたが、ここにきて新しいパフォーマンスの問題が発生しました。では、どうすればいいのでしょうか?クロージャです。
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() }
Goでは、クロージャを適切に使用しないと、問題が発生しやすいことに注意してください。クロージャを理解する上での重要なポイントは、コピーではなくオブジェクトへの参照です。これは、goroutineプールの実装の簡略化されたバージョンにすぎません。実際に実装する場合は、プールを停止するための停止チャネルの設定など、多くの詳細を考慮する必要があります。しかし、goroutineプールの核となるのはここにあります。
3. GoroutineプールとCPUコアの関係
では、goroutineプール内のgoroutineの数とCPUコアの数に関係はあるのでしょうか?これは、実際には異なるケースで議論する必要があります。
1. Goroutineプールが十分に活用されていない場合
これは、channel data
にデータが入るとすぐに、goroutineによって取り去られることを意味します。この場合、もちろん、CPUがそれをスケジュールできる限り、つまり、プール内のgoroutineの数とCPUコアの数が最適な状態になります。テストでこれが確認されています。
2. channel data
内のデータがブロックされている場合
これは、十分なgoroutineがないことを意味します。goroutineの実行タスクがCPU負荷が高くない場合(ほとんどの場合そうではありません)、I/Oによってのみブロックされている場合、一般的に、一定の範囲内であれば、goroutineの数が多いほど有利です。もちろん、具体的な範囲は、具体的な状況に応じて分析する必要があります。
Leapcell: Golangアプリのホスティングのための次世代サーバーレスプラットフォーム
最後に、Golangサービスのデプロイに最適なプラットフォーム**Leapell**をお勧めします。
1. 複数言語のサポート
- JavaScript、Python、Go、またはRustで開発できます。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量に応じて支払い — リクエストなし、料金なし。
3. 比類なきコスト効率
- アイドル料金なしの従量課金制。
- 例:25ドルで平均応答時間60msで694万リクエストをサポートします。
4. 合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的な洞察のためのリアルタイムのメトリクスとロギング。
5. 簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するための自動スケーリング。
- 運用上のオーバーヘッドはゼロ — 構築に集中するだけです。
Leapcell Twitter: https://x.com/LeapcellHQ