2021年8月29日日曜日

Go 1.14からgoroutineがプリエンプティブになったらしい

goroutineはスレッドのようなものと説明されることが多いですが、一方でノンプリエンプティブ、つまり空の無限ループのようなコードを書いたら他のgoroutineにも処理が移らずにハングしてしまうとも説明されます。処理の切り替わりタイミングはめちゃくちゃ大雑把に言えばNode.jsのasync/awaitのようなものだと思ってください。内部処理は結構違いますが。

今までそう思っていたんですが、どうやらGo 1.14からプリエンプティブになったようです。Goを真面目に勉強している人にとっては今更感が強いでしょうけど、つい最近知ったんで勘弁してください。

見たのはここの記事。今回はそれについての検証です。

何はともあれ試してみる

公式ページからGo 1.13と1.14をダウンロードして、カレントディレクトリーに展開します。ただしそのまま展開するとディレクトリー名がかぶるので、go1.13 / go1.14のように名前を変えること。

次に以下のようなコードをloop.goのような名前で保存します。

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)

    fmt.Println("The program starts ...")

    go func() {
        for {
        }
    }()

    time.Sleep(time.Second)
    fmt.Println("I got scheduled!")
}

やってることは、

  • 使うCPUを1個に制限して
  • 無限ループをgoroutineで走らせて
  • 1秒寝て
  • "I got scheduled!"と表示して終了
というシンプルな内容です。

ノンプリエンプティブの場合は寝たあとでgoroutineに処理が移りますが、無限ループのgoroutineからmain()に処理が切り替わらず、結果としてプログラムが終了しないはず。

まずはgo1.13で実行。

$ ./go1.13/bin/go run loop.go 
The program starts ...

1分ほど放置しましたが、予想通りプログラムは終了しませんでした。

続いてgo1.14。

$ ./go1.14/bin/go run loop.go 
The program starts ...
I got scheduled!

おっ。1秒後に"I got scheduled!"が出て終わりました。

仕組みがどう変わったんだろう

先のページを見てみると、go1.14ではシステムシグナルに基づく非同期プリエンプションスケジューリングが導入されたと書いてあります。なるほどさっぱりわからない。ただ、少なくともgoroutine=スレッドになったわけではなさそうです。

これについてもうちょっと調べてみると、日本語の解説ページが見つかりました。ここによると、もともと1.13以前でも長時間実行されているgoroutineを検出する仕組み自体はあったようですが(このあたりモニターとかスケジューラーがGoがミニOSとも呼ばれる理由)、検出しても処理タイミング的にどうしようもなかったみたいです。

これが1.14以降では、

  1. 関数の実行を監視しているモニター(sysmon)は、長時間実行されているgoroutineに対してpreemptフラグを立てる←これは1.13でも同じ
  2. 該当のgoroutineを処理しているスケジューラーにSIGURGというシグナルを送る
  3. スケジューラーはSIGURGを受け取ったら、別のgoroutine(gsignal)を起動して実行しているgoroutineの代わりに割り付ける
  4. gsignalはpreemptフラグが立っている場合に自分自身を停止する

という手順を踏むことで無限ループ中でも別のgoroutineに処理を切り替えることができて、プリエンプティブになるそうな。ほーん

これはこれで今までの方法に比べるとオーバーヘッドがありそうな気がしますが、goroutine間で協調する必要がなくなったメリットのほうが大きいんでしょうねきっと。Node.jsでの開発に慣れている人は非同期処理を伴わないループは適当なところで切り上げようとか協調的なことを色々考えてるはずなので従来の方式でもあまり気にならないかも。

0 件のコメント:

コメントを投稿