1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goの核心:Goroutineスケジューリングの原理

Posted at

Group17.png

I. Goroutineの紹介

Goroutineは、Goプログラミング言語における非常に特徴的な設計であり、その主なハイライトの一つです。基本的にはコルーチンであり、並列計算を実現するための鍵となります。Goroutineの使用は非常に簡単です。単にgoキーワードを使用するだけでコルーチンを開始でき、非同期的に実行されます。プログラムは、Goroutineが完了するのを待たずに、後続のコードを引き続き実行することができます。

go func() // goキーワードを使用して関数を実行するコルーチンを開始

II. Goroutineの内部原理

概念の紹介

並行性(Concurrency)

単一のCPU上で、複数のタスクを同時に実行することができます。極めて短い期間内で、CPUはタスク間を迅速に切り替えます(例えば、短時間プログラムAを実行し、その後すぐにプログラムBに切り替えます)。時間的に重複があります(巨視的には同時実行しているように見えますが、微視的には依然として順次実行です)。これにより、複数のタスクが同時に実行されているような錯覚を生み出し、これが我々が言う並行性です。

並列処理(Parallelism)

システムが複数のCPUを持つ場合、各CPUは独自のCPUのリソースを競合することなく、同時にタスクを実行できます。それらは同時に動作し、これを並列処理と呼びます。

プロセス(Process)

CPUがプログラム間で切り替える際、前のプログラムの状態(いわゆるコンテキスト)を保存せず、直接次のプログラムに切り替えると、前のプログラムの一連の状態が失われます。この問題を解決するため、プログラム実行に必要なリソースを割り当てるためにプロセスという概念が導入されました。したがって、プロセスはプログラムが実行するために必要な基本的なリソース単位です(プログラム実行のエンティティと見なすこともできます)。例えば、テキストエディタアプリケーションを実行する場合、このアプリケーションのプロセスは、テキストバッファのメモリ空間、ファイル処理リソースなどのすべてのリソースを管理します。

スレッド(Thread)

CPUが複数のプロセス間で切り替えると、かなりの時間が消費されます。なぜなら、プロセス切り替えにはカーネルモードへの移行が必要であり、各スケジューリングでユーザーモードデータの読み取りが必要だからです。プロセスの数が増えると、CPUスケジューリングに大量のリソースが消費されます。そこで、スレッドという概念が導入されました。スレッド自体は非常に少ないリソースを消費し、プロセス内のリソースを共有します。カーネルがスレッドをスケジューリングする際、プロセスをスケジューリングする際ほど多くのリソースを消費しません。例えば、ウェブサーバーアプリケーションでは、複数のスレッドを使用して異なるクライアント要求を同時に処理し、サーバープロセスのネットワーク接続やメモリキャッシュなどのリソースを共有することができます。

コルーチン(Coroutine)

コルーチンは独自のレジスタコンテキストとスタックを持ちます。コルーチンがスケジューリングにより切り替えられるとき、そのレジスタコンテキストとスタックは別の場所に保存されます。元に戻るとき、以前保存したレジスタコンテキストとスタックが復元されます。したがって、コルーチンは前回の呼び出しからの状態を保持することができます(すなわち、すべてのローカル状態の特定の組み合わせ)。毎回プロセスに再入するとき、前回の呼び出しの状態に戻ることに等しく、言い換えれば、前回離れた論理フローの位置に戻ることになります。スレッドやプロセスの操作は、プログラムがシステムインターフェイスを通じてトリガーされ、最終的な実行者はシステムです。しかし、コルーチンの操作はユーザー自身のプログラムによって実行され、goroutineはコルーチンの一種です。

スケジューリングモデルの紹介

Goroutineの強力な並行実装は、GPMスケジューリングモデルを通じて達成されます。以下では、goroutineスケジューリングモデルを説明します。

