Devlion Memo

のらりくらりと

Golangメモリ周りのメモ (goroutine割り込み)

前回

mjhd.hatenablog.com

最近、面白いツイートを見つけた。

内容は、ゴルーチンのデッドロックなのだが、原因が「協調割り込み」というゴルーチンが動作するための仕組みと、前回の記事で紹介した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最適化あたり?