シグナルと ptrace:非同期通知から制御された停止へ

このページの目次

kill() が成功を返しても、シグナルハンドラがまだ実行されていないことがあります。複数の子プロセスが同時に終了しても、親プロセスは 1 回だけの SIGCHLD を受け取ります。これらの現象を理解するには、⁠イベントの発生、保留状態、実際の配信を区別する必要があります。

ptrace はさらに 1 層の停止プロトコルを追加します。トレース元は特定の位置でターゲットを観察できますが、停止理由を正しく識別して初めて、正しく再開することができます。

遭遇する現象該当セクション
シグナルを送信しても反応がない、または特定のスレッドのみが受信する第 1 節:保留とスレッド選択
送信回数と処理回数が一致しない第 2 節:マージ、キューイングと実験
ハンドラがフリーズする、または待機タイムアウトが延び続ける第 3~4 節:非同期エントリと EINTR
デバッガの再開後にターゲットが異常終了する第 5~6 節:停止分類とアクセス境界

1. 発生、保留、配信は3つの異なる段階

送信インターフェースが成功することは、通常、シグナルリクエストがチェックを通過しカーネルに受け入れられたことを示しており、受信側がすでに処理関数を実行した確認ではありません。受信スレッドはそのシグナルをブロックしているか、まだスケジューリングされていないか、または一時的に応答できないカーネルパスにある可能性があります。もしビジネスロジックで何らかの操作の完了を確認する必要がある場合は、kill() の戻り値を完了通知として扱うのではなく、明示的な応答を設計すべきです。

まず、状態が誰に属しているかを確認しましょう。

状態所属範囲意味
処理:デフォルト、無視、処理関数通常、スレッドグループ内で共有受信後にどのように処理するか
ブロックマスクスレッドごとに独立そのスレッドが現在受信するかどうか
スレッド指向保留シグナル指定されたスレッド対象スレッドが受信可能になるのを待つ
プロセス指向保留シグナルグループ共有状態に保存可能適切なスレッドから受信者を選択

kill(pid, sig) は通常、プロセスに対して送信されます。pthread_kill() は特定のスレッドを指定します。⁠ターゲットスレッドがシグナルをブロックしている場合、スレッド指向のシグナルを他のスレッドに委ねることはできません。

プロセス指向シグナルは受信スレッドを選択し、全スレッドにブロードキャストしない kill(PID, SIGUSR1) プロセス指向発生 スレッドグループ共有保留 未配信のシグナル スレッド A SIGUSR1 をブロック スレッド B ブロックされていない、選択可能 スレッド C ブロックされていない、選択可能 いずれか一方を選択 スレッド指向シグナルは指定されたスレッドの保留状態に入る。そのスレッドがブロックしていても、他のスレッドに委ねることはできない。

図を読む際は、まず A のブロックマスクを確認し、次に B、C の受信資格を見る。A は SIGUSR1 をブロックしているため、B または C が選択される可能性があるが、両方がそれぞれ1回ずつ実行されるわけではない。3つのスレッドすべてがブロックしている場合、シグナルは保留状態のままになり、適切な配信機会があるまで保持される。

送信先とブロックマスクを変更し、スレッド上で配信を試行する。共有および個別の pending は別々に表示され、手動でスレッドを選択して可能な受信者を比較するが、カーネルの選択順序を保証するものではない。

図を準備しています
プロセス宛てか、スレッド宛てか?

プロセス指向シグナルは適切なスレッドの1つで受信できる。スレッド指向シグナルは指定されたスレッドにのみ留まる。処理は通常共有され、ブロックマスクと個別の pending はそれぞれに属する。

シグナルを一元処理するには、ワーカースレッドを作成する前に対象のシグナルセットをブロックし、新しいスレッドがブロックマスクを継承するようにし、専用のスレッドで同期的に消費させる。こうすることで受信位置がプログラムによって制御され、どのスレッドがたまたま選択されたかに依存しなくて済む。

SIGKILL と SIGSTOP は処理関数をインストールしたり無視したりすることはできず、sigaction() で処理を変更しようとすると失敗する。これらをブロック集合に追加しても、実際にブロックされるわけではない。ただし、実行状態の制約を受ける。SIGKILL はロック保持中のカーネルスタックを任意に放棄するものではなく、タスクは致命的シグナルを処理できるパスまで待機する必要があるかもしれない。TASK_KILLABLE などの待機設計は、対応する待機が致命的シグナルによって中断可能かどうかを決定する。signal(7)

