UNIX上でのC++ソフトウェア設計の定石 (2)

鉄則2: シグナルハンドラで行ってよい処理を知ろう

  • sigaction関数で登録したシグナルハンドラで行ってよい処理は非常に限定されている
  • 次の3つの処理だけが許されている
    1. 自動変数の操作
    2. “volatile sig_atomic_t” 型の大域変数の操作
    3. 「非同期シグナルセーフ」関数の呼び出し
  • これ以外の処理を記述しないこと!

説明:


シグナル受信時に何らかの処理を行うためには、シグナルハンドラと呼ばれる関数を用意し、それをsigaction関数でシグナル名と紐付けておけばOKです。しかし、シグナルハンドラ内で行ってよい処理は、上記の通り非常に限定されています。これを把握しないまま奔放なコードを書くと次のような現象が起き得ます:

  • 問題1: プログラムがデッドロックする危険がある
    • タイミングに依存する、再現困難なバグの原因となる
    • デッドロックの発生が典型例だが、それ以外にも関数の戻り値不正、関数内での突然のSEGVなどの誤動作も起こり得る
  • 問題2: コンパイラによって意図しない最適化が行われ、プログラムが誤動作する危険がある
    • コンパイラおよびコンパイラの最適化レベルに依存したバグとなる。「最適化をかけたら動かなくなった」「インライン化したら動かなくなった」「OSを変更したら動かなくなった」などた、解析困難なバグの原因となる。


具体的なコードを見ながら解説しましょう。次のコードには少なくとも3つの問題があり*1、環境によっては正しく動きません。問題点を順に指摘していきます。

int gSignaled;

void sig_handler(int signo) {
    std::printf("signal %d received!\n", signo);
    gSignaled = 1;
}

int main(void) {
    struct sigaction sa;
  // (略)
  sigaction(SIGINT, &sa, 0);
    gSignaled = 0;
    while(!gSignaled) {
//      std::printf("waiting...\n");
        struct timespec t = { 1, 0 }; nanosleep(&t, 0);
    }
}


誤り1: レースコンディション


上記コードにはレースコンディションがあります。sigaction関数を呼んだ後、gSignaledに0を代入するまでの間にSIGINTを受信したらどうなるでしょうか? シグナルハンドラで書き換えた gSignaled がシグナルハンドラからの復帰後 0 に初期化されてしまい、その後のwhileループから脱出できなくなるかもしれません。


誤り2: 大域変数 gSignaled の型が不正


シグナルハンドラから触る大域変数であるgSignaledの型が volatile sig_atomic_t になっていません。これにより、whileループ内のコードを実行中にSIGINTを受けた時、whileループからの脱出が起きない可能性があります。なぜそんな事が起き得るか、それは:

  • シグナルハンドラは、メモリ上のgSignaledの値を1に更新する
  movl    $1, gSignaled
  • しかし、main関数は、gSignaledの値がレジスタ上にあると思っている。whileループの直前で一度だけメモリ上のgSignaled変数の値をレジスタにコピーし、whileループに入ってからはレジスタだけを参照する。
  movl     gSignaled, %ebx
.L8:
  testl    %ebx, %ebx
  jne      .L8


コンパイラが上記のようなコードを吐く可能性があるためです。gccでは、-O2で最適化した場合に実際にそういうコードを生成しますので机上のみの脅威ではありません。この手の問題は、デバイスドライバを書く人間には半ば常識と思いますが、アプリケーションプログラムの設計者・実装者にはあまり知られていないのが現実でしょう。


この問題を解決するためには、大域変数 gSignaled の型を次のように変更します。

	volatile sig_atomic_t gSignaled;

volatile修飾を行えば前頁のような最適化は行われなくなり、常にメモリを参照するようになりますので、シグナルハンドラでの値の更新がmain関数のループにまで確実に反映されます。


sig_atomic_t は、CPUに応じて適切にtypedefされる整数型で、例えばx86ではint型です。「単一のマシン語命令で更新可能な最大の*2メモリ幅」に定義されます。シグナルハンドラから「参照」される変数は、その型をsig_atomic_t型にしなければなりません。sig_atomic_t型ではない変数(例えばx86における64ビット整数)は、更新を行うのに2つのマシン語命令が必要です。もしマシン語命令を1つ実行した段階でシグナルに割り込まれ、その変数をシグナルハンドラから参照すると、全くわけのわからない値に見えてしまいます。他にもアライメントの問題などで更新が1命令で行えないケースもありえます。変数の型をsig_atomic_tにしておけば、その変数の更新は1マシン語命令で行われることが保証されますので、シグナルハンドラから参照しても問題が起きません。


