コラム: シグナル安全な終了パターン
SIGINT を受け取ったら DB をファイルに保存して終了する。一見当たり前の要件ですが、「シグナルハンドラ内で何をやって良いか」 を知らないと、まずいコードを書きがちです。本コラムでは、ハンドラと通常コードの境界線を引き直します。
1. なぜ「ハンドラから直接 save」してはいけないのか
シグナルは 任意の関数の任意の命令の途中 で割り込みます。たとえばあなたのコードがちょうど malloc 内部のロックを取った瞬間に SIGINT が来て、ハンドラの中でも malloc (たとえば std::ofstream の内部) を呼んだら — 同じロックを再取得しようとして デッドロック か未定義動作になります。
POSIX は「シグナルハンドラから安全に呼べる関数のリスト」を async-signal-safe という名前で定めています。man 7 signal-safety を見ると、write, _exit, read などはセーフですが、
printf,std::cerr(内部でmallocを呼ぶ可能性)std::ofstreamの<<やclose(fopen系内部)std::mapの操作 (アロケータ経由でmalloc)exit(atexit ハンドラを動かす)
これらは いずれもアウト です。つまり「ハンドラ内で save() を呼ぶ」は確実にダメな書き方になります。
2. 正しいパターン: フラグだけ倒す
代わりに、ハンドラはフラグを 1 ビット倒すだけ にします。本物の処理 (= save) は通常コンテキストで行います。
volatile sig_atomic_t g_running = 1;
void on_sigint(int) {
g_running = 0; // これだけ。async-signal-safe な代入。
}
void MiniDb::run() {
while (g_running) {
if (select(...) < 0 && errno == EINTR) continue;
/* … fd 走査 … */
}
save(); // ← ここは通常コンテキスト。何でも呼べる。
}select は SIGINT で起こされて -1 / EINTR で抜けるため、ループ条件で g_running == 0 を見つけ次第、save() 経路に入って正常終了できます。
3. なぜ volatile sig_atomic_t なのか
volatile: : コンパイラが「ループ内で変わらない」と誤判断してフラグの読み込みを最適化で消すのを防ぎます。while (g_running) を while (1) に書き換えられたら終わりです。
sig_atomic_t: : 「シグナルハンドラからの読み書きと、通常コードからの読み書きが、互いに 裂けない (atomic) 」と保証された型です。int でも実用上は動きますが、規格的に保証されているのは sig_atomic_t だけ。
C++11 以降なら std::atomic<bool> も選択肢ですが、シグナルハンドラから安全に使えるのは std::atomic_flag または is_lock_free() が真の std::atomic<T> のみです。ここでは C++98 + 単純なフラグで書いているので、volatile sig_atomic_t がいちばん教科書的。
4. SIGPIPE — もう 1 つの罠
クライアントが切断した直後の fd に send すると、デフォルトでは SIGPIPE が飛んできてプロセスごと終了します。サーバ的には「相手の都合でこっちが落ちる」のは最悪なので、起動時に明示的に無視しておきます:
std::signal(SIGPIPE, SIG_IGN);これで send は単に -1 / EPIPE を返すだけになり、戻り値を無視すれば実害なし。
まとめ
- ハンドラは フラグを倒すだけ。
save/printf/mallocを呼ばない。 - フラグは
volatile sig_atomic_t。 selectは SIGINT でEINTRで抜けるので、ループ条件で再評価して正常終了経路に入れる。- ついでに
SIGPIPEを無視 しておく。サーバの安定運用は他人に依存しない。
このパターンは mini_db に限らず、長時間動かす TCP サーバ全般で使い回せる「定石」です。