2. 標準シグナルをイベントカウンターとして使えない理由

SIGUSR1 をブロックした状態で5回連続で送信し、ブロック解除時に処理関数が5回実行されることを期待してはいけない。同じ番号の標準シグナルがすでに保留されている場合、その後の発生は通常マージされる。それは「この種のエベントに注意が必要」という状態を保持するだけである。

同じ回数送信しても、保留状態の保持方法は異なる 標準シグナル:同じ番号が保留中ならマージ S S S S S 1つの保留 SIGUSR1 同じ番号のリアルタイムシグナル:各キューイング値を保持 10 11 12 10 11 12 前提:シグナルはブロックされており消費されていない。リアルタイムキューイングは成功。キューには上限があり、無限の信頼できるメッセージチャネルではない。

SIGCHLD:1回の通知、回収には複数回の操作が必要

SIGCHLD の正しい消費方法はここから導き出せる。1回の通知が届いたら、適切なノンブロッキング wait を繰り返し呼び出し、現在回収可能な子プロセスの状態をすべて取り出す。1回だけ回収すると、他のプロセスはすでに終了しているが、それを知らせる独立した後の SIGCHLD は残っていない可能性がある。シグナルがマージされた場合でも、終了状態はカーネルの子プロセス待避機構によって保存される。通知と事実の記録は異なる対象に属する。

リアルタイムシグナル:順序付きキューイングでもリソース上限は存在する

リアルタイムシグナルは sigqueue() を介して値を付与でき、同じシグナルの複数のインスタンスは送信順に配信される。図では 10、11、12 の3つの値が保持されているが、キュー容量は限られており、送信は EAGAIN で失敗する可能性がある。

異なるリアルタイムシグナル番号には優先順位があり、すべての番号を1つのグローバル FIFO に合成することはできない。SIGRTMIN + n を使用する際は SIGRTMAX を確認すること。glibc はスレッドライブラリ用にリアルタイムシグナルを予約しているため、SIGRTMIN を 34 と固定してはならない。signal(7)

連続送信後、2種類のシグナルをそれぞれ取り出す。図では意図的に小容量を使用して送信失敗を露呈させている。これは Linux の固定キュー上限ではない。

図を準備しています
3回送信したら、3回取り出せるか?

同じ番号の通常シグナルは1つの pending 状態にマージされ、同じ番号のリアルタイムシグナルは個別にキューイングされ、順序通りに消費される。キューリソースは限られており、送信に失敗した値はキューに入らない。

実験:1回の保留と3つのキューイング値

以下では、シングルスレッドプロセス内でシグナルをブロックし、5回の標準シグナルのマージと3つの同じ番号のリアルタイムシグナルの順序付き取出しをそれぞれ検証する。非同期処理関数はインストールせず、他のプロセスにはシグナルを送信しない。pending.c として保存し、cc -std=c11 -O2 -Wall -Wextra -Werror pending.c -o pending でコンパイルし、NDEBUG を定義しないこと。

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

int main(void) {
    const int rt = SIGRTMIN;
    assert(rt <= SIGRTMAX);
    sigset_t all, old, standard, realtime;
    sigemptyset(&all);
    sigaddset(&all, SIGUSR1);
    sigaddset(&all, rt);
    assert(sigprocmask(SIG_BLOCK, &all, &old) == 0);
    struct sigaction sa = { .sa_handler = SIG_DFL };
    sigemptyset(&sa.sa_mask);
    assert(sigaction(SIGUSR1, &sa, NULL) == 0);
    assert(sigaction(rt, &sa, NULL) == 0);
    for (int i = 0; i < 5; ++i)
        assert(kill(getpid(), SIGUSR1) == 0);
    for (int i = 10; i < 13; ++i) {
        union sigval value = { .sival_int = i };
        assert(sigqueue(getpid(), rt, value) == 0);
    }
    sigemptyset(&standard);
    sigaddset(&standard, SIGUSR1);
    sigemptyset(&realtime);
    sigaddset(&realtime, rt);
    struct timespec zero = {0};
    siginfo_t info;
    assert(sigtimedwait(&standard, &info, &zero) == SIGUSR1);
    errno = 0;
    assert(sigtimedwait(&standard, &info, &zero) == -1 && errno == EAGAIN);
    for (int expected = 10; expected < 13; ++expected) {
        assert(sigtimedwait(&realtime, &info, &zero) == rt);
        assert(info.si_code == SI_QUEUE);
        assert(info.si_value.sival_int == expected);
    }
    errno = 0;
    assert(sigtimedwait(&realtime, &info, &zero) == -1 && errno == EAGAIN);
    assert(sigprocmask(SIG_SETMASK, &old, NULL) == 0);
    puts("standard: 1 pending; realtime payloads: 10, 11, 12");
}

