ユーザー空間でのシグナル処理

このページの目次

シグナルは、プロセスの制御(終了、一時停止など)を表すためにも使用できますし、特定のスレッドでイベントや例外が発生したことを通知するためにも使用できます。正しく使用するための鍵は、シグナルが誰に送られ、どのスレッドで配信され、いつブロックされ、そしてハンドラが何を行うことが安全かという点にあります。

処理、マスク、および pending

シグナルの処理(デフォルト、無視、ハンドラの呼び出し)はプロセス共有の属性ですが、マスク集合(mask)はスレッドごとの属性です。プロセスに対して送信されたシグナルは、それを許可するスレッドに配信される可能性があります。ハードウェア例外や pthread_kill によって生成されたスレッド指向のシグナルには、特定のターゲットが指定されます。

ブロックは無視とは異なります:シグナルは pending 状態になり、ブロック解除されるか、同期待機関口によって消費されるまで待機します。SIGKILL と SIGSTOP は、捕捉、無視、またはブロックすることはできません。マルチスレッドプログラムでは、pthread_sigmask を使用してマスクを調整します。新しいスレッドは作成者のマスクを継承し、別のスレッドが後でマスクを変更しても、自動的に一緒に変化することはありません。signal(7)

通常のシグナルは、通常、送信ごとにカウントされません。同じシグナルが pending 期間中に複数回発生した場合、マージ(結合)される可能性があります。SIGUSR1 を損失のないタスクキューとして扱うのは信頼性の低い設計であり、実際のタスク数はキューまたはカウンターに保存し、シグナルはチェックを促す役割だけを果たすべきです。

なぜ送信回数が処理回数と一致しないのか?

比較の鍵となる条件は、シグナルがまだ pending 状態にあるかどうかです。ブロックを維持したまま3回連続で送信すると、通常のシグナルは1つのインスタンスだけが残ります。一方、送信ごとにハンドラが1回消費する場合は、3回処理することができます。リアルタイムシグナルはキューイングされたインスタンスを保持しますが、送信の成功も確認する必要があります。

図を準備しています
なぜ送信回数が処理回数と一致しないのか?

通常のシグナルはpending期間中にマージされます。リアルタイムシグナルはキューに積めますが、リソースの上限があります。マスク解除は、通常のシグナルに対してマージされた回数を回復しません。

sigaction と実行コンテキスト

sigaction は、ハンドラ、実行中の追加マスク集合、および動作フラグを明示的に指定します。sigset_t は、sigemptyset/sigaddset などのインターフェースを通じて初期化する必要があり、整数として直接代入できる移植性のあるフィールドであると仮定してはいけません。

フラグ意味
SA_SIGINFOsiginfo_t とコンテキスト引数を持つハンドラを使用
SA_RESTART再起動をサポートするインターフェースに対して、対応する動作を要求します。すべてのシステムコールをカバーするわけではありません。
SA_NODEFER処理中であっても同種のシグナルを自動的にブロックせず、より複雑な再入を許可します。
SA_RESETHAND処理の開始時に、対応するデフォルトの処理に戻します。これはその後の配信動作に関与します。
SA_ONSTACKそのスレッドに使用可能な代替スタックがある場合、それを使用して処理関数を実行します。
中断されたスレッドでハンドラが実行され、戻り時に保存されたコンテキストが復元される 通常の実行 スレッドの現在のレジスタとスタック ハンドラの実行 カーネルはまずユーザー空間のシグナルフレームを構築する 実行の再開 trampoline → rt_sigreturn スレッドごとに代替シグナルスタックを構成できます。処理関数は新しいスレッドではなく、中断されたライブラリの状態が再入可能であると仮定することはできません。

シグナルフレームは、実行を再開するために必要なレジスタ、マスク、およびその他のコンテキストを保存します。その具体的なレイアウトは、アーキテクチャと ABI によって決定されます。ハンドラが正常に返した後、ユーザー空間の trampoline を経由して対応する sigreturn パスが呼び出されます。x86-64 のスタックレイアウトを arm64 の固定構造であると見なしたり、通常の関数呼び出しのように sigreturn を直接呼び出したりしないでください。

非同期シグナル安全性はスレッド安全性よりも厳格である

