PIDではなくファイルディスクリプタでプロセスをつかむ: LinuxのpidfdとPID再利用の問題

kill(PID)はPIDが再利用されると無関係なプロセスにシグナルを送りかねません。pidfd_open、pollによる終了検知、pidfd_send_signal、waitid(P_PIDFD)が何を保証し何を保証しないかを、manページをもとに整理します。

linuxpidfdprocesssignal

監視プロセスがワーカーのPIDを覚えておき、しばらくして kill(pid, SIGTERM) を送ります。その間にワーカーがすでに終了し、誰かがそれを回収し、カーネルが同じ番号を新しいプロセスに割り当てていたら、シグナルはまったく関係のないプロセスに届きます。PIDが指すのは「いまその番号を使っているプロセス」であって、最初に意図したプロセスではないからです。

LinuxのPIDファイルディスクリプタ(pidfd)は、この問題への答えです。この記事では pidfd_open(2)、pidfd_send_signal(2)、waitid(2) のmanページをもとに、pidfdが固定してくれるものと、依然として解決しないものを分けて整理します。

問題: PIDは再利用される番号

pidfd_send_signal(2) のNOTESは、問題をこう説明しています。kill(2) のような従来のインターフェースは対象をPIDで指定します。そのため、本来の対象が終了してそのPIDが別のプロセスに再利用されていると、送信側が誤って違うプロセスにシグナルを送ってしまうことがあります。これに対してPIDファイルディスクリプタは特定のプロセスへの安定した参照であり、そのプロセスが終了すると pidfd_send_signal() は ESRCH で失敗します。

先に一つ押さえておきます。自分の子プロセスであれば、PIDが再利用されうるタイミングは決まっています。waitid(2) によると、終了したがまだwaitされていない子はゾンビとして残り、カーネルは親が後でwaitできるよう、PIDや終了ステータスなど最小限の情報を保持します。ゾンビがwaitで取り除かれるまでそのPIDは他に渡らないので、まだ回収していない自分の子に kill() を送ること自体は危険ではありません。

危険になるのは、たとえば次のような場合です。

  • 別のスレッドや非同期のシグナルハンドラが先に子を回収しうるプログラム
  • SIGCHLD を明示的に SIG_IGN にしたり SA_NOCLDWAIT を使ったりして、子がゾンビとして残らないプログラム
  • 自分の子ではないプロセス。いつ回収されるかをこちらは制御できません。

pidfd_open: PIDからファイルディスクリプタを得る

#include <sys/syscall.h>   /* SYS_* 定数 */
#include <unistd.h>

int syscall(SYS_pidfd_open, pid_t pid, unsigned int flags);

pidfd_open() は pid が指すタスクを参照するファイルディスクリプタを作って返します。返されるディスクリプタにはclose-on-execフラグが設定されています。この版のmanページ(man-pages 6.19)は、glibcがこの呼び出しのラッパーを提供しないため syscall(2) で呼ぶ必要があると書いています。Linux 5.3で導入され、規格は「Linux」のみです。移植性のあるAPIではありません。

flags は0か、次のフラグを含みます。

  • PIDFD_NONBLOCK(Linux 5.10以降): ノンブロッキングのディスクリプタを返します。参照先のタスクがまだ終了していなければ、このディスクリプタに対する waitid(2) はブロックせず、すぐに EAGAIN エラーを返します。
  • PIDFD_THREAD(Linux 6.9以降): プロセス(スレッドグループリーダー)ではなく特定のスレッドを指すディスクリプタを作ります。このフラグがなければ pid はプロセスでなければなりません。

エラーとしては、不正な flags や pid(EINVAL)、ファイルディスクリプタの上限(EMFILE、ENFILE)、匿名inodeファイルシステムのないカーネル(ENODEV)、メモリ不足(ENOMEM)、そしてそのPIDのプロセスが存在しない(ESRCH)が記載されています。

競合が消えるのはディスクリプタを得てから

pidfd_open() も結局はPIDを受け取ります。つまり呼び出した瞬間にその番号を使っているタスクを指すことになります。覚えておいたPIDがすでに別のプロセスに再利用されていれば、pidfd_open() は成功し、無関係なプロセスのpidfdを返します。pidfdが保証するのは「得た時点から同じプロセスを指し続ける」ことであり、手元のPIDがまだ意図したプロセスかどうかを検証してくれるわけではありません。