タイムアウトゼロは、現在保留中の一致するシグナルのみを取り出すことを意味し、該当するものがない場合は EAGAIN を返す。プログラムは元のブロックマスクを復元する前に、自身が送信したシグナルをすべて消費する。この実験は外部送信者がおらず、キューイングクォータが十分であることを仮定している。本番プログラムでは sigqueue のリソース不足を処理する必要があり、アサーションに依存して復旧戦略を担ってはならない。sigtimedwait(2)

3. ハンドラはどのように入り、どのように中断された位置に戻るのか

ユーザー処理関数を呼び出す必要がある配信の場合、カーネルは適切なユーザーモードの戻り境界でシグナルフレームを準備する。復元に必要なユーザーレジスタ、ブロックマスク、および関連コンテキストを保存し、通常ユーザースタックまたは条件を満たす代替シグナルスタックを選択し、ユーザー実行エントリを処理関数に変更する。それは受信スレッドのユーザーモードコンテキストで実行され、カーネルの割り込み処理関数でも、カーネルが一時的に作成した新しいスレッドでもない。

処理関数が返した後、適用されるユーザーモード戻りジャンプ経由で rt_sigreturn に入り、カーネルは検証して対応する状態を復元し、中断された実行を続ける。フレームレイアウトと拡張レジスタの詳細はアーキテクチャおよび ABI に依存し、固定サイズの C 構造体を仮定してすべてのアーキテクチャを適当に解釈することはできない。デフォルトの終了、停止、または無視アクションは、この「ユーザー処理関数を呼び出す」パスに沿って実行される必要もない。

メインプログラムの実行位置を選択し、そこでシグナルを配信する。TID は変更されず、PC、一時的に保存されたフレーム、およびマスクからこのエントリと復帰を観察する。

図を準備しています
処理関数は誰の実行スレッドを借用するか?

処理関数は受信スレッドのユーザーモードコンテキストで実行される。シグナルフレームは中断時の状態を保存し、rt_sigreturn は PC とマスクを復元し、処理関数によるフラグの変更は保持される。

なぜ処理関数は通常通りログを出力できないのか

元のスレッドが stdio を実行中で、内部状態がまだ更新されていない状態で、シグナル処理関数が挿入して実行されるとする。ハンドラがさらに printf を呼び出すと、未完成の状態に対して再帰呼び出しが発生する可能性がある。また、元のスレッドがすでに保持しているロックを取得しようとする場合、自分自身でロックを解放するまで待機する可能性がある。

これが非同期シグナル安全制限の由来である。処理関数は許可された操作のみを行うべきであり、例えば volatile sig_atomic_t フラグを設定するか、ノンブロッキング/容量ポリシーを持つ通知チャネルに書き込み、その後、通常の処理フローに任せるべきである。signal-safety(7)

複雑な処理を通常のスレッドに戻す

sig_atomic_t は規定範囲内のシグナル処理通信に適しているが、マルチスレッドデータ公開に必要な release/acquire プロトコルを自動的に提供するものではない。もし終了、リロードなどの非同期リクエストを一元処理することが本来のニーズであれば、sigwaitinfo() または Linux の signalfd() を使用し、処理を通常のスレッド実行フローに配置できる。マルチスレッド環境では、関連スレッドが一貫してこれらのシグナルをブロックし、他のスレッドによって先に配信されないようにする必要がある。不正メモリアクセスなどの同期例外によって発生する SIGSEGV は、この一元消費モデルを機械的に適用してプログラムを復旧させることはできない。

4. EINTR、再起動と「チェックしてからスリープ」の競合状態

シグナルによって中断された待機後、まずインターフェースが実際に何を返したかを判断する。

結果後続処理の根拠
EINTR を返すインターフェースのセマンティクスに従って、再試行、キャンセル、または上位層への返却を決定
read/write が正の短いカウントを返す完了したバイト数を記録し、残りの部分を処理
インターフェースが規則に従って自動的に再起動呼び出しは待機を続ける可能性がある。SA_RESTART の範囲は依然としてインターフェースに依存

短い返却は部分的な作業が完了している可能性があるため、「何もしていない」と一律に再提出してはならない。

タイムアウト再試行:元の期限切れ時刻を保持する