ハンドラは、malloc、printf、または通常のロックが内部状態を変更している最中に現在のスレッドを中断する可能性があります。同じ機能機構を再度呼び出すと、一貫性の欠如やデッドロックが発生する可能性があります。通常の「スレッド安全性」の保証は、async-signal-safe の保証に取って代わるものではありません。

一般的な設計では、ハンドラは volatile sig_atomic_t フラグを設定するだけで、通常の制御フローでタスクを処理します。この型は制限されたシグナル相互作用のために使用され、任意のスレッド共有プロトコルの代替手段ではありません。複雑なカウント、インクリメント、および複数のフィールドの一貫性は、個別に分析する必要があります。

イベントループをウェイクアップする必要がある場合は、事前に確立されたノンブロッキング self-pipe を使用し、ハンドラ内で許可された write を呼び出すことができます。パイプが満杯の場合、通知がマージされたことを受け入れ、通常のパスで状態を再確認する必要があります。write は非同期安全なインターフェースですが、ブロッキング fd 上では待機する可能性があります。安全であることは、遅延がないことを意味しません。errno を使用するハンドラは、それを保存して復元する必要があります。signal-safety(7)

sigsuspend を使用してチェックと待機の間の競合を回避する

while (!seen) pause() にはウィンドウがあります:チェックの直後にシグナルが処理され、その後 pause が次のシグナルを待ち続けます。以下の単一スレッドの例では、まずシグナルをブロックし、sigsuspend を使用してマスクを原子置換して待機します。

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <unistd.h>

static volatile sig_atomic_t seen;
static void on_signal(int signo) {
    (void)signo;
    seen = 1;
}

int main(void) {
    sigset_t blocked, previous, waiting;
    sigemptyset(&blocked);
    sigaddset(&blocked, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &blocked, &previous) == -1) return 1;

    struct sigaction action = {0};
    action.sa_handler = on_signal;
    sigemptyset(&action.sa_mask);
    if (sigaction(SIGUSR1, &action, NULL) == -1) return 1;

    if (raise(SIGUSR1) != 0) return 1; // この時点で pending に入る
    waiting = previous;
    sigdelset(&waiting, SIGUSR1);
    while (!seen) {
        if (sigsuspend(&waiting) == -1 && errno != EINTR) return 1;
    }
    if (sigprocmask(SIG_SETMASK, &previous, NULL) == -1) return 1;
    return 0;
}

待機中は SIGUSR1 の配信が許可され、ハンドラが返した後、sigsuspend は元のブロック状態に戻り、EINTR で返ります。ループは seen を再確認し、他のシグナルによるウェイクアップに対応できます。この例の自発的なシグナルは、プロセスを繰り返可能にするためのものであり、実際のプログラムは他の権限のあるソースから通知を受けることができます。sigsuspend(2)

pselect、ppoll、epoll_pwait も、I/O を待機する際にマスクを一時的に置換することで、同様のウィンドウを解決し、手動でブロック解除してから通常の wait を呼び出すのではなく、より安全なアプローチを提供します。

まずブロックしてからチェックし、原子操作でブロック解除して待機する SIGUSR1 はブロック済み seen == 0 をチェック この時点でシグナルが到着 pending に入り、ハンドラはまだ実行されていない sigsuspend 一時的にブロック解除して待機 pending が配信される → ハンドラが seen を設定 → 待機が EINTR で返る ブロックマスクを復元した後、seen を再確認する。通知を消費してから無条件にスリープすることはない。 修正の鍵は、pause をより短いポーリング間隔に置き換えることではなく、マスクと待機の原子操作による置換である。

エラーのあるタイミングと正しいタイミングを段階的に比較

時刻チェックしてから pauseブロック + sigsuspend
1seen=0 を読むシグナルがブロックされている間に seen=0 を読む
2ハンドラが実行され、seen=1 を書くシグナルが到着し、pending として記録されるだけ
3pause が次のシグナルを待ち始めるマスクを原子操作で置換し、既存の pending を配信
4新しいシグナルがない場合、スリープを続けるハンドラが seen を設定し、返った後に再確認

