2026-08-16
Before Linux 5.3, referring to a process meant holding its PID — a small integer the kernel is free to recycle the instant the process is reaped. This creates a race that has crashed production supervisors for decades: you read a PID from a config or a pidfile, decide to send it SIGTERM, and in the microseconds between decision and kill(pid, SIGTERM), the original process exits, gets reaped, and the PID is handed to an unrelated program you just killed by accident.
A pidfd is a file descriptor that refers to a specific process. Because it's an fd, the kernel pins the reference — the PID slot cannot be reused while any pidfd points to that process. You get one three ways:
pidfd_open(pid, 0) — turn an existing PID into a pidfd (race-y at this exact call, but safe forever after).clone3() with CLONE_PIDFD — the kernel hands you the pidfd atomically with process creation. Zero race.pidfd_getfd(pidfd, targetfd, 0) — steal an fd from another process (useful for debuggers and CRIU).What you can do with it:
pidfd_send_signal(pidfd, SIGTERM, NULL, 0) — kill exactly this process, no PID-reuse gap.poll() or epoll() on the pidfd — it becomes readable when the process exits. This is the first sane way to wait on a non-child process. Before pidfd, watching a non-child required polling /proc/[pid] or ptrace tricks.waitid(P_PIDFD, pidfd, &si, WEXITED) — reap by pidfd instead of PID.Real-world example: systemd's cgroup-empty notification used to combine PID scanning with signals, and had a documented race where restarting a service could occasionally SIGKILL a freshly-spawned unrelated process that had inherited the recycled PID. systemd 245 (2020) switched to pidfds, and this class of bug disappeared. Container runtimes (runc, crun) did the same migration for the same reason.
Rule of thumb for the race window: on a busy 64-bit Linux system with pid_max = 4194304 and, say, 50 process creations per second, a specific PID's expected reuse time is pid_max / rate ≈ 23 hours. Sounds huge — but on a short-lived-worker system doing 5000 forks/second, it collapses to ~14 minutes. Any long-lived supervisor holding a stale PID longer than that has already lost.
The one gotcha: pidfds are not inherited across fork() by default the way you might expect for parent-child semantics, and passing one over an AF_UNIX socket with SCM_RIGHTS gives the receiver full signaling power — treat them as capabilities.
kill(pid, ...) fundamentally unsafe for any process you didn't just fork yourself.
