Go が従来のスレッド モデルよりも優れた同時実行性を処理する方法

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 は次のことを行う必要があります。

  1. 現在のレジスタの状態を保存します。
  2. CPU キャッシュ ラインをフラッシュし、ページ テーブル (変換ルックアサイド バッファ) を更新します。
  3. カーネル モードにジャンプし、次のスレッドを選択し、その保存された状態をロードします。
  4. ユーザーモードに戻ります。

このラウンドトリップ全体には約 1 ~ 2 マイクロ秒かかります。これは、実際のビジネス ロジックの実行ではなく、純粋に管理オーバーヘッドに費やされる数百または数千の CPU サイクルに相当します。

C. OS スケジュールの制限

OS スケジューラは汎用です。データベース スレッド、UI スレッド、および軽量ネットワーク接続を同じスケジューリング ヒューリスティックで扱います。アプリケーションレベルのコンテキストを理解できないため、スレッドがネットワークソケット上でブロックされているか、内部ロックを待機しているか、アクティブな計算を実行しているかに基づいてスケジューリングを最適化できません。


2. Go の解決策: M:N スケジューラ (Goroutines)

Go は Goroutines を導入し、独自のランタイム スケジューラを実装することで、OS スレッドの制限を回避します。 Go では、スレッドを 1:1 にマッピングするのではなく、M 個の軽量ゴルーチンが N 物理 OS スレッドに多重化される M:N スケジューリング モデル を使用します。

従来の 1:1 スレッディングと Go M:N スケジューラのモデル図

このアーキテクチャは、GMP モデル と呼ばれることが多い、次の 3 つの主要な構造要素によって管理されます。

  • G (ゴルーチン): 単一のゴルーチンを表します。これには、ゴルーチンの実行スタック、プログラム カウンター、および状態が含まれます。 G は OS スレッドではありません。これは単純な Go 構造体であり、初期化に必要なメモリの量はわずか 2KB です。
  • M (Machine): 物理 OS/カーネル スレッドを表します。これは OS スケジューラによって管理され、ゴルーチンのマシンコード命令の実行を担当します。
  • P (プロセッサ): 論理プロセッサまたは実行コンテキストを表します。 P インスタンスの数のデフォルトは、ホスト マシン上の論理 CPU コアの数です (GOMAXPROCS によって制御されます)。 Go コードを実行するには、マシン M が論理プロセッサ P を取得する必要があります。

GMP モデルが優れている理由

ゴルーチンは Go ランタイムによって完全にユーザー空間で管理されるため、次のようになります。

  1. 動的スタック: goroutine は 2KB の小さなスタックから始まります。実行の要求に応じて、スタックは動的に拡大し (ヒープ内でより大きな連続メモリ セグメントを割り当てます)、縮小します。これにより、Go は 1 台のラップトップ上で数十万のゴルーチンを同時に実行できるようになります。
  2. 高速コンテキスト スイッチ: ゴルーチン間の切り替えは、カーネル コンテキスト スイッチを呼び出すことなく、完全にユーザー空間で行われます。プログラム カウンターといくつかの 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 に組み込まれた同時実行アーキテクチャにより、アプリケーションの応答性、リソース効率、および保守の容易さが確保されます。


Ghaznix ブログでソフトウェア開発とバックエンド エンジニアリングの洞察をさらに詳しく見る→