Posted at

ゴルーチンの切り替えと関数のスタックチェック

More than 1 year has passed since last update.


はじめに

Go言語の並行処理は、ゴルーチン(goroutine)という実行単位を使用してます。ゴルーチンは軽量スレッドの一種であり、OSプロセスなどと同様に、他のゴルーチンへの切り替え(コンテキストスイッチ)が発生します。この切り替えは様々なタイミングで発生するのですが、今回は「関数の先頭にあるスタックチェック」での切り替え処理について述べていきたいと思います。

※ 本文章はgo 1.9のコンパイラを調査した内容を元に記述しています。バージョンによって変わる部分も多いと思いますのでご注意ください。


ゴルーチンの切り替え

ゴルーチンの実行にはOSスレッドを使用し、ゴルーチンM個に対してOSスレッドがN個割り当たるM:Nスレッドモデルを採用しています。このモデルが示す通り、その対応する数が1対1ではありません。「ゴルーチンの数 > OSスレッドの数」になると、OSスレッドが足りない分だけ「実行待ちのゴルーチン」ができます。この「実行待ちのゴルーチン」を実行するために、実行中のゴルーチンと切り替えを行います。


切り替えのタイミング

ゴルーチンの切り替えは、OSのプロセスやスレッドのようにスケジューラが強制的に割り込むのではなく、次のような決まったタイミングで行われます。


  • 関数の先頭(スタックチェック)

  • ガベージコレクション実行時

  • チャネルの送受信やシステムコールなど

このように様々なタイミングで発生しますが、今回は関数の先頭に行うスタックチェックでの切り替えについて述べていきます。


関数の先頭で行うスタックチェック

それでは関数のスタックチェック処理を確認するために、単純な関数を例にして処理を追っていきます。

package main

import "fmt"

func f1() {
fmt.Println("ゴルーチン1")
}

このGo言語のコードをgo tool compile -l -S ファイル名でアセンブリコードに変換した結果が下記になります。

"".f1 STEXT size=120 args=0x0 locals=0x48

0x0000 00000 (f1.go:5) TEXT "".f1(SB), $72-0
0x0000 00000 (f1.go:5) MOVQ (TLS), CX
0x0009 00009 (f1.go:5) CMPQ SP, 16(CX)
0x000d 00013 (f1.go:5) JLS 113
・・・
0x0071 00113 (f1.go:5) CALL runtime.morestack_noctxt(SB)
0x0076 00118 (f1.go:5) JMP 0

このアセンブリコードは関数f1の先頭部分です。CMPQの行でスタックサイズのチェックを行っています。SPはスタックポインタ、16(CX)はスタックガードです。スタックガードは、ランタイム内のゴルーチン情報である構造体gのstackguard0フィールドであり、スタックオーバーフローを防ぐ役目を持っています。

d1.png

CMPQJLSにより、スタックポインタ(SP)の値がスタックガード(stackguard0)より小さければ(スタックサイズが小さいと判断すれば)、CALL runtime.morestack_noctxtの行にジャンプします。runtime.morestack_noctxtの中ではスタックの拡張を行います。

今回の話で重要なこととして、このスタックチェックがスタックの拡張のためだけではなく、ゴルーチンの切り替えにも利用している点があります。


システムモニタによるスタックガード変更

Go言語のランタイム内では、システムモニタ(sysmon)が並行して動作しておりゴルーチンを監視しています。このシステムモニタの中で、一定時間(10ms)実行中のゴルーチンはスタックガード(stackguard0)の値を大きな固定値(stackPreempt=0xFFFFFADE) に書き換えます。

これによって関数の先頭のスタックチェックに引っかかり、runtime.morestack_noctxtが呼ばれます。runtime.morestack_noctxtの先でもさらに分岐があり、スタックガードの値がstackPreemptの場合は、ゴルーチンを切り替えるようになっています。

d2.png


切り替えが発生しないケース

ここまでは、ゴルーチンの切り替えが関数の先頭(スタックチェック)で発生する仕組みの話でした。ここからは、一部の関数の先頭で切り替えが発生しないケースの話になります。

まずはベースとなる、何も処理しない無限ループによってゴルーチン切り替えが発生しないコード例です。

package main

import (
"fmt"
"runtime"
)

func main() {
// 同時に実行するゴルーチンの数を1個に限定
runtime.GOMAXPROCS(1)

// このゴルーチンには処理が切り替わらない
go func() { fmt.Println("ゴルーチン2") }()

// 無限ループ
for {
}
}