pidfd_open(2) のNOTESは、この区間が安全な場合を一つ明記しています。fork(2) の直後に親が子のPIDで pidfd_open() を呼ぶ場合です。子がその時点ですでに終了していてもPIDは再利用されておらず、返されるディスクリプタはそのゾンビプロセスを指します。ただし、次の条件がすべて成り立つときに限ります。

  • SIGCHLD の処理方法を明示的に SIG_IGN にしていない。
  • SIGCHLD のハンドラを設定するとき、または処理方法を SIG_DFL にするときに SA_NOCLDWAIT フラグを指定していない。
  • そのゾンビをプログラムの他の場所(非同期に実行されるシグナルハンドラ、別スレッドの wait(2) など)で回収していない。

一つでも成り立たない場合、manページは clone(2) に CLONE_PIDFD フラグを付けて子とそのpidfdを同時に作るよう案内しています。そうすればPIDを経由する区間そのものがなくなります。

自分の子ではないプロセスには、こうした保証はありません。別の経路(PIDファイルなど)で得たPIDを pidfd_open() に渡す時点の競合はそのまま残ります。

終了の検知: poll、select、epoll

pidfdは poll(2)、select(2)、epoll(7) で監視できます。pidfd_open(2) によると、参照先のタスクが終了してゾンビになるとディスクリプタは読み取り可能(EPOLLIN)として通知され、タスクが回収されるとハングアップイベント(EPOLLHUP)が発生します。

細かい点がいくつかあります。

  • 「読み取り可能」でも、実際に読めるものはありません。現在の実装では、このディスクリプタへの read(2) は EINVAL で失敗します。終了ステータスを得るには後述の waitid() を使います。
  • PIDFD_THREAD なしで得たディスクリプタは、スレッドグループの最後のスレッドが終了したときにだけ読み取り可能になります。
  • PIDFD_THREAD で得たディスクリプタは、スレッドグループが空でなくても、そのスレッドが終了してゾンビになれば読み取り可能になります。

この監視は自分の子でないプロセスにも使えます。pidfd_open(2) のサンプルプログラムも、コマンドラインで受け取った任意のPIDに対して pidfd_open() を呼び、poll() で POLLIN を待っています。SIGCHLD ハンドラなしで、イベントループの中で「そのプロセスが終わった」ことを受け取れるのが実用上の利点です。

終了ステータスを得る: waitid(P_PIDFD)

#include <sys/wait.h>

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

waitid(2) はLinux 5.4から idtype に P_PIDFD を受け付け、id に渡したPIDファイルディスクリプタが指す子を待ちます。重要なのは 子 という言葉です。pidfd_open(2) も、pidfdが呼び出し元プロセスの子を指している場合に waitid(2) で待てると書いています。waitid(2) の ECHILD は、指定したプロセスが存在しないか呼び出し元の子でない場合に発生し、自分の子でも SIGCHLD が SIG_IGN なら起こりえます。

まとめるとこうなります。

対象 pollによる終了検知 waitid(P_PIDFD)
呼び出し元の子 できる できる。終了ステータスを得てゾンビを回収
子ではないプロセス できる ECHILD

子ではないプロセスの終了ステータスをpidfdで得る方法は、今回確認した三つのmanページには出てきません。終わったという事実までしか分からないと考えるのが安全です。

options には待つ状態変化として WEXITED(終了)、WSTOPPED(停止)、WCONTINUED(再開)のうち一つ以上を指定し、必要なら次を加えます。

  • WNOHANG: 待機可能な子がなければブロックしません。
  • WNOWAIT: 子を待機可能な状態のまま残します。後で再びwaitして同じ情報を得られます。

成功すると infop の si_pid、si_uid、si_signo(常に SIGCHLD)、si_status、si_code が埋められます。si_code が CLD_EXITED なら si_status は終了ステータス、CLD_KILLED や CLD_DUMPED なら子を終わらせたシグナル番号です。POSIX.1-2008では infop は必ず siginfo_t を指す必要があります。Linuxでは NULL でも成功しますが、manページはこの非標準の動作に頼らないよう書いています。

なお、このシグネチャはglibcとPOSIXのインターフェースで、生のシステムコールは5番目の引数として struct rusage * を取ります。waitした子のCPU時間がいつ親側の集計に加わるかは real・user・sysの記事 で扱いました。

PIDFD_NONBLOCKとWNOHANGは違う

どちらも「待たない」ように見えますが、結果の返し方が違います。

