Linux pidfds: Race-Free Process Handles Beyond Numeric PIDs
How Linux PID file descriptors provide stable process identity for signaling, polling, waiting, and descriptor duplication without PID reuse races.
A numeric PID is a lookup key, not a permanent identity. After a process exits and is reaped, the kernel can reuse that number. Software that reads a PID, performs unrelated work, and later sends a signal may accidentally target a different process. Linux PID file descriptors, usually called pidfds, let a program retain a file-descriptor reference to the intended task.
The design brings process lifecycle operations into the same descriptor model used for files, sockets, and event sources. A pidfd can be polled, passed to selected system calls, and closed when the reference is no longer needed.
Acquire the reference without opening a race
pidfd_open(pid, flags) obtains a pidfd for an existing process. It is useful when the caller already learned the PID through a trusted channel and can tolerate the small acquisition window. For a child process that the caller creates, clone() or clone3() with CLONE_PIDFD is stronger because the kernel returns the process and pidfd together.
int pidfd = pidfd_open(pid, 0);
if (pidfd == -1) {
/* ESRCH means the target no longer exists. */
}
Opening /proc/PID can also produce a descriptor usable by some pidfd operations on newer kernels, but pidfd_open() and CLONE_PIDFD state the intent directly and have documented lifecycle semantics.
The pidfd remains tied to the original task even if the numeric PID is later reused. It does not keep a dead process running, and it does not grant authority that the caller otherwise lacks.
Signal the referenced task
pidfd_send_signal() sends a signal through the descriptor rather than resolving a numeric PID at call time.
if (pidfd_send_signal(pidfd, SIGTERM, NULL, 0) == -1) {
/* Handle permission, lifecycle, and argument errors separately. */
}
Normal permission checks still apply. A pidfd is stable identity, not a capability that bypasses credentials, namespaces, or Linux Security Modules. This distinction matters when a supervisor passes descriptors between processes: the receiver may possess the reference but still be unable to signal the target.
Group semantics also remain separate. A pidfd identifies one task, while a negative argument to kill() can address a process group. Supervisors that need group-wide shutdown should deliberately manage a cgroup or process group instead of assuming one pidfd represents an entire service tree.
Poll for exit with the normal event loop
A pidfd becomes readable when the referenced task exits and becomes a zombie. It can be registered with poll, select, or epoll, allowing a service manager to monitor processes and sockets in one event loop.
struct pollfd item = { .fd = pidfd, .events = POLLIN };
int ready = poll(&item, 1, timeout_ms);
Readability is a lifecycle notification; applications do not read an exit-status structure from the descriptor. A parent can use waitid() with P_PIDFD to collect status for an eligible child. Once the process has been reaped, polling also reports a hangup condition.
This avoids signal-handler bookkeeping and PID tables, but it does not eliminate child-reaping responsibilities. A parent that never waits still creates zombies. Design the ownership of exit status explicitly when pidfds are shared across components.
Duplicate a target descriptor carefully
pidfd_getfd() can duplicate a file descriptor from the target process into the caller. The operation resembles obtaining a duplicate through /proc/PID/fd/N, but uses stable process identity and applies a ptrace-style access check.
This is valuable for debugging, service handoff, and recovery tooling. It is also powerful: copying a socket or file descriptor transfers access to the underlying open file description. The duplicated descriptor shares state such as file offset and status flags where normal dup semantics do. Never expose pidfd_getfd() through an unreviewed control socket.
Model compatibility and failure explicitly
Pidfd support arrived incrementally. pidfd_send_signal() appeared in Linux 5.1, pidfd_open() in 5.3, pidfd_getfd() in 5.6, and APIs have continued to grow. Compile-time headers and runtime kernels can therefore disagree.
Probe the actual syscall result and distinguish ENOSYS from ESRCH, EPERM, and ordinary resource exhaustion. A fallback to numeric PIDs must reintroduce race defenses, such as verifying process start metadata immediately before an operation, and should be treated as a weaker mode rather than equivalent protection.
Containers add another namespace dimension. The numeric PID visible to one namespace may differ elsewhere, while the pidfd still references the kernel task obtained by the caller. Pass pidfds over Unix domain sockets when identity must cross a cooperating process boundary; do not serialize the descriptor number into a file and expect another process to inherit its meaning.
Distinguish processes, threads, and waiting behavior
Modern kernels add flags that make the reference more precise. PIDFD_NONBLOCK, available since Linux 5.10, makes waitid(P_PIDFD, ...) return EAGAIN while the task is still running instead of blocking. Since Linux 6.9, PIDFD_THREAD can refer to one specific thread rather than the thread-group leader. Without that flag, poll readability corresponds to the last thread in the group exiting; with it, the referenced thread can become readable while sibling threads remain alive.
That distinction affects supervisors and debuggers. A service supervisor usually wants a process-wide pidfd acquired at creation. A thread diagnostic tool may deliberately need PIDFD_THREAD, but it must not interpret one thread’s exit as termination of the whole service. Feature-test the flag at build time and the kernel behavior at runtime because recent headers can run on an older kernel.
Pidfds are also ordinary descriptor resources. Set or confirm close-on-exec behavior, close every reference when ownership ends, account for RLIMIT_NOFILE, and use SCM_RIGHTS when transferring one over a Unix socket. The integer value is local to each descriptor table; logging pidfd=7 helps debug one process but is not a durable identifier.
When a parent needs exit status, keep one clear reaping owner. Poll can notify several observers, but only the eligible parent can reap with waitid, and a concurrent signal handler or waiter can consume the status first. Put notification, status collection, and descriptor closure in one lifecycle design rather than layering pidfds over an existing race between waiters.
Pidfds do not replace process groups, cgroups, credentials, or service state. They solve one foundational problem well: retaining a stable reference after the moment a process was selected.
Related:
- fs-verity on Linux: Per-File Integrity with Merkle Trees
- Reading /proc and /sys: The Kernel’s Window into Userspace
Sources: