GoSuda

Goルーチンの基本

By hamori
views ...

Goroutine

Gopherの皆様にGo言語の利点について語る際に、頻繁に登場する並行性 (Concurrency)に関する記述があります。その内容の基盤となっているのは、軽量で簡潔に処理できるGoroutineです。これについて簡単に記述しました。

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

Goroutineを理解する前に、しばしば混同される2つの概念について先に説明したいと思います。

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

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

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

Goroutineはなぜ優れているのか?

軽量である (lightweight)

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

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

性能が良い (performance)

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

その他にも、M:Nモデルを利用してOSスレッドに割り当てて管理します。OSスレッドプールを作成することで、多くのスレッドを必要とせず、少ないスレッドでも処理が可能です。例えば、システムコールのような待機状態に陥った場合、GoランタイムはOSスレッド上で別のGoroutineを実行し、OSスレッドは休むことなく効率的にCPUを活用して高速処理を可能にします。

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

簡潔である (concise)

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

MutexSemaphoreなどの複雑なLockを利用する必要があり、Lockを利用する際には必然的に考慮すべきデッドロック (DeadLock) 状態を考慮せざるを得ず、開発前の設計段階から複雑な段階が必要になります。

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

また、コンテキスト (context) を利用して、これをユーザーレベル (User-Level) でライフサイクル、キャンセル、タイムアウト、デッドライン、要求範囲を制御できるため、ある程度の安定性を保証できます。

Goroutineの並列作業 (GOMAXPROCS)

Goroutineの並行性が良い点を述べましたが、並列はサポートしないのか?という疑問を抱かれることでしょう。最近のCPUのコア数は過去とは異なり、2桁を超えており、家庭用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) に実行機会を与えます。

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

最後に、Go言語は言語の強力な利点の一つであり、他にも多くの利点がありますので、多くの開発者の皆様がGo言語を楽しんでいただければ幸いです。

ありがとうございます。