シグナル配送の内部とシグナルセーフティ
ハンドラ内の malloc がなぜデッドロックを生むのか、その根を断てます。ペンディング集合・ブロックマスク・配送タイミングからasync-signal-safeの制約までを原理から解説します。
- 1.シグナルは送信時に task のペンディング集合へ記録され、配送はカーネルからユーザー空間へ戻る境界で行われる。ブロックマスクで保留中の配送を一時的に止められる。
- 2.ハンドラは任意命令の途中へ割り込むため、再入不可な関数(malloc・printf 等)は呼べない。安全に呼べるのは signal-safety(7) が列挙する async-signal-safe 関数だけ。
- 3.標準シグナルは同種が1個に潰れ順序も保証されないが、リアルタイムシグナル(SIGRTMIN〜)はキューイングされ値を運べる。signalfd でシグナルを fd 経由の同期イベントに変えられる。
なぜ「配送タイミング」を知る必要があるのか
シグナルの基礎(シグナル)では「合図を1つ送る非同期通知」だと説明しました。しかし実務でシグナル絡みの障害——ハンドラ内のデッドロック、取りこぼし、EINTR の謎——を解くには、シグナルがいつ・どこで・どう配送されるかという内部機構を正確に押さえる必要があります。本記事は、生成(generate)と配送(deliver)の分離、ブロックマスク、シグナルフレーム、そして async-signal-safety の根拠を原理から解剖します。
生成と配送は別物:ペンディング集合とブロックマスク
シグナルのライフサイクルは2段階に分かれます。
- 生成(generation):
kill()やハードウェア例外などでシグナルが発生し、対象タスクの ペンディング集合(pending set) に記録された状態。 - 配送(delivery):そのシグナルに対応する動作(ハンドラ実行・既定動作・無視)が実際に行われる瞬間。
この2つの間に時間差が生まれるのは、各タスクが ブロックマスク(signal mask、sigprocmask で操作) を持つからです。マスクされたシグナルは、生成されてもペンディングのまま 保留 され、マスクが解かれて初めて配送されます。
kill(pid, SIGUSR1)
│
▼ 生成:pending集合に SIGUSR1 を立てる
[pending] ── マスク中? ──Yes──→ 保留(マスク解除まで待機)
│ No
▼ 配送:ハンドラ/既定動作を実行し、pendingから降ろす
カーネル内では、ペンディング集合もブロックマスクも sigset_t(ビットマップ)で表現され、シグナル番号 n はビット n に対応します。つまり「SIGUSR1 が保留中か」は単なるビットテストです。
ブロック(マスク)は配送を「先延ばし」にするだけで、シグナルは pending に残り続けます。一方、ディスポジションを SIG_IGN(無視)にすると、配送時点で捨てられます。マスクを解いた瞬間に保留分が配送される点が、無視との決定的な違いです。
配送はいつ起きるか:カーネル→ユーザー空間の境界
ハンドラは「実行中のどのタイミングでも割り込む」と言われますが、厳密には任意の機械語命令の途中で割り込むわけではありません。配送が起きるのは、カーネルからユーザー空間へ制御が戻る境界 です。具体的には次のような瞬間です。
- システムコール(システムコール)から復帰するとき
- 割り込み・例外処理からユーザーモードへ戻るとき
- タイムスライス満了などでスケジューラ経由でタスクが再開するとき
カーネルはこの戻り際に「配送可能な(pending かつ非マスク)シグナルはあるか」をチェックし(do_signal 系の処理)、あればユーザー空間へ戻る前にハンドラを差し込みます。だから純粋にユーザー空間で無限ループしているプロセスにも、タイマー割り込みでカーネルに入る隙があるため配送できます。
この「戻り境界で配送」という設計が、有名な EINTR の正体です。read() 等でブロック中にハンドラ付きシグナルが届くと、カーネルはシステムコールを中断してハンドラを呼び、システムコールは errno = EINTR で返ります(SA_RESTART 指定時は自動再開)。
ssize_t n;
while ((n = read(fd, buf, len)) < 0 && errno == EINTR)
; // EINTR は「失敗」ではない。ループで再試行するのが定石
シグナルフレーム:スタックに積まれる「割り込みの記録」
ハンドラを呼ぶには、現在のユーザーコンテキスト(レジスタ・元の戻り先・配送時のブロックマスク)を保存しておき、ハンドラ終了後に元の処理へ正確に戻る必要があります。カーネルはこれを シグナルフレーム として ユーザースタック上に構築 します。
- カーネルが、割り込まれた地点のレジスタ一式と元のマスクを
ucontext構造としてユーザースタックへ積む。 - ハンドラの戻り先を トランポリン(
sigreturnを呼ぶ小片コード)に差し替え、ハンドラへジャンプする。 - ハンドラが return すると、トランポリンが
sigreturnシステムコールを発行する。 sigreturnがフレームから元のコンテキストを復元し、割り込まれた地点へ正確に復帰する。
[ユーザースタック]
┌────────────┐ ← ハンドラ実行中はここから下が積まれる
│ シグナルフレーム │ 保存されたレジスタ・元マスク・戻り先(トランポリン)
├────────────┤
│ 割り込まれた地点 │ ← sigreturn でここへ戻る
スタックが壊れている状況(SIGSEGV をスタックオーバーフローで受けた等)でも配送できるよう、sigaltstack() で 別スタック(alternate stack) を指定し、SA_ONSTACK で利用できます。スタック破壊系のシグナルを安全に捕まえる定石です。
配送時、カーネルはそのシグナル(および sa_mask で追加指定したもの)を一時的にブロックマスクへ加えます。だからハンドラの中で同じシグナルが再帰的に割り込むことは通常ありません。sigreturn でフレームを復元する際に、保存しておいた元のマスクへ戻されます。
async-signal-safety:なぜ malloc を呼んではいけないか
ここが上級者の核心です。ハンドラは メインの処理と同じスレッドの、任意の地点へ割り込んで 実行されます。割り込まれたコードが malloc の途中でヒープのロックを保持していたら、その同じスレッドのハンドラ内で再び malloc を呼ぶと——自分が握ったロックの解放を自分で待つデッドロック が起きます。
この問題の本質は 再入可能性(re-entrancy) です。ハンドラから安全に呼べるのは、内部状態やロックを共有せず、途中で割り込まれて再入されても壊れない関数——すなわち async-signal-safe な関数だけです。POSIX/Linux は安全な関数の一覧を signal-safety(7) で明示的に列挙しており、そこに無い関数(malloc/free/printf 系/std::cout/大半の stdio/ロケール依存関数など)は呼んではいけません。
| 分類 | 代表例 | ハンドラ内で | 理由 |
|---|---|---|---|
| async-signal-safe | write, read, _exit, kill, sigaction, sem_post | 呼んでよい | 内部ロックを持たず再入安全 |
| 非安全(割り込み危険) | malloc, free, printf, snprintf, syslog | 呼んではいけない | ヒープ/stdio のロックで自己デッドロック |
| 非安全(状態共有) | strtok, localtime, gmtime | 呼んではいけない | 静的バッファを再入で破壊 |
実務での定石は 「ハンドラはフラグを立てるだけ」 です。volatile sig_atomic_t なフラグを 1 にするか、signalfd/self-pipe で通知し、重い処理はメインループに戻ってから行います。
volatile sig_atomic_t got_term = 0;
void on_term(int sig) { got_term = 1; } // これだけ。printf も free も書かない
// メインループで if (got_term) { graceful_shutdown(); } を見る
printf がデッドロックしないように見えても、それは「たまたまロックを保持していない瞬間に割り込んだ」だけで、高負荷時に再現する時限爆弾です。なお errno はハンドラで書き換わりうるため、write 等を呼ぶ前後で 保存・復元 するのが厳密な作法です(int saved = errno; ...; errno = saved;)。
標準シグナル vs リアルタイムシグナル
標準シグナル(1〜31)には2つの本質的制約があります。同種のシグナルが複数生成されても1個に潰れる(合体される) こと、そして 配送順序が保証されない ことです。ペンディング集合がビットマップである以上、「SIGUSR1 が来た」という1ビットしか立たず、何回来たかは数えられません。
リアルタイムシグナル(SIGRTMIN 〜 SIGRTMAX、Linux では通常 32〜64)はこれを解消します。
- キューイングされる:同じ番号が複数届いても潰れず、生成順に並んで全部配送される(上限あり)。
- 値を運べる:
sigqueue()でintまたはvoid*をsiginfo_t->si_valueに添えられる。シグナルでデータを運べる唯一の正攻法。 - 番号間の順序:小さい番号が先に配送される(優先度がある)。
| 観点 | 標準シグナル(1-31) | リアルタイムシグナル(SIGRTMIN-) |
|---|---|---|
| 同種の多重配送 | 1個に合体される | 全てキューされる |
| 配送順序 | 未規定 | 番号順 + 同番号はFIFO |
| 付随データ | 運べない | si_value で値を運べる |
| 送信API | kill / raise | sigqueue |
「シグナルは取りこぼすか」への正答は、標準シグナルは同種が合体するので取りこぼし得る/リアルタイムシグナルはキューイングされ取りこぼさない(キュー満杯まで) です。SIGCHLD を標準シグナルで受ける子プロセス回収では、ハンドラ内で waitpid を WNOHANG でループして回収する——1回の配送が複数の子終了を表しうるから——が定番の理由もここにあります。
signalfd:非同期シグナルを同期イベントへ
ハンドラ方式の根本的な不自由さは「async-signal-safe の檻」です。これを回避するのが signalfd() です。シグナルをハンドラで非同期に受ける代わりに、ファイルディスクリプタからの読み取りという同期イベント に変換します。
sigset_t m; sigemptyset(&m); sigaddset(&m, SIGTERM);
sigprocmask(SIG_BLOCK, &m, NULL); // 通常配送を止めるのが前提
int sfd = signalfd(-1, &m, 0);
// 以降 read(sfd, &si, sizeof(struct signalfd_siginfo)) でシグナルを受け取る
要点は、対象シグナルを 必ずブロックしておく ことです。ブロックしてペンディングに溜め、それを signalfd から読み出します。この fd は epoll で監視できる(高性能I/Oモデル(epoll・io_uring)の内部)ため、シグナルを通常の I/O イベントと同じイベントループで一元処理 できます。ハンドラの再入制約から解放され、read 後の文脈では malloc でも何でも自由に呼べるのが最大の利点です。
同系統に、sigwaitinfo/sigtimedwait(専用スレッドでブロックして同期受信)や、タイマー・ファイル変更を fd 化する timerfd/inotify があり、いずれも「非同期イベントを fd の同期読み取りに統一する」Linux の設計思想を体現しています。デバッガがシグナルを横取りして観測する仕組みは ptrace とデバッグの内部 も参照してください。
まとめ
シグナルは 生成(pending集合へ記録) と 配送(カーネル→ユーザー境界で実行) が分離し、その間を ブロックマスク が制御します。配送はユーザースタックに シグナルフレーム を積み、sigreturn で元のコンテキストへ正確に戻します。ハンドラは任意地点へ割り込むため async-signal-safe な関数しか呼べず、malloc/printf はヒープ/stdio のロックで自己デッドロックを招く——だから定石は「フラグを立てるだけ」。標準シグナルは合体・順序未規定 なのに対し、リアルタイムシグナルはキューされ値も運べ、signalfd を使えば非同期シグナルを fd の同期読み取り に変えてイベントループへ統合できます。基礎はシグナル、シグナルでは運べないデータの受け渡しはプロセス間通信(IPC)へ。
OS Article
シグナル配送の内部とシグナルセーフティを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
シグナル
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
ハンドラは任意命令の途中へ割り込むため、再入不可な関数(malloc・printf 等)は呼べない。安全に呼べるのは signal-safety(7) が列挙する async-signal-safe 関数だけ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「シグナル / Linux」に近いか確認する。
- 強みである「シグナルは送信時に task のペンディング集合へ記録され、配送はカーネルからユーザー空間へ戻る境界で行われる。ブロックマスクで保留中の配送を一時的に止められる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。