「ハンドラがすでにフラグを 1 に設定した」という事実は、進入しようとしている待機がそれを自動的に認識することを意味しません。待機システムコールは、アプリケーションの seen 変数を読み取りません。この例はまた、通知と待機される条件は別物であることを示しています:ループは条件の確認を担当し、原子待機は条件の変化に対応する通知を見逃さないようにする責任を負います。

フラグがすでに 1 である場合、なぜスレッドはスリープするのか?

「チェック→送信→待機」を試してみましょう:誤ったソリューションでは seen はすでに 1 ですが、スレッドは次のシグナルを待ちます。正しいソリューションでは、シグナルは pending のまま残り、sigsuspend に入ったときに配信されます。「チェック→待機→送信」をもう一度試してみましょう。どちらのソリューションでも返却されるため、正常な順序のみを検証しても、待機プロトコルをチェックするには不十分であることがわかります。

図を準備しています
フラグがすでに 1 である場合、なぜスレッドはスリープするのか?

pause はアプリケーションのフラグを読み取らない。チェック後、待機前にシグナルが処理されると、ウェイクアップが失われる可能性がある。ブロック解除後、sigsuspend でマスクを原子置換することで、pending 通知を保持できる。

SA_RESTART と EINTR

Linux 上では、一部の低速デバイスに対する read/write、wait シリーズ、および条件を満たすソケット操作が SA_RESTART の影響を受ける可能性があります。poll、select、epoll_wait、pause、sigsuspend などは、このフラグを設定したからといって自動的に再起動されるわけではありません。ソケットのタイムアウトオプションなどは具体的な動作を変更するため、関数名だけで無条件の二分表を作ることはできません。

read/write で一度に部分的なデータが転送された場合、正の短いカウントを返すことがあります。アプリケーションは進捗を保持し、メッセージ全体を最初から再送信してはいけません。相対的な時間を使用してタイムアウト待機を行う場合、EINTR 後の再試行では、経過した時間を差し引く必要があります。

SIGSEGV などの同期障害のハンドラが返した後、原因が解消されていない場合、同じ障害命令が再度実行される可能性があります。ハンドラをインストールしても、任意のメモリエラーが回復可能な例外になるわけではありません。

代替シグナルスタックはスレッドごとのリソースである

sigaltstack は、まだ有効なメモリ領域を登録し、SA_ONSTACK は対応するハンドラがそれを使用するようにします。これにより、通常のスタックがすでに不足している場合でも、最低限の情報を記録するスペースがある可能性があります。ただし、代替スタック自体が無限に成長するわけではなく、malloc、複雑なスタック展開、またはログライブラリが突然非同期安全になるわけではありません。

サイズは、アーキテクチャの拡張状態、ネストされたシグナル、およびハンドラの要件を考慮して決定する必要があり、「通常 8 KiB で十分」とハードコーディングしてはいけません。関連するスレッドごとに確立し、ライフサイクルを正しく管理します。置換または解放する際には、まだ使用されていないことを確認してください。sigaltstack(2)

同期消費:sigwait と signalfd

専用シグナルスレッドは、ターゲットシグナルをブロックし、sigwaitinfo/sigwait を使用して同期的に受信できます。通常のスレッドコンテキストでの後続の処理は、非同期ハンドラの同じ制限を受けません。Linux の signalfd は、シグナル記録を fd イベントループに接続します。

従来のハンドラにシグナルが送られないようにするために、関連するすべてのスレッドで一貫したルーティング戦略を確立する必要があります。signalfd は、別のスレッド専用にハードウェア障害を管理スレッドに任意に持ち越すことはできません。設定例は epoll とイベント駆動 を参照してください。

通常のシグナルのマージは、1つのプロセス内で再現可能であることを確認できる

以下では、まず SIGUSR1 をブロックし、自分自身に3回連続で送信し、その後同期的に消費します。3回発生しても、それらはまだ配信されていないため、pending には通常のシグナルインスタンスが1つだけ残ります。2つ目のシグナルがまだあるかどうかを確認するために、ゼロタイムアウトの sigtimedwait を使用し、無期限に待機しないようにします。

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
int main(void) {
    sigset_t set, old;
    sigemptyset(&set);
    sigaddset(&set, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &set, &old) == -1) return 1;
    for (int i = 0; i < 3; ++i)
        if (kill(getpid(), SIGUSR1) == -1) return 1;
    struct timespec zero = {0, 0};
    siginfo_t info;
    int first = sigtimedwait(&set, &info, &zero);
    errno = 0;
    int second = sigtimedwait(&set, &info, &zero);
    int saved = errno;
    if (sigprocmask(SIG_SETMASK, &old, NULL) == -1) return 1;
    printf("one received=%d; next empty=%d\n",
           first == SIGUSR1, second == -1 && saved == EAGAIN);
    return !(first == SIGUSR1 && second == -1 && saved == EAGAIN);
}