SA_RESTART はグローバルな「EINTR にならない」スイッチではない。例えば nanosleep や複数の待機インターフェースは独自の中断ルールを持っている。タイムアウト付きループでは、元の期限切れ時刻を保持する必要がある。各中断で完全な 5 秒を再待機すると、頻繁なシグナルにより総待機時間が 5 秒を大幅に超える可能性がある。単調クロックを使用して残り予算を計算するか、絶対期限切れをサポートするインターフェースを使用して、エンドツーエンドのタイムリミットを維持する必要がある。

待機競合:フラグはすでに真だが、pause は始まったばかり

この順序を考える。メインプログラムがフラグが偽であることを確認。その後、シグナル処理関数がそれを真に設定。最後にメインプログラムが pause() を呼び出す。通知はすでに過ぎ去っており、メインプログラムは次のシグナルを待機することになる。

通常、まず対象シグナルをブロックし、フラグをチェックしてから、sigsuspend() でブロックマスクを原子的に置き換えて待機する。復帰後、条件を再チェックする。これは条件待避が保護するのと同じウィンドウを保護する。条件のチェックからスリープへの移行まで。signal(7)

5. ptrace:まず停止理由を識別し、再開方法を決定する

Linux における ptrace の追跡対象はスレッドである。1つの TID に対して追跡関係を作成しても、他のスレッドが同時に停止するわけではない。デバッガがプロセス全体の一貫したビューを構築するには、スレッドの発見、作成イベント、および各スレッドの停止状態の管理も必要である。

PTRACE_ATTACH は関係を作成し停止リクエストを送信するが、呼び出しの戻りがターゲットのチェック可能な停止状態に入ったことを意味するわけではない。トレース元は対応する状態を待機すべきである。PTRACE_SEIZE はそれ自体ではターゲットを停止せず、その後 PTRACE_INTERRUPT を使用して停止を要求できる。追跡ループは「wait が返ったらレジスタを読む」と単純に書くことはできない。wait は最終的な終了やその他のイベントも報告する可能性があるからである。

ptrace はトレース元と追跡対象スレッド間の停止プロトコルである 追跡関係の確立 ATTACH は停止を要求する。SEIZE はそれ自体ではターゲットを停止しない 1 待機し、停止を分類 wait 状態を使用して、シグナル配信、システムコール、ptrace イベントを区別 2 アクセスが許可された状態のチェックまたは変更 レジスタ、メモリ、イベント情報。他のスレッドがまだ実行中である可能性も考慮 3 再開アクションの選択 CONT / SYSCALL / SINGLESTEP、および実際のシグナルを注入するかどうか 4 次のイベントまたは最終的な終了を処理 ループでプロトコルを駆動。終了状態は通常の停止として扱わない 5 停止理由が再開方法を決定する。すべての SIGTRAP をターゲットに転送すると、プログラム動作が変更される。

以下の表に基づいて基本的な分類を作成し、オプション、スレッドグループ、およびカーネルバージョンの違いを処理できる。

観察された状態意味と後続アクション
正常終了またはシグナルによる終了最終状態を消費し、追跡記録をクリーンアップ。再開リクエストを送信しない
通常シグナル配信停止siginfo をチェックし、抑制するか、再開時にそのシグナルを注入するかを選択
システムコール停止TRACESYSGOOD を有効にすると、通常 SIGTRAP | 0x80 で識別される。これは分類マーカーであり、実際に転送されるシグナルではない
ptrace イベント停止wait 状態内のイベントをデコード。例えば clone、exec、exit。イベント停止と最終終了は依然として異なる
ジョブコントロールグループ停止シグナル配信停止と区別する必要がある。SEIZE モードでは LISTEN などのプロトコルと組み合わせて処理できる

以下では、独立した停止スナップショットを使用して再開決定を比較する。実際の SIGUSR1 配信停止のみが転送オプションを提供し、ターゲットスレッドの処理結果は再開後に保持される。

図を準備しています
SIGTRAP が見えたら、SIGTRAP を転送すべきか?

ptrace 再開前に、実際のシグナル配信、デバッグイベント、および最終終了を区別する必要がある。実際のシグナルは再開時に転送または抑制できる。デバッグマーカーは、転送すべき通常シグナルではない。

再開決定:システムコール停止とシグナル配信は異なる

