前回
最近、面白いツイートを見つけた。
Interesting bug caused by cooperative preemption and mutex fast-path inlining in Go: https://t.co/CHeGvBVhTE
— Roberto Clapis (GMT -7) 🏳️🌈🇪🇺 (@empijei) August 16, 2019
内容は、ゴルーチンのデッドロックなのだが、原因が「協調割り込み」というゴルーチンが動作するための仕組みと、前回の記事で紹介したmid-stack inliningの複合技により引き起こされていると言う。 今回は、ゴルーチンの内部動作から、なぜこのバグが引き起こされたかをまとめたいと思う。
そもそもゴルーチンってどうやって動いてるんだっけ?
GolangではOSの管理するスレッドとは別に、論理プロセッサと独自のスケジューラを持ち、ランタイムが管理をしている。
各コンポーネントは以下のように説明される。
M(Machine): OSの管理するスレッド
G(Goroutine): ゴルーチン
P(Processor): 論理プロセッサ
よく見かけるGOMAXPROCS変数はこのうちP
に該当し、論理プロセッサの数を表す。
golangのスケジューリングは二つの階層に分けて説明でき、一つ目は、M
(スレッド)とP
(論理プロセッサ)の割り当てと、もう一つがP
(論理プロセッサ)とG
(ゴルーチン)の割り当てである。
work-stealing
グローバルとして、キューを持ちP
に割り当てされていないゴルーチンを保持する。
また、各P
(論理プロセッサ)もそれぞれキューを持ち、割り当てられたゴルーチンを保持している。
各P
はもし自分のキューにゴルーチンが存在しなければ、他のP
からゴルーチンを奪い、実行する。
これをwork stealingという。
もし奪えるゴルーチンが存在しなければ、グローバルなキューからゴルーチンを割り当て、実行する。
ゴルーチンの実行が終わると、P
は別のゴルーチンへコンテキストスイッチをし、実行を続ける。
これを繰り返しゴルーチンは処理されている。
協調割り込み
ここで一つ問題になるのが、もし一つのゴルーチンがとても重たく、容易に終了しないものだった場合、このままでは論理プロセッサ P
が占有されてしまう。
現在の定義だと、P
がゴルーチンが終わるまで次のゴルーチンへコンテキストスイッチできないのである。
ここで必要になる機能が、ゴルーチンの割り込みである。ゴルーチンの割り込みとは、ゴルーチンの実行中でも他のゴルーチンに処理を譲る(または奪う)ことで、平等に平行に処理をするための機能のことである。
現在のGoの実装では、「協調割り込み(co-operative preemption)」という方式が実装されている。 これは、コンパイル時に任意の箇所に割り込みコード(他のゴルーチンに処理を譲る処理)を埋め込むことにより、ゴルーチンが他のゴルーチンに処理を譲りながら実行できるようにしたものである。 基本的に、この割り込みコードはインライン化されていない関数呼び出しなど周りにコンパイル時に挿入される。つまり、関数呼び出しが合図になる。
逆に言うと、関数呼び出したり、その他割り込みコードが挿入される余地のない強めのループなどを書いてしまった場合、その P
を占有してしまう。
非協調割り込み
これは proposal
として掲げられている機能なのだが、上記の協調割り込みには強めのループなどのようにエッジケースが存在するため、非協調割り込みという、強制的にゴルーチンの処理を奪う方式を採用しようという動きがある。
proposal/24543-non-cooperative-preemption.md at master · golang/proposal · GitHub
バグの原因は
前回紹介したmid-stack inliningに関連して、より積極的に関数のインライン化が可能になったため、先日 RWMutex のインライン化対応が行われた。
https://go-review.googlesource.com/c/go/+/148958
これにより、RWMutex.RLock, Unlockなどがインライン化されてしまい、割り込みチェックが入らなくなってしまった。 よって、無限にブロックし続けるプログラムが誕生した。
参考文献
Golangのスケジューラあたりの話 - Qiita Scheduling In Go : Part II - Go Scheduler
次回
多分SSA最適化あたり?