かかる場所 まだ終了していないとき
PIDFD_NONBLOCK ファイルディスクリプタ(pidfd_open() の flags) waitid() が-1を返し、errno は EAGAIN
WNOHANG その1回の waitid() 呼び出し(options) waitid() が0を返す

WNOHANG の場合、0は成功時にも使われる値です。待機可能な子がなくて0が返ったとき、siginfo_t の内容は実装によって異なります。waitid(2) は、この場合を移植性をもって区別するには、呼び出し前に si_pid を0にしておき、呼び出し後に0以外かを確認するよう案内しています。LinuxはPOSIX.1-2008 TC1の要件どおり si_pid と si_signo を0にしますが、すべての実装がそうではありません。

PIDFD_NONBLOCK はディスクリプタ側の設定なので、そのディスクリプタに対するすべての waitid() に効き、失敗として知らせるので errno を確認する必要があります。文書上の条件の書き方も違います。EAGAIN は「そのプロセスがまだ終了していない」とき、WNOHANG の0は「待機可能な状態の子がない」ときです。

pidfd_send_signal: シグナルを送る

#include <linux/signal.h>  /* SIG* 定数 */
#include <signal.h>        /* SI_* 定数 */
#include <sys/syscall.h>   /* SYS_* 定数 */
#include <unistd.h>

int syscall(SYS_pidfd_send_signal, int pidfd, int sig,
            siginfo_t *_Nullable info, unsigned int flags);

pidfd が指すプロセスに sig を送ります。Linux 5.1で導入され、manページによればこちらもglibcのラッパーはありません。info が NULL なら、kill(2) で送るときに暗黙に与えられる値(si_code は SI_USER、si_pid は呼び出し元のPID、si_uid は呼び出し元の実UID)が入った siginfo_t を渡したのと同じになります。info を自分で埋める場合は rt_sigqueueinfo(2) の規則に従います。

flags がデフォルトの0なら、範囲は pidfd から推定されます。PIDFD_THREAD で得たpidfdならそのスレッドに送られます。Linux 6.9からは PIDFD_SIGNAL_THREAD、PIDFD_SIGNAL_THREAD_GROUP、PIDFD_SIGNAL_PROCESS_GROUP で範囲を明示できます。

ESRCH については、manページの二か所で表現が少し異なります。NOTESは「そのプロセスが終了すると ESRCH で失敗する」と書き、ERRORSは ESRCH を「対象プロセスが存在しない(つまり終了してwaitされた)」と説明しています。どちらにしても要点は同じで、元のプロセスが消えた後に、同じPIDを得た別のプロセスへシグナルが移ることはありません。ただ、まだ回収されていないゾンビに送ったとき正確に何が返るかは、この文書だけで断定しないほうがよいでしょう。

pidfdは pidfd_open() のほか、/proc/<pid> ディレクトリを開くか、clone(2)/clone3(2) に CLONE_PIDFD を指定することでも得られます。ただしpidfd_open(2) によると、/proc/<pid> から得たディスクリプタはprocファイルシステムがマウントされている必要があり、pollできず、waitid(2) で待つこともできません。シグナルの送信には使えます。

pidfdが解決しないもの

pidfdが扱うのは「どのプロセスか」という参照の問題です。次のものはそのまま残ります。

  • 権限。 pidfdを持っていてもシグナルを送る権限は生まれません。呼び出し元に対象へシグナルを送る権限がなければ pidfd_send_signal() は EPERM で失敗します。対象が自分自身でなく info.si_code が不正な場合も EPERM です。
  • PID名前空間。 呼び出し元は対象と同じPID名前空間か、その祖先の名前空間にいなければなりません。そうでなければ EINVAL です。pidfdが名前空間の境界を越えてプロセスを見えるようにするわけではありません。
  • PIDを手にするまでの競合。 前述のとおり、古いPIDで pidfd_open() を呼ぶ瞬間の競合は残ります。子であれば fork() の直後(条件を満たす場合)か、最初から CLONE_PIDFD でpidfdを得てください。
  • 子でないプロセスの終了ステータス。 終了したことは分かりますが、waitid(P_PIDFD) は ECHILD です。
  • その他すべてのTOCTOU。 pidfdが固定するのはプロセスへの参照一つだけです。そのプロセスが何をしているか、確認した後に状態が変わったかといった、他のチェックと使用の間の競合までなくしてくれるわけではありません。
  • 移植性。 三つの呼び出しとも規格は「Linux」のみです(waitid() 自体はPOSIXですが、P_PIDFD はLinuxの拡張です)。

