GoSuda

Go ゴルーチン(Goroutine)の動作原理: 2KB の可変スタックと M:N GMP スケジューラ

By hamori views ...

Goroutine

Gopherに対してGolangの利点について尋ねると、よく挙がる話題が**並行性(Concurrency)に関するものです。その内容の基盤となるのは、軽量で簡単に処理できるゴルーチン(goroutine)**です。これについて簡潔にまとめてみました。

並行性(Concurrency) vs 並列性(Parallelism)

ゴルーチンを理解する前に、よく混同される二つの概念を先に確認しておきたいと思います。

  • 並行性: 並行性は、多くの仕事を一度に処理することに関するものです。実際に同時に実行されることを意味するわけではなく、複数の作業を小さな単位に分割し、交互に実行することで、利用者には同時に複数の作業が処理されているように見せる構造的・論理的な概念です。シングルコアでも並行性は可能です。
  • 並列性: 並列性は「複数のコアで複数の仕事を同時に処理すること」です。文字通り並列に作業を進めることであり、他の作業を同時に実行します。

ゴルーチンは、Goランタイムスケジューラを通じて並行性を容易に実現させ、GOMAXPROCSの設定により並列性まで自然に活用します。

利用率の高いJavaのマルチスレッド(Multi thread)は、並列性の代表的な概念です。

ゴルーチンはなぜ優れているのか?

軽量である(lightweight)

生成コストが他の言語と比べて非常に低いです。ここで、なぜGolangは少なく使用するのかという疑問が生じますが、生成位置がGoランタイム内部で管理されているためです。なぜなら、上記の軽量な論理スレッドだからです。OSスレッド単位より小さく、初期スタックは約2KBのサイズを必要とし、ユーザーの実装に応じてスタックを追加し動的に可変するためです。

スタック単位で管理するため、生成と削除が非常に速く安価に処理でき、数百万個のゴルーチンを動かしても負担のない処理が可能です。これにより、Goroutineはランタイムスケジューラのおかげで、OSカーネルの介入を最小限に抑えることができます。

性能が良い(performance)

まず、Goroutineは上記の説明のとおりOSカーネルの介入が少ないため、ユーザーレベル(User-Level)でのコンテキストスイッチングにおいて、OSスレッド単位よりコストが安く、迅速に作業を切り替えることができます。

加えて、M:Nモデルを利用してOSスレッドに割り当てて管理します。OSスレッドプールを作成することで、多くのスレッドが不要で、少ないスレッドでも処理が可能です。例えば、システムコールのような待機状態に陥ると、Goランタイムは当該OSスレッドから他のゴルーチンを実行させるため、OSスレッドは休むことなく効率的にCPUを活用し、迅速な処理が可能です。

これにより、Golangは特にI/O作業において、他の言語と比べて高い性能を発揮できます。

簡潔である(concise)

並行性が必要な場合、goキーワード一つで関数を容易に処理できることも大きな利点です。

Mutex、Semaphoreなどの複雑なLockを使用しなければならず、Lockを利用すると必然的に考慮すべきデッドロック(DeadLock)状態を考慮せざるを得ないため、開発前の設計段階から複雑な工程が必要になります。

Goroutineは「メモリを共有して通信するのではなく、通信してメモリを共有せよ」という哲学に従い、チャネル(Channel)を介したデータ伝達を推奨しており、SELECTはチャネル(Channel)と組み合わせて、データが準備されたチャネルから処理できるようにする機能までサポートしています。また、sync.WaitGroupを利用すると、複数のゴルーチンがすべて終了するまで簡単に待機できるため、作業の流れを容易に管理できます。これらのツールのおかげで、スレッド間のデータ競合問題を防止し、より安全に並行処理が可能です。

さらに、コンテキスト(context)を利用することで、ユーザーレベル(User-Level)において、ライフサイクル、キャンセル、タイムアウト、デッドライン、リクエストスコープを制御でき、ある程度の安定性を保証できます。

Goroutineの並列処理(GOMAXPROCS)

ゴルーチンの並行性の利点については述べましたが、並列処理はサポートしていないのか?という疑問を持たれるかもしれません。最近のCPUのコア数は過去と異なり二桁を超えており、家庭用PCにもかなり多くのコアが搭載されているためです。

しかし、Goroutineは並列作業も行います。それがGOMAXPROCSです。

GOMAXPROCSを設定しない場合、バージョンごとに異なる値が設定されます。

  1. 1.5以前: デフォルト値は1であり、1以上が必要な場合はruntime.GOMAXPOCS(runtime.NumCPU())のような方法で設定することが必須

  2. 1.5~1.24: 利用可能なすべての論理コア数に変更されました。この時点から、特別な制約が必要な場合を除き、開発者が設定する必要はありません

  3. 1.25: コンテナ環境で評判の高い言語らしく、linux上のcGroupを確認し、コンテナに設定されたCPU制限を確認します。

    例えば、論理コア数が10個でCPU制限値が5である場合、GOMAXPROCSはより低い値である5に設定されます。

1.25の修正は非常に大きな変更点を持っています。コンテナ環境における言語の活用度が向上したためです。これにより、不要なスレッド生成やコンテキストスイッチングを減らし、CPUスロットリング(throttling)を防止できるようになりました。

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: 開始\n", name)
14	time.Sleep(10 * time.Millisecond) // 作業シミュレーションのための遅延
15	fmt.Printf("Goroutine %d: 開始\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // CPUコアを2つだけ使用
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("すべてのgoroutineが終了するまで待機します...")
29	wg.Wait()
30	fmt.Println("すべての作業が完了しました。")
31
32}
33

Goroutineのスケジューラ (M:Nモデル)

前述のM:Nモデルを利用してOSスレッドに割り当てて管理するという部分について、より具体的に見ると、goroutineにはGMPモデルがあります。

  • G (Goroutine): Goで実行される最小の作業単位
  • M (Machine): OSスレッド(実際の作業位置)
  • P (Processor): Goランタイムが管理する論理的なプロセッサ

です。Pは追加でローカル実行キュー(Local Run Queue)を持ち、割り当てられたGをMに配分するスケジューラの役割を果たします。簡単に言えば、goroutineは

GMPの動作過程は以下のとおりです。

  1. G(Goroutine)が生成されると、P(Processor)のローカル実行キューに割り当てを行います。
  2. P(Processor)はローカル実行キューにあるG(Goroutine)をM(Machine)に割り当てます。
  3. M(Machine)はG(Goroutine)の状態であるblock、complete、preemptedを返します。
  4. Work-Stealing(作業の窃取): Pのローカル実行キューが空になった場合、他のPはグローバルキューを確認します。そこにもG(Goroutine)がなければ、他のローカルP(Processor)の作業を奪い取り、すべてのMが休むことなく動作するようにします。
  5. システムコール処理(Blocking): G(Goroutine)の実行中にBlockが発生すると、M(Machine)は待機状態になりますが、この時P(Processor)はBlockされたM(Machine)から分離され、別のM(Machine)と結合して次のG(Goroutine)を実行します。このため、I/O作業の待機時間においてもCPUの無駄がありません。
  6. 一つのG(Goroutine)が長時間プリエンプト(preempted)された場合、他のG(Goroutine)に実行の機会を与えます。

GolangのGC(Garbage Collector)もGoroutine上で実行されるため、アプリケーションの実行を最小限にのみ中断(STW)しながら、並列的にメモリを整理することができ、システムリソースを効率的に使用します。

最後に、Golangは言語の強力な長所の一つであり、そのほかにも多くの利点があります。多くの開発者の皆さまがGoを楽しんでくださることを願っています。

ありがとうございました。