このコードではGOMAXPROCSによってユーザが同時に実行できるゴルーチンを1個に限定しています。そして無限ループfor { }によって切り替えるタイミングをなくしています。そのため、main関数から始まるゴルーチンがその1個を専有し続けて、もう1つのゴルーチンgo func() { fmt.Println("ゴルーチン2") }()に処理が切り替わることがありません。


無限ループ内に関数呼び出しを追加したケース

関数の先頭で切り替えが発生するため、無限ループ内に関数sw1の呼び出しを追加した場合はどうでしょうか。

func main() {

// 同時に実行するゴルーチンの数を1個に限定
runtime.GOMAXPROCS(1)

// このゴルーチンには処理が切り替わらない
go func() { fmt.Println("ゴルーチン2") }()

// 無限ループ
for {
sw1()
}
}

func sw1() {
// 処理なし
}

このコードを普通にコンパイルすると、関数sw1がインライン展開してしまいます。インライン展開すると、関数が呼び出されないので切り替えも発生しません。そこでgo build -gcflags="-l" ファイル名でインライン展開を無効にしてコンパイルします。(-lがインライン展開無効化フラグ)

インライン展開を無効化したことによって、関数sw1の先頭でゴルーチンの切り替えが発生するかと思いました。しかし実はこれでも切り替えは発生しませんでした。


nosplitディレクティブ

ゴルーチンの切り替えが発生しない理由は何かと思い、関数sw1のアセンブリコードを見てみると、次のようになっていました。

"".sw1 STEXT nosplit size=1 args=0x0 locals=0x0

0x0000 00000 (f2-2.go:21) TEXT "".sw1(SB), NOSPLIT, $0-0
0x0000 00000 (f2-2.go:21) FUNCDATA $0, gclocals・33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (f2-2.go:21) FUNCDATA $1, gclocals・33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (f2-2.go:23) RET

スタックチェックは存在せず、所々にnosplitといった単語があります。この単語でgolang.orgのドキュメント検索すると//go:nosplitディレクティブが見つかりました。このディレクティブは、対象関数にスタックチェックを含めないようにするものです。主にランタイムの低レベル部分で使用するようですが、今回の関数sw1には//go:nosplitディレクティブを記述していません。

そこでコンパイラを追ってみると、関数の属性をnosplitに変更する処理が見つかりました。nosplitに変更する条件は、関数コールグラフの最後(関数内で別の関数を呼ばない)であることや、配列などで大きなサイズのローカル変数を使用しないことでした。おそらくスタックのサイズが予測できるときは、スタックチェックを除く最適化を行っているのだと思います。(このnosplitへの変更ですが、ソースを読む限りamd64限定でありx86にはなさそうです)


無限ループ内に関数呼び出しを追加したケース(変更版)

元に戻って、関数sw1に関数呼出しを追加すればコールグラフの最後でなくなります。 そこで関数sw2を追加して、関数sw1から呼び出すようにします。この追加によって、関数s1にスタックチェックが追加されゴルーチンの切り替えが発生するはずです。

func sw1() {

// 関数呼び出しを追加
sw2()
}

func sw2() {
// 処理なし
}

この追加したコードでは無事切り替えが発生し、ゴルーチン2の処理fmt.Println("ゴルーチン2")が実行されました。関数sw1のアセンブリコードは、次のように変わっています。

"".sw1 STEXT size=48 args=0x0 locals=0x8

0x0000 00000 (f2-3.go:21) TEXT "".sw1(SB), $8-0
0x0000 00000 (f2-3.go:21) MOVQ (TLS), CX
0x0009 00009 (f2-3.go:21) CMPQ SP, 16(CX)
0x000d 00013 (f2-3.go:21) JLS 41
・・・
0x0029 00041 (f2-3.go:21) CALL runtime.morestack_noctxt(SB)
0x002e 00046 (f2-3.go:21) JMP 0

関数sw2の呼び出しによってnosplitがなくなり、スタックチェックを行うようになりました。


まとめ

関数の先頭でのゴルーチン切り替えについて説明しました。基本的に関数の先頭にあるスタックチェックで切り替えが発生しますが、インライン展開やnosplit追加などによってなくなる場合があります。

今回nosplitの動作を調べたのは、インライン展開を止めれば関数の先頭でゴルーチンの切り替えが発生すると思い、実験的なコードで試したけど切り替わらなかったのが理由です。実際のアプリなどのコードではまず問題にならないと思いますが、切り替わらなくて悩んだら参考にしてみてください。