Goスケジューラ内には4つの重要な構造体があります:M、P、G、およびSched(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のキュー、およびスケジューラのいくつかの状態情報を保持し、システム全体の効率的なスケジューリングを保証します。

スケジューリングの実装

hJXPS8PNtS.jpeg

図から分かるように、2つの物理的なスレッドMがあり、各Mには1つのプロセッサPがあり、1つの実行中のgoroutineがあります。

  • Pの数はGOMAXPROCS()を通じて設定できます。実際には、trueの並行レベル、つまり同時に実行できるgoroutineの数を表します。
  • 図の灰色のgoroutineは実行中ではなく、準備完了状態で、スケジューリングを待っています。Pはこのキュー(runqueueと呼ばれます)を保持しています。
  • Go言語では、goroutineを開始することは非常に簡単です:単にgo functionを使用するだけです。したがって、goステートメントが実行されるたびに、goroutineがrunqueueの末尾に追加されます。次のスケジューリングポイントで、runqueueから1つのgoroutineが取り出されて実行されます(ただし、どのgoroutineを選択するかはどのように決定されるのでしょう?)。

OSスレッドM0がブロックされると(以下の図参照)、PはM1を実行するように切り替わります。図のM1は作成中か、スレッドキャッシュから取り出されたものかもしれません。

IF1xdSfr3q.jpeg

M0が戻るとき、必ずgoroutineを実行するためにPを取得しようとします。通常は、他のOSスレッドからPを取得しようとします。取得に失敗した場合は、goroutineをグローバルrunqueueに入れ、その後自らスリープ状態に入ります(スレッドキャッシュに入れられます)。すべてのPは定期的にグローバルrunqueueをチェックし、その中のgoroutineを実行します。そうでないと、グローバルrunqueue上のgoroutineは決して実行されません。

もう1つの状況は、Pに割り当てられたタスクGが迅速に完了すること(不均一な分配)で、これによりこのプロセッサPがアイドル状態になり、他のPは依然としてタスクを持っている状態になります。グローバルrunqueueにタスクGがない場合、Pは他のPからいくつかのGを取得して実行する必要があります。一般的に、Pが他のPからタスクを取得する場合、通常はrunキューの半分を取得して、各OSスレッドが完全に利用されるようにします。以下の図を参照してください:

oWxZmCSyUr.jpeg

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) // すべてのタスクが完了するのを待つためにSleepを使用
}

結果:

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減少します。メインプログラムは Wait によってブロックされ、待機キューが0になるまで待ちます。

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が完了したとき、Doneメソッドを呼び出してWaitGroupのカウントを1減らす
    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間の同期化の実装

実装方法:チャネルを通じて、複数のgoroutine間で通信を行うことができます。goroutineが完了すると、チャネルに終了信号を送信します。すべてのgoroutineが終了すると、for ループを使用してチャネルから信号を取得します。データを取得できない場合は、すべての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向けの次世代サーバレスプラットフォーム

barndpic.png

最後に、Goサービスをデプロイするのに最適なプラットフォームをおすすめします:Leapcell

1. 多言語対応

  • JavaScript、Python、Go、またはRustで開発できます。

2. 無制限のプロジェクトを無料でデプロイ

  • 使用量に応じて課金 — リクエストがなければ料金はかかりません。

3. 圧倒的なコスト効率

  • 使った分だけ支払い、アイドル時は料金がかかりません。
  • 例:25ドルで平均応答時間60msで694万回のリクエストをサポートできます。

4. ストリームライン化された開発者体験

  • 直感的なUIで簡単にセットアップできます。
  • 完全自動化されたCI/CDパイプラインとGitOps統合。
  • アクション可能なインサイトを得るためのリアルタイムメトリクスとログ。

5. 簡単なスケーラビリティと高性能

  • 高い並行性を簡単に処理するための自動スケーリング。
  • オペレーションオーバーヘッドゼロ — 構築に集中できます。

Frame3-withpadding2x.png

ドキュメントで詳細を確認!

LeapcellのTwitter:https://x.com/LeapcellHQ

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?