シグナル捕捉の仕組みとカーネル遷移
Linux プロセスにおける実行コンテキストは、大きく分けて「ユーザーモード」と「カーネルモード」の 2 状態存在します。ユーザーモードではアプリケーションコードが実行され、カーネルモードでは OS カーネルのコードが実行されます。シグナルが発生し、プロセスに配送される際、CPU はユーザーモードからカーネルモードへ遷移します。
この遷移時に、カーネルはプロセスが保持する以下の 3 つの信号関連テーブルを確認します。
- block 表: 現在ブロックされている信号の集合
- pending 表: 発生したがまだ処理されていない信号の集合
- handler 表: 各信号に対する処理動作の定義
もし信号に対する動作がユーザー定義の関数に設定されている場合、カーネルはユーザーモードに戻る前にその関数を呼び出します。この仕組みを「シグナルの捕捉」と呼びます。
sigaction による信号処理の登録
信号処理関数を登録する際、従来の signal() 関数よりも sigaction() システムコールを使用することが推奨されます。sigaction() はより詳細な制御が可能であり、異なる UNIX 系 OS 間での移植性も高くなります。
signal() との主な違いは以下の点です。
- ハンドラ実行中に、自動的に現在の信号がブロックされるため、再帰的な呼び出しを防ぐことができます。
sa_maskフィールドを使用することで、ハンドラ実行中に追加でブロックしたい信号を指定可能です。- システムコールが信号によって中断された場合、自動的に再開させるかどうかの制御が可能です。
関数原型と構造体
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
主要なパラメータは以下の通りです。
signum:操作対象の信号番号(SIGKILL と SIGSTOP は除外)。act:新しい信号処理動作を定義した構造体へのポインタ。oldact:以前の設定を保存するためのポインタ。
設定構造体 struct sigaction には、処理関数ポインタ、信号マスク、動作フラグなどが含まれます。特に sa_flags では、SA_NOCLDSTOP(子プロセス停止時に信号を送らない)、SA_RESTART(中断されたシステムコールを再開)、SA_SIGINFO(追加情報付きハンドラを使用)などのオプションを指定できます。
実装例
以下のコードは、SIGTERM 信号を捕捉して任意の処理を行う例です。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void sig_handler(int sig) {
printf("Caught signal: %d\n", sig);
}
int main(void) {
struct sigaction sa;
// 構造体の初期化
sa.sa_handler = sig_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
// 信号処理の登録
if (sigaction(SIGTERM, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
printf("Waiting for SIGTERM...\n");
while(1) {
sleep(1);
}
return 0;
}
シグナルハンドラ内の関数呼び出しと再入可能性
シグナルハンドラは、プログラムの任意の時点で割り込んで実行される可能性があります。そのため、ハンドラ内で呼び出す関数は「再入可能(Reentrant)」である必要があります。
再入可能関数とは、同時に複数の実行フローから呼び出されても安全に動作する関数です。具体的には、グローバル変数や静的変数に依存せず、自身のローカル変数または引数のみで処理が完結する関数を指します。
一方で、以下のような関数は不可再入(Non-reentrant)であり、シグナルハンドラ内での使用は避けるべきです。
mallocやfree:ヒープ管理にグローバルなデータ構造を使用するため、デッドロックやメモリ破損の原因となります。- 標準入出力関数(
printfなど):内部でバッファ管理のためにロックを使用する実装が多く、安全が保証されません(厳密には async-signal-safe な関数リストを確認する必要があります)。
コンパイラ最適化と volatile 修飾子
シグナルハンドラとメイン処理フロー間で共有される変数を変更する場合、volatile 修飾子の使用が不可欠です。コンパイラは最適化を行う際、変数の値を CPU レジスタにキャッシュすることがあります。
例えば、メインループで共有フラグを監視している場合、コンパイラは「この変数はループ内で変更されない」と判断し、メモリから読み込まずレジスタの値を使い続ける可能性があります。この場合、シグナルハンドラでメモリ上の変数を書き換えても、メインループからはその変更が見えず、無限ループに陥ります。
volatile を宣言することで、コンパイラに対し「この変数は外部要因で変更される可能性があるため、毎回メモリから読み書きせよ」と指示できます。
最適化による問題と対策
最適化オプション(例:-O2)を有効にして编译した場合、以下のコードは volatile がないと正常に終了しません。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile int shutdown_req = 0;
void on_interrupt(int sig) {
shutdown_req = 1;
}
int main() {
signal(SIGINT, on_interrupt);
// volatile がない場合、最適化によりループ条件が評価されなくなる可能性がある
while (shutdown_req == 0) {
// Waiting for signal
}
printf("Exiting gracefully.\n");
return 0;
}
SIGCHLD を利用した子プロセスの管理
子プロセスが終了すると、親プロセスに対して SIGCHLD 信号が送信されます。デフォルトではこの信号は無視されますが、これを捕捉することで、子プロセスの終了を検知し、リソースを解放することができます。適切に処理しないと、終了した子プロセスが「ゾンビプロセス」としてシステムに残存します。
waitpid による回収
シグナルハンドラ内で waitpid を呼び出すことで、終了した子プロセスの状態を取得し、ゾンビ化を防ぎます。複数の子プロセスが同時に終了する可能性があるため、ループ内で非阻塞モード(WNOHANG)で回収を行うのが一般的です。
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void reap_children(int sig) {
int status;
pid_t pid;
// 全ての子プロセスを回収する
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("Reaped child: %d\n", pid);
}
}
int main() {
signal(SIGCHLD, reap_children);
if (fork() == 0) {
sleep(2);
exit(0);
}
while(1) {
pause();
}
}
SIG_IGN による自動回収
もう一つの方法として、SIGCHLD の処理動作を SIG_IGN に設定する方法があります。これにより、子プロセスが終了した際にカーネルが自動的にリソースを解放し、ゾンビプロセスが発生しなくなります。ただし、この挙動は実装依存である場合があり、 POSIX 標準では sigaction を使用して明示的に無視を設定することが推奨されます。
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
struct sigaction sa;
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
// SIGCHLD を無視設定
sigaction(SIGCHLD, &sa, NULL);
if (fork() == 0) {
sleep(2);
exit(0);
}
while(1) {
sleep(1);
}
return 0;
}