【入門編】Goランタイムの「ワークスティーリング」アルゴリズム詳解:なぜGoroutineの実行順序は保証されないのか – 実行環境・ランタイム・コンパイラ生産性向上バイブル

Goの圧倒的な並行処理、その「魔法」の裏側へようこそ

こんにちは! 日々の開発、お疲れ様です。

Go言語(Golang)といえば、「並行処理がとにかく簡単に、そして圧倒的に高速に動く」というイメージをお持ちではないでしょうか。`go`というキーワードを関数の前につけるだけで、数千、数万ものタスク(Goroutine)を同時に立ち上げられるその手軽さは、一度体験すると病みつきになりますよね。

しかし、Goを触り始めたエンジニアの多くが、ある日突然このような疑問にぶつかります。

「なぜ、Goroutineを起動した順番と、実際に実行される順番がバラバラなんだろう?」
「なぜ、ローカル環境と本番環境(CPUコア数が多い環境)で、実行順序の挙動がガラリと変わるのだろう?」

実は、この「不確定な実行順序」の裏側には、Goランタイムが持つ世界最高峰のスケジューリングアルゴリズム「ワークスティーリング(Work Stealing:仕事を盗む)」が潜んでいます。

この記事では、Goの並行処理の心臓部であるこのアルゴリズムを、どこよりも直感的、かつ低レイヤの視点からロジカルに解説します。

「動けばいいや」から脱却し、「GoランタイムがCPUとメモリをどう操っているのか」という本質をマスターしましょう。これが理解できれば、並行処理のバグに怯えることがなくなり、毎日のコーディングやデバッグが劇的に楽になりますよ!

—

1. Goランタイムの心臓部:「G-M-Pモデル」を直感的に理解する

Goのスケジューラを理解するための第一歩は、「G」「M」「P」という3つの登場人物(エンティティ)の関係性を知ることです。
GoはOSのスレッドを直接そのまま使うのではなく、その上に独自の「仮想的なスケジューラ」を構築しています。

これを、「タスクが詰まったオフィス」に例えて考えてみましょう。

| 略称 | 正式名称 | オフィスの役割に例えると | 概要 |
| :— | :— | :— | :— |
| G | Goroutine | やるべき「仕事のメモ」 | Goプログラム上の軽量スレッド。メモリ消費はわずか数KBで、何万個も作成可能。 |
| M | Machine (OS Thread) | 実際に手を動かす「労働者」 | OSの物理的なカーネルスレッド。実際にCPUコア上で命令を実行する実体。 |
| P | Processor (Logical) | 労働者が座る「デスク(権利)」 | 論理プロセッサ。Goがコードを実行するために必要な「コンテキスト(リソース)」。 |

[ G ] (仕事のメモ)
|
[ P ] (デスク / ローカルキュー)
|
[ M ] (労働者 / OSスレッド) <---> [ CPU Core ] (物理実行環境)

なぜ「M(OSスレッド)」を直接増やさないのか?

JavaやC++などの従来のスレッドモデルでは、並行処理を行おうとするとOSスレッド(M)を直接増やしがちでした。しかし、OSスレッドの作成や切り替え(コンテキストスイッチ)は、OSカーネルを呼び出すため非常に「重い」処理です。メモリも1スレッドあたり数MB消費します。

そこでGoは、「重いOSスレッド(M)はCPUコア数と同数程度に抑えつつ、その上で超軽量なタスク(G)を、デスク(P)を使って超高速に回す」という賢い戦略をとっています。このデスク(P)の最大数が、Goでおなじみの `GOMAXPROCS` という設定値です(デフォルトはマシンの物理CPUコア数)。

—

2. ワークスティーリング(Work Stealing)のメカニズム

では、本題である「ワークスティーリング(仕事を盗む)」の仕組みに迫りましょう。

各デスク(P)には、次に実行すべき仕事(G)を並べておく「ローカルランキュー(Local Run Queue)」という自分専用の引き出しがあります。この引き出しには最大で256個のGを格納できます。

普段、労働者(M)は自分のデスク(P)の引き出しから順に仕事を取り出して処理していきます。しかし、特定のデスクだけ仕事が山積みになり、別のデスクは一瞬で仕事を終えて「暇」になってしまうことがあります。

このとき、暇になった労働者(M)がとる行動こそがワークスティーリングです。

仕事を盗むステップ(優先順位)

労働者(M)は、自分のデスク(P)の引き出しが空になると、以下の順番で新しい仕事を探しに行きます。

【仕事を探すルート】
1. 自分のローカルキュー (Local Run Queue) を見る
↓ (空っぽ!)
2. 全体の共有箱「グローバルランキュー」を見に行く
↓ (空っぽ!)
3. 他のデスク (P) から、仕事を「半分」盗んでくる! (Work Stealing)

① グローバルランキュー(Global Run Queue)の確認

まず、特定のデスクに属さない、全体共有の「グローバルランキュー」を見に行きます。
(※実は、グローバルランキューばかりが無視されて仕事が滞る「飢餓状態」を防ぐため、Goスケジューラは「61回に1回」の割合で、ローカルキューより先にグローバルキューを強制的に確認するという極めて緻密な設計になっています。)

② ワークスティーリングの実行(他のPから盗む)

グローバルキューにも仕事がない場合、労働者はランダムに他のデスク(P)を指名し、その引き出し(ローカルキュー)に溜まっている仕事の「半分(正確には半分 + 1個)」を文字通り奪い取り(Steal)、自分のデスクに持ってきて実行します。

なぜ「半分」も盗むのか?