2006/1/16 追記: ちょっと書き忘れ。sig_atomic_tの詳細は、C99規格の§7.14.1.1/5を参照してください。シグナルハンドラからvolatile sig_atomic_t型以外の変数をいじると、結果は "unspecified" です。なお、sig_atomic_t型の変数が取りえる値の範囲は、SIG_ATOMIC_MIN/MAXで知ることができます(同§7.18.3/2)。符号有無は処理系によります。移植性を最大限確保したいなら、0〜127 の値のみ格納するようにすればよいでしょう。この範囲の値が格納できることはC99で保障されています。C++規格(14882:2003)での同様の記述は、確か§1.9/9にあります。SUSv3での記述はsigactionのところ*3。あと、GCCのマニュアルにはポインタ型もアトミックに更新できると書いてあるのだけど、標準には記載がなさげ*4


誤り3: シグナルハンドラから「非同期シグナルセーフ」ではない関数を呼んでいる


前掲のサンプルコードは、シグナルハンドラ内でprintf関数を呼んでいますが、printf関数は「非同期シグナルセーフ」な関数ではないので、シグナルハンドラから呼ぶと問題を起こします。具体的には、シグナルハンドラ内でprintf関数を呼んだ瞬間に、プログラムがデッドロックを起こして停止する可能性があるでしょう。ただし、タイミングに依存するバグとなるため、再現が困難で、解析困難なバグとなること請け合いです。

バグ発生のメカニズムは次のようになります。まず、printf関数の内部処理を把握してください。

  • printf関数は内部でmalloc関数を呼ぶ
  • malloc関数は、関数に固有(static)なmutexを保持しており、複数スレッドが同時にmallocを呼んだ場合の排他制御に用いている
  • つまり、malloc関数は「mutexのロック、メモリ確保処理、mutexのアンロック」という「分割不能な一連の処理」を行っている

上記を把握しておくと、

main関数:
  call printf  // whileループの中のprintf関数
    call malloc
      call pthread_mutex_lock(malloc関数内のstaticなmutex)
      // mallocの処理中に..
☆SIGINTを受信!
        call sig_handler
          call printf // シグナルハンドラ中のprintf関数
            call malloc
              call pthread_mutex_lock(malloc関数内のstaticなmutex) 
              // 同じmutexを再度ロックしてしまった!デッドロック!!


このようなシナリオでデッドロックが発生し得ることが確認できるでしょう。このバグを修正するためには、シグナルハンドラからは「非同期シグナルセーフ」関数だけを呼ぶように変更するしかありません。「非同期シグナルセーフ関数」の一覧は、UNIXの規格(SUSv3)を見れば書いてあります*5。きっと、その量の少なさに驚かれる事でしょう。なお、

  • SUSv3でasync-signal-safeであるとされていても、実装がそうなっていない場合もあり得るので、OSのマニュアルも必ず参照すること
  • 三者が作成した関数については、特に明記されていない場合、非同期シグナルセーフティについて悲観的な仮定をすること
  • 非同期シグナルセーフではない関数を呼んでいる関数は非同期シグナルセーフではない

も忘れてはなりません。


最後に、念のために「非同期シグナルセーフ」とは何であるか説明しておきます。非同期シグナルセーフな関数とは、「関数内の任意の場所でシグナルに割り込まれても、次回の関数呼び出しに問題がない関数」の事です。その関数に静的に紐づけられたデータを更新するような関数(例: malloc)は、大抵の場合、非同期シグナルセーフにできません。ただし、静的なデータを持っていても、そのデータの処理中のシグナル受信を禁止(マスク)している場合は、特例的に非同期シグナルセーフ関数たりえます。


以上。

*1:sigaction関数を呼ぶ前にSIGINTを受けるとプログラムが終了してしまう、というのはとりあえず除外しましょう

*2:「最大の」は嘘でした。例えばAlphaは32/64bit変数は単一で更新できるけど、8/16bitは複数命令になったりするそうで。http://lists.sourceforge.jp/mailman/archives/anthy-dev/2005-September/002336.html このへんです。

*3:If the signal occurs other than as the result of calling abort(), kill(), or raise(), the behavior is undefined if the signal handler calls any function in the standard library other than one of the functions listed in the table above or refers to any object with static storage duration other than by assigning a value to a static storage duration variable of type volatile sig_atomic_t. Furthermore, if such a call fails, the value of errno is unspecified.

*4:このマニュアルの In practice, you can assume that int and other integer types no longer than int are atomic. の部分は嘘とのこと。Alphaの例を参照

*5:The following table defines a set of functions that shall be either reentrant or non-interruptible by signals and shall be async-signal-safe. の後ろの表