Goの並行性Decoded: Goroutine Scheduling
Lukas Schneider
DevOps Engineer · Leapcell

I. Goroutineの紹介
Goroutineは、Goプログラミング言語における非常に特徴的な設計であり、その主なハイライトの一つです。本質的にはコルーチンであり、並列コンピューティングを実現するための鍵となります。goroutineの使用は非常に簡単です。go
キーワードを使用するだけでコルーチンを開始でき、それは非同期的に実行されます。プログラムは、goroutineが完了するのを待たずに、後続のコードの実行を継続できます。
go func() // goキーワードを使って関数を実行するコルーチンを開始します
II. Goroutineの内部原理
概念の紹介
並行性
単一のCPU上で、複数のタスクを同時に実行できます。非常に短い時間で、CPUはタスク間を迅速に切り替えます(たとえば、プログラムAをしばらく実行した後、すぐにプログラムBに切り替えます)。時間的な重複があります(マクロな視点からは並行しているように見えますが、ミクロなレベルでは、まだ逐次的な実行です)。これにより、複数のタスクが同時に実行されているかのような錯覚を与え、これが私たちが並行性と呼ぶものです。
並列性
システムが複数のCPUを持っている場合、各CPUは自身のCPUのリソースを競合することなく、同時にタスクを実行できます。それらは同時に動作し、これが並列性として知られています。
プロセス
CPUがプログラム間を切り替える際、前のプログラムの状態(いわゆるコンテキスト)を保存せずに、直接次のプログラムに切り替えると、前のプログラムの一連の状態が失われます。この問題を解決するために、プログラムの実行に必要なリソースを割り当てるためのプロセスという概念が導入されました。したがって、プロセスはプログラムの実行に必要な基本的なリソース単位です(プログラム実行のエンティティと見なすこともできます)。たとえば、テキスト編集アプリケーションを実行する場合、このアプリケーションのプロセスは、テキストバッファのメモリ空間、ファイル処理リソースなど、すべてのリソースを管理します。
スレッド
CPUが複数のプロセス間を切り替えることは、プロセス切り替えがカーネルモードへの移行を必要とし、各スケジューリングがユーザーモードのデータの読み取りを必要とするため、かなりの時間を消費します。プロセスの数が増加するにつれて、CPUスケジューリングは大量のリソースを消費します。したがって、スレッドという概念が導入されました。スレッド自体は非常に少ないリソースしか消費せず、プロセス内のリソースを共有します。カーネルがスレッドをスケジュールする場合、プロセスをスケジュールする場合ほど多くのリソースを消費しません。たとえば、Webサーバーアプリケーションでは、複数のスレッドを使用して、さまざまなクライアントリクエストを同時に処理し、ネットワーク接続やメモリキャッシュなどのサーバープロセスのリソースを共有できます。
コルーチン
コルーチンは、独自レジスタコンテキストとスタックを持っています。コルーチンがスケジュールされて切り替わる際、別の場所にレジスタコンテキストとスタックを保存します。切り替え時に、以前に保存したレジスタコンテキストとスタックを復元します。したがって、コルーチンは以前の呼び出しからの状態を保持できます(つまり、すべてのローカル状態の特定の組み合わせ)。プロセスに再入するたびに、以前の呼び出しの状態に戻るのと同じです。言い換えれば、前回離れた論理フロー内の位置に戻ることになります。スレッドとプロセスの操作は、システムインターフェースを介してプログラムによってトリガーされ、最終的な実行者はシステムです。ただし、コルーチンの操作はユーザー自身のプログラムによって実行され、goroutineはコルーチンのタイプです。
スケジューリングモデルの紹介
goroutineの強力な並行実装は、GPMスケジューリングモデルを通じて実現されます。以下にgoroutineスケジューリングモデルについて説明します。
Goスケジューラ内には、M、P、G、およびSchedの4つの重要な構造があります(Schedは図には示されていません)。
- M: カーネルレベルのスレッドを表します。1つのMは1つのスレッドであり、goroutineはM上で実行されます。たとえば、複雑な計算を実行するためにgoroutineが起動された場合、このgoroutineは実行のためにMに割り当てられます。Mは、小オブジェクトメモリキャッシュ(mcache)、現在実行中のgoroutine、乱数ジェネレーター、その他多くの情報を保持する大きな構造です。
- G: goroutineを表します。関数呼び出し情報を格納するための独自のスタック、実行位置を指定する命令ポインタ、およびスケジューリングに使用されるチャネルなど、他の情報を持っています。たとえば、goroutineがチャネルからデータを受信するのを待っている場合、この情報はG構造体に格納されます。
- P: 正式名称はProcessorです。主にgoroutineを実行するために使用されます。タスクディスパッチャと考えることができます。また、実行する必要のあるすべてのgoroutineを格納するgoroutineキューを保持します。たとえば、複数のgoroutineが作成されると、スケジューリングのためにPによって保持されているキューに追加されます。
- Sched: スケジューラを表します。中央スケジューリングセンターと見なすことができます。MとGのキュー、およびスケジューラのいくつかの状態情報を保持し、システム全体の効率的なスケジューリングを保証します。
スケジューリングの実装
図からわかるように、2つの物理スレッドMがあり、各MにはプロセッサPがあり、実行中のgoroutineがあります。
- Pの数は
GOMAXPROCS()
を通じて設定できます。これは実際には真の並行レベル、つまり同時に実行できるgoroutineの数を表します。 - 図の灰色のgoroutineは実行されておらず、実行可能な状態で、スケジュールされるのを待っています。Pはこのキュー(runqueueと呼ばれます)を保持しています。
- Go言語では、goroutineの起動は非常に簡単です。
go function
を使用するだけです。したがって、go
ステートメントが実行されるたびに、goroutineがrunqueueの最後に追加されます。次のスケジューリングポイントで、goroutineがrunqueueから取り出されて実行されます(ただし、どのgoroutineを選択するかをどのように決定するのでしょうか?)。
OSスレッドM0がブロックされると(下の図に示すように)、PはM1を実行するように切り替わります。図のM1は、作成中であるか、スレッドキャッシュから取得されている可能性があります。
M0が戻ると、goroutineを実行するためにPを取得しようとする必要があります。通常、他のOSスレッドからPを取得しようとします。取得に失敗した場合は、goroutineをグローバルrunqueueに入れ、スリープ状態になります(スレッドキャッシュに入れられます)。すべてのPは定期的にグローバルrunqueueをチェックし、その中のgoroutineを実行します。そうしないと、グローバルrunqueue上のgoroutineは決して実行されません。
もう1つの状況は、Pに割り当てられたタスクGが迅速に完了することです(不均等な分布)。これにより、このプロセッサPはアイドル状態になり、他のPはまだタスクを持っています。グローバルrunqueueにタスクGがない場合、Pは実行のために他のPからいくつかのGを取得する必要があります。一般に、Pが他のPからタスクを取得する場合、各OSスレッドを完全に利用できるように、通常はrun queueの半分を取得します。下の図に示すように、
III. Goroutineの使用
基本的な使い方
goroutineが実行するCPUの数を設定します。最新バージョンのGoにはデフォルト設定があります。
num := runtime.NumCPU() // ホストの論理CPUの数を取得し、後で並行レベルを設定するための準備をします runtime.GOMAXPROCS(num) // ホストCPUの数に応じて同時に実行できるCPUの最大数を設定し、それによってgoroutineの並行レベルを制御します
使用例
例1:シンプルなGoroutine計算
package main import ( "fmt" "time" ) // cal関数は、2つの整数の合計を計算し、結果を出力するために使用されます func cal(a int, b int) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) } func main() { for i := 0; i < 10; i++ { go cal(i, i + 1) // 計算を実行するために10個のgoroutineを開始します } time.Sleep(time.Second * 2) // すべてのタスクが完了するのを待つためにスリープを使用します }
結果:
8 + 9 = 17
9 + 10 = 19
4 + 5 = 9
5 + 6 = 11
0 + 1 = 1
1 + 2 = 3
2 + 3 = 5
3 + 4 = 7
7 + 8 = 15
6 + 7 = 13
Goroutineの例外キャッチ
複数のgoroutineを開始する際、そのうちの1つが例外に遭遇し、例外処理が行われない場合、プログラム全体が終了します。したがって、プログラムを作成する際には、各goroutineによって実行される関数に例外処理を追加することをお勧めします。recover
関数は、例外処理に使用できます。
package main import ( "fmt" "time" ) func addele(a []int, i int) { // deferを使用して匿名関数の実行を遅延させます。これは、発生する可能性のある例外をキャッチするために使用されます defer func() { // recover関数を呼び出して、例外情報を取得します err := recover() if err!= nil { // 例外情報を出力します fmt.Println("add ele fail") } }() a[i] = i fmt.Println(a) } func main() { Arry := make([]int, 4) for i := 0; i < 10; i++ { go addele(Arry, i) } time.Sleep(time.Second * 2) }
結果:
add ele fail
[0 0 0 0]
[0 1 0 0]
[0 1 2 0]
[0 1 2 3]
add ele fail
add ele fail
add ele fail
add ele fail
add ele fail
同期されたGoroutine
goroutineは非同期的に実行されるため、メインプログラムが終了するときに、一部のgoroutineがまだ実行を完了しておらず、これらのgoroutineも終了する可能性があります。すべてのgoroutineタスクが完了するまで待ってから終了する場合は、Goは同期の問題を解決するためにsync
パッケージとchannel
を提供します。もちろん、各goroutineの実行時間を予測できる場合は、time.Sleep
を使用して、プログラムを終了する前に完了するのを待つこともできます(上記の例のように)。
例1:syncパッケージを使用してGoroutineを同期する
WaitGroup
は、goroutineのグループが完了するのを待つために使用されます。メインプログラムはAdd
を呼び出して、待機するgoroutineの数を追加します。各goroutineは、実行が終了するとDone
を呼び出し、待機キューの数は1減少します。メインプログラムは、待機キューが0になるまでWait
によってブロックされます。
package main import ( "fmt" "sync" ) func cal(a int, b int, n *sync.WaitGroup) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) // goroutineが完了したら、WaitGroupのカウントを1つ減らすためにDoneメソッドを呼び出します defer n.Done() } func main() { var go_sync sync.WaitGroup // WaitGroup変数を宣言します for i := 0; i < 10; i++ { // goroutineを開始する前に、WaitGroupのカウントを1つ増やします go_sync.Add(1) go cal(i, i + 1, &go_sync) } // ブロックし、WaitGroupのカウントが0になるまで、つまりすべてのgoroutineが完了するまで待ちます go_sync.Wait() }
結果:
9 + 10 = 19
2 + 3 = 5
3 + 4 = 7
4 + 5 = 9
5 + 6 = 11
1 + 2 = 3
6 + 7 = 13
7 + 8 = 15
0 + 1 = 1
8 + 9 = 17
例2:チャネルを介してGoroutine間の同期を実装する
実装方法:channel
を使用すると、複数のgoroutine間で通信を実行できます。goroutineが完了すると、終了信号をchannel
に送信します。すべてのgoroutineが終了すると、for
ループを使用してchannel
から信号を取得します。データが取得できない場合は、すべてのgoroutineが完了するまでブロックされます。このメソッドを使用するための前提条件は、開始されたgoroutineの数を知っていることです。
package main import ( "fmt" "time" ) func cal(a int, b int, Exitchan chan bool) { c := a + b fmt.Printf("%d + %d = %d\n", a, b, c) time.Sleep(time.Second * 2) // goroutineが完了したことを示す信号をチャネルに送信します Exitchan <- true } func main() { // goroutineの完了信号を格納するために、容量が10のboolタイプのチャネルを作成します Exitchan := make(chan bool, 10) for i := 0; i < 10; i++ { go cal(i, i + 1, Exitchan) } for j := 0; j < 10; j++ { // チャネルから信号を受信します。信号が使用できない場合は、goroutineが完了して信号を送信するまでブロックされます <-Exitchan } // チャネルを閉じます close(Exitchan) }
Goroutine間の通信
goroutineは本質的にコルーチンであり、カーネルではなくGoスケジューラによって管理されるスレッドとして理解できます。goroutine間の通信またはデータ共有は、channel
を介して実現できます。もちろん、グローバル変数を使用してデータを共有することもできます。
例:チャネルを使用してプロデューサー - コンシューマーパターンをシミュレートする
package main import ( "fmt" "sync" ) func Productor(mychan chan int, data int, wait *sync.WaitGroup) { // チャネルにデータを送信します mychan <- data fmt.Println("product data:", data) // プロデューサーを完了としてマークし、WaitGroupのカウントを1つ減らします wait.Done() } func Consumer(mychan chan int, wait *sync.WaitGroup) { // チャネルからデータを受信します a := <-mychan fmt.Println("consumer data:", a) // コンシューマーを完了としてマークし、WaitGroupのカウントを1つ減らします wait.Done() } func main() { // プロデューサーとコンシューマー間のデータ転送のために、容量が100のintタイプのチャネルを作成します datachan := make(chan int, 100) var wg sync.WaitGroup for i := 0; i < 10; i++ { // プロデューサーgoroutineを開始して、チャネルにデータを送信します go Productor(datachan, i, &wg) // WaitGroupのカウントを増やします wg.Add(1) } for j := 0; j < 10; j++ { // コンシューマーgoroutineを開始して、チャネルからデータを受信します go Consumer(datachan, &wg) // WaitGroupのカウントを増やします wg.Add(1) } // ブロックして、プロデューサーとコンシューマーの両方がタスクを完了するまで待ちます wg.Wait() }
結果:
consumer data: 4
product data: 5
product data: 6
product data: 7
product data: 8
product data: 9
consumer data: 1
consumer data: 5
consumer data: 6
consumer data: 7
consumer data: 8
consumer data: 9
product data: 2
consumer data: 2
product data: 3
consumer data: 3
product data: 4
consumer data: 0
product data: 0
product data: 1
Leapcell:Webホスティング、非同期タスク、およびRedisのための次世代サーバーレスプラットフォーム
最後に、Goサービスをデプロイするのに最適なプラットフォームをお勧めします:Leapcell
1. 多言語サポート
- JavaScript、Python、Go、またはRustで開発します。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量のみを支払います - リクエストも料金もありません。
3. 無敵のコスト効率
- アイドル料金なしで従量課金。
- 例:$25で平均応答時間60msで694万リクエストをサポートします。
4. 合理化された開発者体験
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的な洞察のためのリアルタイムメトリックとロギング。
5. 簡単なスケーラビリティと高パフォーマンス
- 高い並行性を簡単に処理するための自動スケーリング。
- 運用上のオーバーヘッドなし - 構築に集中するだけです。
Leapcell Twitter:https://x.com/LeapcellHQ