このほかpidfdは、pidfd_getfd(2) で相手プロセスのファイルディスクリプタを複製したり、setns(2) で対象と同じ名前空間に入ったり、process_madvise(2) でメモリの使い方を伝えたりするのにも使われます。この記事では扱いません。

例: 子を監視し、時間が過ぎたら終了させる

子を一つ作り、最大2秒間終了を待ち、まだ生きていればそのpidfdで SIGTERM を送り、最後に waitid(P_PIDFD) で終了ステータスを得ます。シグナルは自分が作った子にしか届きません。

#define _GNU_SOURCE
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

static int pidfd_open(pid_t pid, unsigned int flags)
{
    return syscall(SYS_pidfd_open, pid, flags);
}

static int pidfd_send_signal(int pidfd, int sig, siginfo_t *info,
                             unsigned int flags)
{
    return syscall(SYS_pidfd_send_signal, pidfd, sig, info, flags);
}

int main(void)
{
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        sleep(30);              /* 時間のかかる処理の代わり */
        _exit(0);
    }

    /* SIGCHLDはデフォルトのまま、まだ誰もwaitしていない: PIDは再利用されていない */
    int pidfd = pidfd_open(pid, 0);
    if (pidfd == -1) {
        perror("pidfd_open");
        return 1;
    }

    struct pollfd pfd = { .fd = pidfd, .events = POLLIN };
    int ready = poll(&pfd, 1, 2000);
    if (ready == -1) {
        perror("poll");
        return 1;
    }
    if (ready == 0 && pidfd_send_signal(pidfd, SIGTERM, NULL, 0) == -1)
        perror("pidfd_send_signal");

    siginfo_t info;
    memset(&info, 0, sizeof(info));
    if (waitid(P_PIDFD, (id_t) pidfd, &info, WEXITED) == -1) {
        perror("waitid");
        return 1;
    }

    if (info.si_code == CLD_EXITED)
        printf("exited, status=%d\n", info.si_status);
    else if (info.si_code == CLD_KILLED || info.si_code == CLD_DUMPED)
        printf("killed by signal %d\n", info.si_status);

    close(pidfd);
    return 0;
}

いくつか補足です。

  • カーネル5.4以降と、SYS_pidfd_open、SYS_pidfd_send_signal、P_PIDFD を定義するヘッダーが必要です。ヘッダーにない場合は、番号を直接書き込むよりヘッダーを更新するほうが安全です。この記事はLinux以外の環境で書いたため、このコードを実際にビルド・実行してはいません。
  • poll() が0を返した直後に子が自分で終了することもありえます。それでもまだwaitしていないので、シグナルが別のプロセスに届くことはありません。この場合に pidfd_send_signal() が成功するか ESRCH になるかは前述のとおり断定しておらず、例では失敗してもメッセージを出すだけで先に進みます。
  • PIDFD_NONBLOCK は使っていません。poll() で終了を確かめてからブロッキングの waitid() を呼ぶ構造なので不要です。イベントループでブロックせずに確認したい場合は、PIDFD_NONBLOCK(EAGAIN)か WNOHANG(0を返し si_pid を確認)のどちらかを選び、前節の違いに合わせて結果を処理してください。
  • 変わりうる名前(PID)の代わりに開いたディスクリプタで対象を指すという発想は、削除したファイルのディスク容量の記事で見たファイル名とディスクリプタの関係に似ています。ただしpidfdはプロセスの終了や回収を妨げません。回収されれば EPOLLHUP が発生するだけです。使い終わったpidfdは閉じてください。

確認した範囲と限界

  • man7.orgで公開されているLinux man-pages 6.19(ページの日付は2026-02-08)のHTMLを2026-10-27に確認しました。ディストリビューションに入っているmanページ、glibcやカーネルのバージョンによって内容が異なる場合があります。特に「glibcのラッパーがない」という説明はこの版のmanページに基づきます。
  • 文書に書かれたバージョン: pidfd_send_signal はLinux 5.1、pidfd_open はLinux 5.3、waitid の P_PIDFD はLinux 5.4、PIDFD_NONBLOCK はLinux 5.10、PIDFD_THREAD と PIDFD_SIGNAL_* はLinux 6.9です。
  • clone(2)/clone3(2) の CLONE_PIDFD、pidfd_getfd(2)、setns(2)、process_madvise(2) は今回それぞれのmanページを確認していないため、名前の紹介にとどめました。
  • 性能の数値や実測の出力は示していません。

出典

2026-10-27に確認しました。

ブログへ