Go が従来のスレッド モデルよりも優れた同時実行性を処理する方法
現代のソフトウェア エンジニアリングでは、複数のタスクを同時に実行できるアプリケーションを構築することはもはや贅沢ではなく、中核的な要件となっています。高スループットの Web サーバーからリアルタイム ストリーミング サービスに至るまで、同時実行性はパフォーマンスの中心です。
何十年もの間、C++、Java、Python などの従来のプログラミング言語は、オペレーティング システムのネイティブ スレッド モデルに依存して同時タスクを処理していました。しかし、2000 年代後半に Google が Go (Golang) を設計したとき、彼らは根本的に異なる道を歩みました。 Go は、生の OS スレッドを公開する代わりに、Goroutines と特殊な M:N Scheduler を導入しました。
この記事では、従来のスレッド モデルのアーキテクチャ上の制限を検討し、Go の同時実行設計が大幅に効率的でスケーラブルで開発者に優しい理由を探ります。
1. 従来のスレッディング (1:1 モデル) のボトルネック
従来のランタイム システムのほとんどは 1:1 スレッド モデル を使用します。このモデルでは、ユーザー空間コードで作成されたすべてのスレッドが、オペレーティング システム (OS) によって管理される 1 つのカーネル空間スレッドに直接マップされます。
この 1:1 マッピングは単純ですが、次の 3 つの重大なボトルネックを引き起こします。
A. メモリのオーバーヘッド (大きなスタック サイズ)
OS スレッドは重いリソースです。デフォルトでは、オペレーティング システムは各スレッドに固定の連続したスタック サイズ (通常は 1MB ~ 8MB) を割り当てます。
- 10,000 の同時接続を処理する場合、10,000 の OS スレッドを割り当てるには、スレッド スタック メモリだけでも 10GB ~ 80GB の RAM が必要になります。
- これにより、接続数が多い場合の高い同時実行性の処理は非常に高価になり、標準的なサーバー ハードウェアでは事実上不可能になります。
B. 高いコンテキスト切り替えコスト
OS は、あるスレッドから別のスレッドに実行を切り替えるときに、コンテキスト スイッチを実行します。 OS スレッドはカーネルによって管理されるため、コンテキストの切り替えにはユーザーとカーネルの境界を越える必要があります。 CPU は次のことを行う必要があります。
- 現在のレジスタの状態を保存します。
- CPU キャッシュ ラインをフラッシュし、ページ テーブル (変換ルックアサイド バッファ) を更新します。
- カーネル モードにジャンプし、次のスレッドを選択し、その保存された状態をロードします。
- ユーザーモードに戻ります。
このラウンドトリップ全体には約 1 ~ 2 マイクロ秒かかります。これは、実際のビジネス ロジックの実行ではなく、純粋に管理オーバーヘッドに費やされる数百または数千の CPU サイクルに相当します。
C. OS スケジュールの制限
OS スケジューラは汎用です。データベース スレッド、UI スレッド、および軽量ネットワーク接続を同じスケジューリング ヒューリスティックで扱います。アプリケーションレベルのコンテキストを理解できないため、スレッドがネットワークソケット上でブロックされているか、内部ロックを待機しているか、アクティブな計算を実行しているかに基づいてスケジューリングを最適化できません。
2. Go の解決策: M:N スケジューラ (Goroutines)
Go は Goroutines を導入し、独自のランタイム スケジューラを実装することで、OS スレッドの制限を回避します。 Go では、スレッドを 1:1 にマッピングするのではなく、M 個の軽量ゴルーチンが N 物理 OS スレッドに多重化される M:N スケジューリング モデル を使用します。
このアーキテクチャは、GMP モデル と呼ばれることが多い、次の 3 つの主要な構造要素によって管理されます。
- G (ゴルーチン): 単一のゴルーチンを表します。これには、ゴルーチンの実行スタック、プログラム カウンター、および状態が含まれます。
Gは OS スレッドではありません。これは単純な Go 構造体であり、初期化に必要なメモリの量はわずか 2KB です。 - M (Machine): 物理 OS/カーネル スレッドを表します。これは OS スケジューラによって管理され、ゴルーチンのマシンコード命令の実行を担当します。
- P (プロセッサ): 論理プロセッサまたは実行コンテキストを表します。
Pインスタンスの数のデフォルトは、ホスト マシン上の論理 CPU コアの数です (GOMAXPROCSによって制御されます)。 Go コードを実行するには、マシンMが論理プロセッサPを取得する必要があります。
GMP モデルが優れている理由
ゴルーチンは Go ランタイムによって完全にユーザー空間で管理されるため、次のようになります。
- 動的スタック: goroutine は 2KB の小さなスタックから始まります。実行の要求に応じて、スタックは動的に拡大し (ヒープ内でより大きな連続メモリ セグメントを割り当てます)、縮小します。これにより、Go は 1 台のラップトップ上で数十万のゴルーチンを同時に実行できるようになります。
- 高速コンテキスト スイッチ: ゴルーチン間の切り替えは、カーネル コンテキスト スイッチを呼び出すことなく、完全にユーザー空間で行われます。プログラム カウンターといくつかの CPU レジスタのみが保存されます。このユーザー空間のコンテキストの切り替えには、10 ~ 100 ナノ秒しかかかりません。これは、OS スレッドの切り替えよりも約 10 倍から 100 倍高速です。
3. ワークスチールとノンブロッキング I/O
Go ランタイムは、ワーク スティーリング と Syscall Hand-off という 2 つの高度なスケジューリング メカニズムを使用して、物理 CPU コアが十分に活用されないようにします。
A. 仕事盗用アルゴリズム
各論理プロセッサ P は、ゴルーチンの独自のローカル実行キューを維持します。さらに、オーバーフロー用のグローバル実行キューがあります。
プロセッサ P がローカル実行キュー内のゴルーチンをすべて使い果たした場合、プロセッサはスリープ状態になりません。代わりに、ワーク スティーリングを実行します。他の論理プロセッサをチェックし、キューに入れられたゴルーチンの半分を盗み、すべての CPU コアにわたってワークロードのバランスをとります。
Local Queue P1: [ G1, G2, G3 ] ---> Running on Thread M1
Local Queue P2: [ ] ---> Running on Thread M2 (Idle)
P2 steals G3 and G2 from P1!
B. ネットワーク ポーラーとノンブロッキング I/O
goroutine がネットワーク I/O (データベース接続からの読み取りや HTTP 呼び出しなど) を実行するとき、Go は基礎となる OS スレッド M をブロックしません。
代わりに、Go ランタイムは専用の ネットワーク ポーラー (Linux の epoll、macOS の kqueue、Windows の IOCP などの効率的な OS 固有の多重化 API を使用します) にブロックを登録します。ブロックされたゴルーチン G はスレッド M から切り離され、ネットワーク ポーラーに保留されます。
スレッド M は、キューから別の実行可能な goroutine をすぐに取得します。 I/O イベントが完了すると、ネットワーク ポーラーは G をアクティブな実行キューに戻し、実行を再開します。
4. チャネルと共有メモリ (CSP モデル)
従来のスレッド モデルは、メモリを共有する (スレッド間でポインタを渡すなど) ことによって同時タスクを調整します。データ競合を防ぐために、開発者はロック、ミューテックス、条件変数を手動で管理する必要があります。
// Traditional Java Shared Memory Approach
synchronized(lock) {
sharedResource.updateState();
}
このモデルはエラーが発生しやすいことで知られており、デッドロック、競合状態、キャッシュ コヒーレンシのボトルネックが頻繁に発生します。
Go は Communicating Sequential Processes (CSP) 正式モデルを実装しています。これは、Golang の有名な格言で要約されています。
「記憶を共有することでコミュニケーションするのではなく、通信することで記憶を共有する。」
Go は、ファーストクラスのプリミティブとして チャネル を提供します。チャネルはタイプセーフなキューとして機能し、ゴルーチンがメッセージを送受信して実行を同期し、データの所有権を転送できるようにします。
5. 実際の実装: ゴルーチンとチャネルの動作
以下は、複数のワーカー ゴルーチンがタスクを同時に処理し、チャネルを通じて結果を返し、ロックなしでデータ フローを安全に管理する方法を示す実用的な Go プログラムです。
package main
import (
"fmt"
"time"
)
// Task represents a unit of work
type Task struct {
ID int
Duration time.Duration
}
// Result represents the outcome of a processed task
type Result struct {
TaskID int
Value string
}
// worker processes incoming tasks from the jobs channel and sends results
func worker(id int, jobs <-chan Task, results chan<- Result) {
for job := range jobs {
fmt.Printf("Worker %d: Started processing job %d\n", id, job.ID)
time.Sleep(job.Duration) // Simulate CPU/IO delay
results <- Result{
TaskID: job.ID,
Value: fmt.Sprintf("Processed by worker %d in %v", id, job.Duration),
}
}
}
func main() {
// Create tasks
tasks := []Task{
{ID: 101, Duration: 200 * time.Millisecond},
{ID: 102, Duration: 400 * time.Millisecond},
{ID: 103, Duration: 100 * time.Millisecond},
{ID: 104, Duration: 300 * time.Millisecond},
}
// Buffered channels prevent sender blocking
jobs := make(chan Task, len(tasks))
results := make(chan Result, len(tasks))
// Spawn 3 concurrent worker goroutines
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Feed tasks into the job channel
for _, task := range tasks {
jobs <- task
}
close(jobs) // Closing tells workers no more jobs are coming
// Collect and aggregate results
for i := 0; i < len(tasks); i++ {
res := <-results
fmt.Printf("[RESULT] Job %d: %s\n", res.TaskID, res.Value)
}
close(results)
}
6. アーキテクチャの比較: OS スレッドとゴルーチン
| 指標 / 機能 | 従来の OS スレッド (1:1 モデル) | Go ゴルーチン (M:N モデル) |
|---|---|---|
| 起動メモリ | 固定 (通常 1MB ~ 8MB) | 動的 (~2KB から開始) |
| コンテキスト切り替え時間 | 遅い (1,000 ~ 2,000 ナノ秒) | 高速 (10 ~ 100 ナノ秒) |
| スイッチングスペース | カーネルスペース (ヘビーコンテキストスイッチ) | ユーザースペース (ランタイムスケジューラ) |
| 作成コスト | 高価 (OS システムコールを含む) | 激安(単純配分)| |
| コミュニケーション | 共有メモリ (ミューテックス、セマフォ) | チャネル (CSP メッセージ パッシング) |
| デッドロックのリスク | 高 (手動で追跡するのが困難) | チャネル設計とコンパイルランタイム検出により軽減 |
## 結論
Go は、オペレーティング システムの生のスレッド モデルから同時実行性を切り離すことにより、最新のバックエンド アーキテクチャの基本的なスケーリング問題を解決しました。
ゴルーチンは最小限のメモリ オーバーヘッドで大規模な同時実行を可能にし、M:N スケジューラはカーネル コンテキストの切り替えコストを発生させずにワーク スティーリングを通じて CPU 使用率を最適化し、チャネルは安全で表現力豊かな同時実行パラダイムを提供します。
マイクロサービスを構築している場合でも、大規模な分散システムを構築している場合でも、Go に組み込まれた同時実行アーキテクチャにより、アプリケーションの応答性、リソース効率、および保守の容易さが確保されます。