pidfd: process file descriptors and the PID recycling race
Published by RodHat

Every time you call kill(pid, SIGTERM) you are making a bet. You got the PID from somewhere, you verified it was the process you wanted, and then you sent the signal. Between the verification and the signal, the process could have died and the kernel could have handed that PID to a completely unrelated process. You just sent SIGTERM to someone else’s nginx.
This is not theoretical. High-volume process supervisors and container runtimes hit it. PID space on Linux defaults to 32768 slots (adjustable via /proc/sys/kernel/pid_max, up to 4194304 on 64-bit). Under load a PID can be recycled in milliseconds. The probability goes up as the PID space fills and as the time between lookup and signal grows.
The POSIX answer is waitpid() in a parent-child relationship: the kernel holds the zombie until the parent waits, so the PID stays reserved. That only works if you are the direct parent. Process supervisors managing grandchildren or arbitrary pids via ptrace do not have that luxury.
Linux 5.2 shipped CLONE_PIDFD and Linux 5.3 shipped pidfd_open(2). Together they give you a file descriptor that refers to a process. The kernel keeps the PID alive in the pidfd’s reference for as long as the fd is open, even after the process exits and becomes a zombie. Any operation through the fd targets exactly that process, no PID recycling race.
Getting a pidfd
Two ways. From a process you spawned:
#define _GNU_SOURCE
#include <sched.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int pidfd;
struct clone_args args = {
.flags = CLONE_PIDFD,
.pidfd = (uint64_t)&pidfd,
};
pid_t child = syscall(SYS_clone3, &args, sizeof(args));
if (child == 0) {
/* child */
sleep(10);
_exit(0);
}
/* parent: pidfd is open, refers unambiguously to child */
printf("pidfd = %d, child pid = %d\n", pidfd, child);
close(pidfd);
waitpid(child, NULL, 0);
return 0;
}
From an existing process you do not own as parent:
#define _GNU_SOURCE
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv) {
pid_t target = atoi(argv[1]);
/* pidfd_open(pid, flags): flags must be 0 currently */
int pidfd = syscall(SYS_pidfd_open, target, 0);
if (pidfd < 0) {
perror("pidfd_open");
return 1;
}
printf("pidfd %d refers to pid %d\n", pidfd, target);
/* use it, then: */
close(pidfd);
return 0;
}
pidfd_open requires that the calling process can send signals to the target (same EUID, or CAP_KILL). It fails with ESRCH if the process does not exist. Once it succeeds, you have a stable reference regardless of what happens to the numeric PID.
A glibc wrapper for pidfd_open landed in glibc 2.36. Before that, call via syscall(SYS_pidfd_open, ...).
Sending signals without the race
/* pidfd_send_signal(pidfd, sig, siginfo, flags) */
#include <signal.h>
int ret = syscall(SYS_pidfd_send_signal, pidfd, SIGTERM, NULL, 0);
if (ret < 0) {
if (errno == ESRCH) {
/* process already exited */
} else {
perror("pidfd_send_signal");
}
}
The third argument is a siginfo_t *. Pass NULL for the default siginfo, or fill one out to send a specific si_code and si_value (same as rt_sigqueueinfo). Fourth argument is flags; currently must be 0.
ESRCH from pidfd_send_signal is unambiguous: the process that this fd referred to is gone. No risk that you are looking at a recycled PID. You can act on that ESRCH with confidence.
Waiting for process exit via poll
A pidfd is pollable. It becomes readable (POLLIN) when the process it refers to exits:
#include <poll.h>
struct pollfd pfd = { .fd = pidfd, .events = POLLIN };
int n = poll(&pfd, 1, -1); /* block until exit */
if (n > 0 && (pfd.revents & POLLIN)) {
/* process has exited; reap it */
siginfo_t info = {};
waitid(P_PIDFD, pidfd, &info, WNOHANG | WEXITED);
printf("exited: si_status=%d\n", info.si_status);
}
waitid(P_PIDFD, ...) is the reap call. Pass the pidfd as the id, P_PIDFD as the idtype. This is available since Linux 5.4. The call populates the siginfo_t with exit status, signal, and timing information.
This makes pidfd trivially usable with epoll in an event loop. A process supervisor can watch dozens of child fds in one epoll instance and get notified when any exits, without polling /proc or catching SIGCHLD and racing to figure out which child it was.
/* add pidfd to epoll set */
struct epoll_event ev = { .events = EPOLLIN, .data.fd = pidfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, pidfd, &ev);
/* in the event loop: */
/* ev.data.fd is the pidfd of the process that exited */
waitid(P_PIDFD, ev.data.fd, &info, WNOHANG | WEXITED);
This is how systemd tracks units since 5.14, and how most modern container runtimes handle the grandchild problem without the PR_SET_CHILD_SUBREAPER workaround.
pidfd_getfd: stealing an fd from another process
Linux 5.6 added pidfd_getfd(2):
/* pidfd_getfd(pidfd, targetfd, flags) */
/* returns a copy of fd number targetfd from the target process */
int stolen_fd = syscall(SYS_pidfd_getfd, pidfd, 3, 0);
This duplicates file descriptor 3 from the target process into the calling process’s fd table. Requires CAP_SYS_PTRACE or that the calling process can ptrace the target (/proc/sys/kernel/yama/ptrace_scope applies).
Legitimate uses: debuggers, process checkpointing (CRIU uses this), container tooling that needs to pick up an fd from a peer process without a Unix socket handoff. The security model is the same as process_vm_readv and ptrace: you are reaching into another process’s address space.
Combining pidfd_getfd with the pidfd of a subprocess gives you fd-passing without setting up a SCM_RIGHTS channel in advance. Spawn the child, wait for it to open the thing you need, then pidfd_getfd it out. Niche, but when you need it there is no cleaner way.
/proc representation
Each process’s pidfds show up under /proc/[pid]/fd/ like any other open file descriptor:
ls -la /proc/$$/fd/
# pidfds appear as:
# lrwxrwxrwx 1 rod rod 64 ... 7 -> anon_inode:[pidfd]
The symlink target is anon_inode:[pidfd]. You can check what process a pidfd refers to via /proc/[pid]/fdinfo/[fd]:
cat /proc/$$/fdinfo/7
# pos: 0
# flags: 02000000
# mnt_id: 9
# ino: 12345
# Pid: 1234
# NSpid: 1234
The Pid: field is the PID in the calling process’s namespace. NSpid: lists the PID at each nesting level of the PID namespace hierarchy, innermost first. This is the right way to figure out what process a pidfd refers to, not parsing ls -l /proc/[pid]/fd/.
Across PID namespaces
pidfd_open on a PID from inside a container’s PID namespace: you must be in the correct namespace or have the view from a parent namespace. The pidfd tracks the process object, not the numeric PID. A process visible as PID 1 inside a container namespace is visible as some other number in the initial namespace. The pidfd is valid from whichever namespace you opened it in; pidfd_send_signal uses the same namespace context. You cannot pidfd_open PID 1 from outside the namespace if PID 1 inside the container is the container’s init. The process must be visible to you.
CLONE_NEWPID + CLONE_PIDFD work correctly together for process supervisors that create their own namespaces: the supervisor holds pidfds into its own namespace view of the child PIDs, and those fds remain stable across namespace lifecycle.
What this replaces
The old patterns pidfd makes obsolete or safer:
kill(pid, 0)to check process existence: replace withpidfd_send_signal(pidfd, 0, NULL, 0), which returnsESRCHwhen gone with no recycling ambiguity.- Polling
/proc/[pid]for existence: replace with poll on the pidfd. - Sending SIGCHLD and then
waitpid(-1, ...)in a handler, then matching pid to internal state: replace withwaitid(P_PIDFD, ...)on the specific pidfd. - Grabbing an fd from another process via
/proc/[pid]/fd/[n]symlink tricks: replace withpidfd_getfd.
The Linux Programming Interface (book) has thorough coverage of the PID lifecycle and signal semantics that the pidfd design is working around. Kerrisk’s description of the PID recycling problem in Chapter 26 is where most people realize how fragile kill(pid, sig) always was. The pidfd API is Linux 5.2 and later, so post-publication, but the problem it solves is documented in detail there.