1個だけ盗むのだと、すぐにまた仕事がなくなってスティーリングを繰り返すことになり、無駄なオーバーヘッド(通信やロックの競合)が発生します。
一気に「半分」を移動させることで、システム全体の負荷(ロードバランス)を最小限のコストで均等に均すことができるのです。これが、Goが驚異的なスループットを叩き出す秘密です。

—

3. 実験:Goroutineの「不確定性」をその目で確かめる

論理を学んだら、次は実際にコードを動かして、このワークスティーリングによる「実行順序の不確定性」を体感してみましょう。

以下のコードは、10個のGoroutineを順番に起動するシンプルなプログラムです。これをマルチコア環境で実行すると、起動順(0〜9)とは全く異なる順番で実行されることが確認できます。

実験コード (`main.go`)

package main

import (
“fmt”
“runtime”
“sync”
“time”
)

func main() {
// 使用する論理プロセッサ(P)の数を、マシンの最大CPUコア数に設定します
numCPU := runtime.NumCPU()
runtime.GOMAXPROCS(numCPU)
fmt.Printf(“利用可能なCPUコア数 (Pの数): %d\n\n”, numCPU)

var wg sync.WaitGroup

// 10個の軽量タスク(Goroutine)を順番に生成して実行します
for i := 0; i < 10; i++ { wg.Add(1) // 待ち合わせカウンターをインクリメント go func(taskID int) { defer wg.Done() // 関数終了時にカウンターをデクリメント // ワークスティーリングやスレッド間移動をシミュレートするため、 // わずかな極小のウェイトを入れます time.Sleep(10 time.Microsecond) fmt.Printf("[Task %d] が実行されました\n", taskID) }(i) // ループ変数の評価値をコピーして渡す } // すべてのGoroutineが終了するのを待ちます wg.Wait() fmt.Println("\nすべてのタスクが完了しました。") }

実行結果の例(ターミナルでの出力)

実際にコマンドを実行してみましょう。

$ go run main.go
利用可能なCPUコア数 (Pの数): 8

[Task 9] が実行されました
[Task 0] が実行されました
[Task 2] が実行されました
[Task 1] が実行されました
[Task 4] が実行されました
[Task 3] が実行されました
[Task 5] が実行されました
[Task 7] が実行されました
[Task 6] が実行されました
[Task 8] が実行されました

何度か実行すると、毎回この順番が変わるはずです。
特に、最後に生成されたはずの `Task 9` が最初に実行されたりする現象が頻発します。これは、Goのスケジューラが「直近に作成されたGoroutine(G)を、キャッシュ効率を高めるために優先して実行しようとする(Next Gと呼ばれる仕組み)」ためであり、かつ、空いた他のPが即座にスティーリングを行うためです。

—

4. なぜ実行順序は保証されないのか? 設計に活かす3つの鉄則

Goのマルチスレッド(マルチコア)環境において、Goroutineの実行順序が保証されない理由は、ここまで読んだあなたならもう明確に答えられるはずです。

> 「個々のPが、自身のキューの状態や、他Pからのワークスティーリング、OSスレッド(M)の割り当て状況に応じて、自律的かつ動的に仕事を奪い合って並行処理しているから」

これはバグではなく、CPUコアを100%使い切るための「究極の効率化」がもたらす必然の仕様なのです。

では、この特性を理解した上で、実務でバグのない美しい並行処理を設計するための「3つの鉄則」を紹介します。

鉄則①:順序制御は「起動順」ではなく「チャネル(Channel)」で行う

Goroutineが動き出す順番をコントロールしようとしてはいけません。処理の「順序」や「依存関係」を制御したい場合は、必ずGoの強力な機能であるChannel(チャネル)を使用してください。

// 順序を制御するチャネルの例
step1Finished := make(chan struct{})

go func() {
// 先に実行したい処理
fmt.Println(“ステップ1完了”)
close(step1Finished) // 完了を通知
}()

go func() {
<-step1Finished // ステップ1が終わるまでここでブロックして待機 fmt.Println("ステップ2完了") }()

鉄則②:並行処理の終了同期には `sync.WaitGroup` を正しく使う

Goroutineがいつ終わるかは誰にも予測できません。「おそらくこれくらいで終わるだろう」という `time.Sleep` による時間待ち合わせは絶対に避け、`sync.WaitGroup` を使って確実なライフサイクル管理を行ってください。

鉄則③:レースコンディション(競合状態)を `-race` フラグで検出する

複数のGoroutineが同じメモリ(変数)に順序を考慮せずアクセスすると、データ破損(データレース)を引き起こします。Goにはこれをコンパイル時に検出する強力なツールが組み込まれています。

テストやビルドを行う際は、必ず `-race` フラグを付与して実行する習慣をつけましょう。

データレース(競合状態)を検出しながら実行するコマンド
$ go test -race ./…
$ go run -race main.go

—

まとめ:低レイヤを知る者が、真に美しいGoコードを書く

お疲れ様でした!
一見難しそうに思える「Goの並行処理の不確定性」も、G-M-Pモデルとワークスティーリングという仕組みを紐解けば、すべては「CPUのパワーを極限まで引き出すための合理的で美しい設計」によるものだと納得できたのではないでしょうか。

Goの内部で何が起きているのか(ランタイムの挙動)をイメージしながら書くコードは、ただ動くだけのコードとは堅牢性と優雅さが天と地ほど違います。

今回の知識を胸に、ぜひ自信を持って並行処理プログラムをデザインしていってください。あなたのGoライフが、より豊かで楽しいものになることを願っています!

—
この記事が役に立った、ここがもっと知りたいなどがあれば、ぜひコメントやシェアで教えてくださいね!

タイトルとURLをコピーしました