1. Qiita
  2. Items
  3. Go

意外と知らないgoroutineのスケジューラーの挙動 #golang

  • 72
    Like
  • 2
    Comment

はじめに

goroutineはGo言語の大きな特徴である並行処理を支える重要な機能です。

しかし、goroutineの仕組みについてしっかり理解しないままコードを書いてしまうと思わぬ挙動をしてしまうことがあるので注意が必要です。

今回はそんなgoroutineのスケジューリングの挙動についてまとめてみました。
僕自身がgoの書き始めの頃に引っかかった部分なので、初心者のgoroutineへの理解の助けになれば幸いです。

goroutineの特徴

goroutineは最小で2048byteなので、 Windows だと 1 MB、Linux だと 2 MB であるスレッドのデフォルトスタックサイズにくらべて軽量であると言えます。

また、OSスレッドはOSカーネルでスケジュールされており、スレッド間の制御を変更するには完全なコンテキストスイッチが必要なので遅くなってしまうのですが、goroutineはm:nスレッド(LWP方式)を用いているため、goroutineのスケジューラはハードウエアタイマーで定期的によびだされるのではなく、カーネルコンテキストへ切り替える必要が無いのでスレッドの再スケジュールより低コストにスケジューリング可能です。

スケジューリングの挙動

例1

以下のコードをみてください。

test.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    users := []string{"太郎", "次郎", "三郎"}

    var wg sync.WaitGroup

    for _, u := range users {
        wg.Add(1)
        go func() {
            fmt.Println("Hello", u)
            wg.Done()
        }()
    }

    wg.Wait()
}

(このコードはこちらで実行できます。)

これをThe Go Playgroundで実行すると

Hello 三郎
Hello 三郎
Hello 三郎

このようになります。
このコードには、メインルーチンとgoroutineがありますが、goroutineに切り替わるのは rangeが回りきってwg.Waitされた時点 です。
なぜかというと、goroutineが切り替わるタイミングが、

  • アンバッファなチャネルへの読み書きが行われる
  • システムコールが呼ばれる
  • メモリの割り当てが行われる
  • time.Sleep()が呼ばれる
  • runtime.Gosched()が呼ばれる

などに限られているためです。参考

追記(2016/12/12):
システムコールもすべてがスイッチするわけではなく、ディスクI/Oとか待ちが入る余地がない即座に帰ってくる系のものだとスイッチしないようです。(コメント参照)

そのため、goroutineがforで宣言された変数をクロージャから参照するときには、uは三郎となってしまっており、同じ値を参照してしまうのです。
つまり、以下の様にSleepを入れてみるとうまくいくことがわかります。

test.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    users := []string{"太郎", "次郎", "三郎"}

    var wg sync.WaitGroup

    for _, u := range users {

        wg.Add(1)
        go func() {
            fmt.Println("Hello", u)
            wg.Done()
        }()
        time.Sleep(1 * time.Second)
    }

    wg.Wait()
}

(コードはこちらで実行できます。)

この現象はGOMAXPROCSが1の時に顕著に現れます。バージョン1.5以降はGOMAXPROCSに適切な値がセットされるようになり、マルチコアを最適に使うようになりましたが、GAEを使用している場合や、GOMAXPROCSに1を指定している場合には注意が必要です。
GAEで上記のようなgoroutineの切り替わらないコードを並行処理で書いてもsequentialに実行しているのと何らかわらなくなってしまいます。

追記(2016/12/12):
そのため、GAEにおいてはDatastoreなどの他サービスAPIを叩くことでgoroutineが切り替わることをうまく使うのがよいでしょう。

なお、上記のコードの問題をGOMAXPROCSに関わらず解決するためには以下の二つの方法があります。(どちらも変数のスコープの問題を解決しています)

ブロックの中で変数として定義し直す

test.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    users := []string{"太郎", "次郎", "三郎"}

    var wg sync.WaitGroup

    for _, u := range users {

        u := u
        wg.Add(1)
        go func() {
            fmt.Println("Hello", u)
            wg.Done()
        }()
    }

    wg.Wait()
}

(コードはこちらで実行できます。)

引数として渡す

test.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    users := []string{"太郎", "次郎", "三郎"}

    var wg sync.WaitGroup

    for _, u := range users {
        wg.Add(1)
        go func(u string) {
            fmt.Println("Hello", u)
            wg.Done()
        }(u)
    }

    wg.Wait()
}

(コードはこちらで実行できます。)

例2

スケジューラの挙動を理解できたところでもう一つの例を見てみます。

test.go
package main

import "fmt"

func main() {
    go func() { panic("Boom") }()
    for i := 0; i < 10000000; {
        fmt.Print(".")
    }
    fmt.Println("Done")
}

(Credit: Péter Szilágyi)

これを実行するとどうなるでしょうか?
.がいくつprintされるのか、GOMAXPROCSを変えながら考えてみてください。

実行結果は、みなさんの予想通り

..............................................................panic: Boom

こんな感じになります。
GOMAXPROCSを1から5まで順に大きくしていくと、goroutineが切り替わりやすくなる事で表示される .の数が小さくなるのが確認できました。
ただ、個人的にはメインルーチンとゴルーチンの2つなのに、GOMAXPROCS=2より大きくしていった後も緩やかではありますが表示数が少なくなったのはあまりしっくりきませんでした。(詳しい方教えてください!!)

さいごに

こんな感じでgoroutineが奥深いものであることがわかっていただけたかと思います。
この記事の内容について教えていただいたDaveさんや@tenntennさんに感謝です。