pending-demo.c として保存し、コンパイルして実行すると、one received=1; next empty=1 が期待されます。この単一スレッド、自発的なシグナルの実験では、ハンドラをインストールする必要はありません。sigwaitinfo/sigtimedwait は、シグナルがブロックされたままの状態で同期的にそれを取得します。ゼロタイムアウトの EAGAIN は、現在受信可能なターゲットシグナルがないことを示します。sigtimedwait(2)

この結果を「いつでも3回送信すると1回しか処理されない」と一般化してはいけません。各送信の間に前のシグナルが消費された場合、3回個別に観察される可能性があります。マージを決定するのは pending 期間中の動作です。信頼性の高いタスク数が必要な場合は、実際のタスクキューを読み取る必要があります。

リアルタイムシグナルと timerfd

リアルタイムシグナルは sigval データを保持し、キューに積むことができますが、キューはリソース制限を受け、sigqueue は失敗する可能性があり、決して失われないことを約束するものではありません。同じリアルタイムシグナルのキューイング順序と、異なるシグナル番号の配信優先順位には規定がありますが、すべての番号が一貫した送信時間の FIFO であるわけではありません。SIGRTMIN はスレッドライブラリによって調整される可能性もあるため、インターフェースマクロを使用し、範囲を確認する必要があります。

timerfd 通知にはシグナルハンドラは必要ありません。read の成功時に返されるのは通常 sizeof(uint64_t) バイトであり、読み出された整数が累積された期限切れ回数であり、1 より大きい場合があります。「戻り値が 8」を期限切れが 8 回であると誤解しないでください。単調クロックは時間間隔の測定に適していますが、システムサスペンドが含まれるかどうかは、選択したクロックに依存します。

待機の再試行では、総時間予算を保持する

待機予算が 100 ms で、30 ms 目にシグナルによって中断され、その後さらに 40 ms 消費したと仮定します。EINTR ごとに 100 ms の待機を再試行すると、総待機時間が延長されます。連続したシグナルは、完了時間を延ばし続ける可能性があります。アプリケーションは、選択した単調クロックを使用して固定されたデッドラインを計算し、現在の時間に基づいて残りの予算を計算する必要があります。

deadline_ms = 100
def remaining(now_ms):
    return max(0, deadline_ms - now_ms)
assert remaining(0) == 100
assert remaining(30) == 70
assert remaining(70) == 30
assert remaining(105) == 0

これは整数時間のモデルにすぎません。実際の C プログラムでは、timespec の秒/ナノ秒の正規化、クロック読み取りのエラー、および特定の待機関口のタイムアウトルールを処理する必要があります。SA_RESTART は、アプリケーション層のデッドラインを維持する代わりに使用することはできません。ある呼び出しが再起動できる場合でも、それに基づいて「1回のブロック期間」と「全体の操作に許可された総時間」を混同すべきではありません。

デバッグ時に観察すべきこと

strace -e trace=rt_sigaction,rt_sigprocmask,rt_sigsuspend -e signal=all ./prog

/proc/PID/status の SigBlk、SigIgn、SigCgt は、マスクと処理を記述し、SigPnd と ShdPnd はスレッドとプロセスの pending を区別します。マルチスレッドの問題では、対応するタスクの状態も確認する必要があります。

まず、シグナルのターゲット、マスク、および pending を確認し、その後、ハンドラが実行されたかどうか、および待機が再起動されたかどうかを分析します。シグナルの頻度を使ってイベント発生回数を直接推定しないでください。また、1回のハンドラ成功を、プログラム全体の同期プロトコルが正しく動作した証拠としないでください。

キーワード:signal, sigaction, signal mask, pending, async-signal-safe, sigsuspend, SA_RESTART, sigaltstack, signalfd, real-time signal