Goスケジューラの秘密を解き明かす G-M-Pモデルの実践
Wenhao Wang
Dev Intern · Leapcell

はじめに
現代のソフトウェア開発の領域では、応答性が高くスケーラブルなアプリケーションを構築するための基盤として、並行処理が不可欠となっています。Goは、組み込みのゴルーチンとチャネルにより、並行プログラミングの課題に取り組むための強力な言語としての地位を確立しています。しかし、Goの並行処理の背後にある独創性は、その表現力豊かな構文だけでなく、非常に効率的で洗練されたスケジューラーにもあります。このスケジューラーは、数千、あるいは数百万ものゴルーチンの実行を透過的に管理し、CPU利用率を最大化し、レイテンシを最小限に抑える、縁の下の力持ちです。Goがこの驚くべき偉業をどのように達成するのかを理解することは、真に高性能なGoアプリケーションを書きたいと願うあらゆる開発者にとって不可欠です。この記事では、Goスケジューラーの核心、特にその基本的なG-M-Pモデルに焦点を当て、その動作を解明し、Goの並行処理能力の背後にある魔法を明らかにします。
Go並行処理の基盤 GMP解説
スケジューラーの仕組みを解剖する前に、Goの並行処理モデルの基盤を形成するコアコンポーネントを明確に理解しましょう。
- ゴルーチン (G): ゴルーチンは、独立して実行される軽量な関数またはメソッドです。これは、少数のOSスレッドに多重化されます。ゴルーチンはスレッドに似ていますが、作成と管理ははるかに安価です。数千、あるいは数百万ものゴルーチンが、最小限のオーバーヘッドで並行して実行できます。
- マシン (M): "スレッド"としても知られるマシンは、オペレーティングシステムのスレッドを表します。これは、オペレーティングシステムスケジューラーが見てディスパッチするものです。Goは、ゴルーチンをMスレッドのプールにマッピングします。アクティブなMスレッドの数は、通常、利用可能なCPUコアの数に関連付けられています。
- プロセッサ (P): プロセッサは、論理プロセッサまたは実行キューです。これは、ゴルーチン用のローカルスケジューラーとして機能します。Pは、実行準備ができたゴルーチンのローカル実行キューを保持します。各Mは、ゴルーチンを実行するために、関連付けられたPを必要とします。Pの数は、
GOMAXPROCS
環境変数によって決定され、デフォルトでは論理CPUコアの数に設定されます。
G-M-Pモデルは、ゴルーチン(G)を論理プロセッサ(P)にバインドすることでゴルーチンの実行を調整し、それらがオペレーティングシステムスレッド(M)によって実行されます。Pをゴルーチンの駐車場、MをドライバーThinkしてください。ドライバー(M)は、駐車場(P)から車(G)を拾って運転します。駐車場よりも車が多い場合、一部の車はグローバルキューで待機する必要があるかもしれません。
G-M-Pモデルの仕組み
ゴルーチンスケジューリングの典型的なフローを分解してみましょう。
- ゴルーチンの作成:
go
キーワードを使用して新しいゴルーチンが作成されると、最初は利用可能なPのローカル実行キューに配置されます。ローカルキューがいっぱいの場合、またはアイドル状態のPがない場合、ゴルーチンはグローバル実行キューに移動される可能性があります。 - ゴルーチンの実行: PにバインドされたMは、Pのローカル実行キューから継続的にゴルーチンを取得します。Mがゴルーチンを実行すると、ゴルーチンが以下のいずれかの状態になるまで実行されます。
- ブロックする(例:I/O、ミューテックス、またはチャネル操作を待機する)。
- 自発的に制御を譲る(ただし、ユーザランドコードではまれです)。
- 実行を完了する。
- ブロッキング操作: M上のゴルーチンがシステムコール(例:ネットワークI/OまたはファイルI/O)でブロックすると、MはそのPからデタッチされ、Pは別のMが取得できるようになります。これにより、あるゴルーチンでのブロッキング操作が、P全体を停止させることを防ぎます。ブロッキングシステムコールが返された後、元のゴルーチンは実行を再開するためにPを再度取得しようとします。Pが利用できない場合、実行キューに戻されます。
- ワークスティーリング: Pに関連付けられたMが、ローカル実行キューが空であることに気付いた場合、単にアイドル状態になるわけではありません。代わりに、他のPのローカル実行キューからゴルーチンを「盗む」ことを試みます。ワークスティーリングとして知られるこのメカニズムは、ロードバランシングと、すべての利用可能なP全体でのCPU利用率の最大化に不可欠です。スケジューラーは通常、作業を均等に分散するために、別のPの実行キューの半分を盗もうとします。
- グローバル実行キュー: すぐにPを見つけられないゴルーチン、またはワークスティーリングのために孤立したゴルーチンに対して、グローバル実行キューがフォールバックとして機能します。Mは、ローカルPのキューや他のPのキューが空の場合、グローバル実行キューをチェックします。
並行処理を示すコード例
ゴルーチンを使用した並行タスクの簡単な例を考えてみましょう。
package main import ( "fmt" "runtime" "sync" "time" ) func worker(id int, wg *sync.WaitGroup) { defer wg.Done() // ゴルーチンが終了したらWaitGroupカウンターをデクリメントする fmt.Printf("Worker %d starting\n", id) time.Sleep(time.Duration(id) * 100 * time.Millisecond) // いくらかの作業をシミュレートする fmt.Printf("Worker %d finished\n", id) } func main() { fmt.Printf("Number of logical CPUs: %d\n", runtime.NumCPU()) fmt.Printf("GOMAXPROCS initially set to: %d\n", runtime.GOMAXPROCS(0)) // 現在のGOMAXPROCSを取得する // パラレリズムを低く観察するためにGOMAXPROCSを1に設定することもできます // runtime.GOMAXPROCS(1) // fmt.Printf("GOMAXPROCS set to: %d\n", runtime.GOMAXPROCS(0)) var wg sync.WaitGroup snumWorkers := 5 for i := 1; i <= numWorkers; i++ { wg.Add(1) // 各ゴルーチンに対してWaitGroupカウンターをインクリメントする go worker(i, &wg) } wg.Wait() // すべてのゴルーチンが終了するまで待機する fmt.Println("All workers completed") }
このコードを実行すると、ワーカー関数は異なるスリープ時間を持つにもかかわらず、多くの場合並行して開始および終了することがわかります。これは、Goスケジューラーがこれらのゴルーチンを利用可能なPに分散していることを示しています。runtime.GOMAXPROCS(1)
のコメントを解除すると、1つのP(したがって、ユーザランドゴルーチンを実行するMは1つ)しか利用できないため、よりシーケンシャルな実行が観察される可能性が高くなります。これは、GOMAXPROCS
が並列処理レベルにどのように直接影響するかを強調しています。
結論
Goスケジューラーは、その巧妙なG-M-Pモデルにより、並行プログラミングの驚異です。スレッド管理の複雑さを抽象化し、ワークスティーリングやブロッキング操作の効率的な処理などのメカニズムを利用することで、開発者にとって強力で驚くほどシンプルな並行モデルを提供します。ゴルーチン、マシン、プロセッサの連携を理解することは、基盤となるハードウェアを効果的に活用する、高性能でスケーラブルなGoアプリケーションを記述するための鍵となります。Goスケジューラーはゴルーチン実行を効率的に調整し、Goでの並行プログラミングを強力かつ驚くほど扱いやすくします。