PTRACE_SYSCALL は、ターゲットを後続の適用可能なシステムコール停止まで継続させる。単純にブール値を維持し、各停止で「エントリ/出口」を交互に認識していると、シグナル、exec、seccomp イベント、またはその他の停止に遭遇すると、ずれる可能性がある。サポートされている場合、PTRACE_GET_SYSCALL_INFO を使用してエントリ、出口、または seccomp 情報を取得できる。それでも、まず停止タイプを識別する必要があり、すべての wait 結果に対してシステムコール番号を一律に読み取ってはならない。

よくある間違いは、すべての SIGTRAP を再開時にそのまま注入することである。デバッガが自身で要求した単步やシステムコールイベントは、すべてがターゲットが本来 SIGTRAP を受信すべきであることを意味しない。誤った注入により、本来正常なプログラムがデフォルトの終了アクションを実行する可能性がある。逆に、実際のビジネスシグナルをすべて抑制すると、ターゲットの動作が変更される。トレース元の再開決定はプログラムセマンティクスの一部である。ptrace(2)

6. メモリ読み取りと seccomp:観測可能だからといって一貫性や安全性があるわけではない

PTRACE_PEEKDATA は合法な値 -1 を読み取る可能性がある。失敗を判断する際は、まず errno をゼロにクリアし、戻り値と errno の両方を組み合わせて判断すべきであり、-1 が見えたら即座に無効なアドレスと認定してはならない。/proc/PID/mem または process_vm_readv() を使用しても、アクセス制御を回避することはできない。資格情報、dumpable、ユーザーネームスペース、Yama、または他の LSM ポリシーなどは、許可される操作に影響を与える。

一貫性:1つのスレッドを停止させても、他の書き込み者はまだ動作中

権限が通過しても、一貫したスナップショットが読めるわけではない。トレース元がスレッド A だけを停止させ、スレッド B がまだリンクリストを修正していると仮定する。まず head を読み、次に next を読むと、2回の構造変更を跨ぐ可能性がある。すべての通常スレッドを停止させたとしても、共有メモリを持つ他のプロセスやデバイスがデータを変更する可能性がある。分析ツールは、尽力したサンプリングが必要なのか、それともプログラムによる協調で一貫したスナップショットを提供する必要があるのかを明確にするべきである。

インターフェース境界:TRACE と USER_NOTIF

seccomp の SECCOMP_RET_TRACE は、対応する ptrace オプションの下で seccomp 追跡イベントを発生させることができる。SECCOMP_RET_USER_NOTIF は、専用のリスナー fd を介してユーザーモードの監視プログラムに渡される。⁠これは ptrace インターフェースではない。

これらは同じ複雑なシステム内に存在できるが、通知の受信、応答、およびタスクライフサイクルの処理にはそれぞれプロトコルが異なる。seccomp フィルタ

ユーザーモードの監視者がシステムコール引数内のポインタを読み取り、元のシステムコールを継続させると、典型的な TOCTOU(Time-of-check to Time-of-use)に直面する。チェックの後、他のスレッドがポインタが指すメモリを変更する可能性がある。「パス文字列をチェックしただけ」では、ターゲットが最終的に同じオブジェクトを操作することを保証できない。

ptrace 自体も信頼できるサンドボックスの境界ではない。観察、代行実行、オブジェクト参照、およびアクセス制御の責任を分離して設計すべきであり、「システムコールをインターセプトできる」ことから「任意のシステムコールを安全に認可できる」と直接推論してはならない。seccomp ユーザー通知

7. クイックトラブルシューティング時に問うべき次の層

現場の現象次のステップのチェック
シグナルに反応がない送信結果、保留状態、ターゲットスレッドのブロックマスク、処理方法、および実行機会
子プロセスが継続して Z 状態を表示するSIGCHLD 後、回収可能な状態をすべて循環して消費したか
デバッガの再開後に異常終了する停止分類、およびデバッグイベントを誤って SIGTRAP として注入していないか

これらの入り口は「何が起きたか」を具体的な状態に結びつける。その後、ソースコードをたどって状態変更の位置を探す。

カーネル実装の読解からは、kernel/signal.c の発生/配信パス、kernel/ptrace.c の関係と権限管理、そしてアーキテクチャ固有のシグナルフレームと ptrace レジスタ操作へと進む。内部シグナル集合とユーザーモードの sigset_t のレイアウトは同じ ABI ではなく、メカニズムを理解するために、バージョンごとに古くなりやすい構造定義を複製する必要もない。

プロセスとスレッドのリソース関係はライフサイクルに戻り、シグナルによるウェイクアップ後の CPU 競合はスケジューラに戻る。