kubo39's blog

ただの雑記です。

そのスタックを食べたのはだれ? ~travisでdubがSEGVし続けたワケ~

あらすじ

ある日のこと、Travis上のプロジェクトがdub使ってるとSEGVで落ちる、というチケットが。さらにスレッドが進み、どうもいろいろなパッケージで同様の問題が起きているとのこと。

LinuxでしかおきないぞとかdubでParallel GC(注:mark&sweep gcのmarkingのフェーズが並列)使ったときしか再現しないっぽいとか再現条件が特定されていく中でParallel GC内のスレッドでスタックオーバーフローが起きているのがどうやら原因だと判明。

といってもこの問題、Parallel GCの内部実装の問題ではない。OSスレッド、それもglibcのpthread実装に関連した問題だった。

この問題はglibcでは静的なTLSブロックがスレッドのスタックのtopに配置されることに起因する。 スレッド実装では最小スタックサイズを保証するために PTHREAD_STACK_MIN よりも小さいサイズが与えられた場合切り上げが行われるのだが、glibcの場合は静的TLSブロックのサイズがこの定数に加算されない仕組みになっている。 (静的TLSブロックサイズを考慮した __pthread_get_minstack という関数があるがこれはglibcバージョン2.15以降で利用可能となる)

Parallel GC内で生成してるスレッドのスタックサイズはアプリケーションに影響を及ぼさないようにするためか0x4000(=16kb)ほどしか設定されてなく、そこそこでかい静的TLSブロックサイズになると簡単にSEGVが起きてしまう状態になっていた。このIssueでは4ページ(0x4000)のスタック領域を確保しているはずが(ガード領域の1ページを除き) 1ページ以上(0x1100)の領域が静的TLSデータに食べられてしまい、利用可能なスタック領域がほんとうにわずかになっていることが確認できる。 おまけにD言語ではグローバルな変数はデフォルトでスレッドローカルストレージに配置されスレッド生成時に暗黙にコピーが走るためバグが顕現しやすいという特性もあったのでさまざまなプロジェクトで影響が出てしまった。

この問題はParallel GCの作者が書いた 静的TLSブロックサイズを考慮する修正パッチ がすでにマージされていてdmd-2.090.1がリリースされているのでひとまず解決した。

影響範囲

Parallel GCを使っている場合 (かつDMD <= 2.090.0) のすべてのバージョンで起きうる。 また自分でスレッド生成を管理している場合なども当然注意が必要。

ワークアラウンド

ランタイムのバグが修正済みであるDMD 2.090.1を使うのがよいのだが、それができない場合

  • プログラム実行時に --DRT-gcopt=parallel:0 を指定する
  • コード内で
// parallel gcが追加されたのは 2.087: https://dlang.org/changelog/2.087.0.html#gc_parallel
// 修正は2.090.1で取り込まれたがパッチバージョンは考慮できないのでとりあえず 2.091を与える
static if (__VERSION__ >= 2087 && __VERSION__ < 2091)
    extern (C) __gshared string[] rt_options = [ "gcopt=parallel:0" ];

という宣言をするとParallel GCを無効にできるので、ひとまずParallel GC起因のものは抑制できる。

自前でスレッドを使いたい場合スタックサイズを余裕をもって大きくしておくか、もしくはスタックサイズとして0を渡すとシステムデフォルトのスタックサイズ(静的TLSブロックが考慮されている)を設定することができる。

おまけ

今回自分は修正パッチのレビューから参加したのでここまでの流れを追うの(特に英語)がしんどかったが、とにかく解決してほっとした。原因究明と修正にあたってくれたGeod24氏とrainers氏に感謝。

ちなみにこの修正、地味に引数に与えたスタックサイズと静的TLSブロックの合計が PTHREAD_STACK_MIN より小さい場合に PTHREAD_STACK_MIN のサイズまでしか切り上げされないというコーナーケースがあるので、追加パッチ を書いた。こちらもマージ済だけど影響範囲は微小と思われるのでリリースは2.091.0になりそうだ。