Systems Programming · OS Interfaces

pidfd: A Process Handle That Cannot Be Reused

A process id is recycled the moment a process is reaped, so any operation addressed by pid can hit an unrelated process. A pidfd refers to the process object itself and closes the race.

A pid is an integer in a namespace, and the kernel reuses integers. When a process is reaped, its pid becomes available, and the next fork may return it. Every interface that takes a pid — kill, waitpid, setpriority, sched_setscheduler, ptrace — resolves the number to a process at the moment of the call and has no way to know whether the process it found is the one the caller had in mind. For a supervisor that recorded a pid earlier and acts on it later, the window between the record and the action is a correctness hole that cannot be closed by careful ordering.

pidfd closes it by replacing the number with a file descriptor. The descriptor refers to the process object, holds a reference that keeps the object allocated, and is never retargeted at another process. All the properties follow from that substitution, and so do the limits.

The race the number cannot express

Consider a supervisor that starts a child, records its pid, and later sends it a termination signal. Between the record and the signal, the child exits and is reaped by something else — or, if the supervisor is the parent, by the supervisor itself in a SIGCHLD handler that the main flow did not anticipate. The pid is now free. Another process on the system forks and receives it. The supervisor’s kill then signals an unrelated process, which may be running as a different user, may be a system service, or may be the supervisor’s own sibling.

The same shape appears in several places. A service manager that tracks a unit’s main process by pid and restarts it on failure can signal the wrong process if the failure was noticed late. A test harness that cleans up by killing recorded pids can kill a process it never started. A monitoring service that samples resource usage by pid can attribute one process’s consumption to another.

The window is not theoretical or rare. On a system with a modest pid limit and a high process turnover rate, the pid space wraps quickly. Linux allocates pids sequentially and wraps at /proc/sys/kernel/pid_max, whose documented default is 32768 and which can be raised to 4194304 (2^22) on 64-bit systems; distributions that run many services often raise it. The wrap is not a rare event on a busy machine, and a process that has been reaped is a candidate for reuse immediately.

The conventional mitigations are all partial. One can re-check the pid with kill(pid, 0) immediately before acting, which shrinks the window but does not close it. One can hold the child as an unreaped zombie, which prevents reuse but leaks a process table slot. One can record the start time from /proc/<pid>/stat alongside the pid and compare on each use, which detects reuse after the fact but leaves a window between the comparison and the action. None of these is atomic with the operation itself.

What the descriptor holds

A pidfd is an anonymous file descriptor, created by pidfd_open (Linux 5.3) or received from clone with CLONE_PIDFD (Linux 5.2). It refers to the process, not to its pid, and the kernel holds a reference on the task structure for as long as the descriptor is open. That reference keeps the task structure allocated after the process has exited and been reaped, which is what allows the descriptor to report the exit rather than becoming meaningless.

The descriptor is close-on-exec by default. That is the correct choice for a handle that identifies a specific process: a handle inherited by an unrelated program is a capability the program did not ask for, and the same reasoning that produces O_CLOEXEC on open applies here without modification. The general inheritance rules are covered in fork, exec, and descriptor inheritance.

Being a descriptor, a pidfd participates in the mechanisms that already exist for descriptors. It can be polled for readability, and it becomes readable when the process terminates. It can be placed in an epoll set alongside sockets, so a supervisor has one event loop rather than a signal handler plus a loop. It can be passed to another process over a UNIX socket, giving that process the ability to wait on or signal the process without ever knowing its pid. The transfer mechanics are those of UNIX domain sockets and descriptor passing.

#include <fcntl.h>
#include <poll.h>
#include <sys/syscall.h>
#include <unistd.h>

/* Wait for a child's exit without a signal handler and without a pid race.
   The libc wrapper for pidfd_open is recent; the syscall form works on any
   kernel that implements it. */
int watch_child(pid_t pid) {
    int pfd = (int)syscall(SYS_pidfd_open, pid, 0);
    if (pfd < 0) return -1;

    struct pollfd p = { .fd = pfd, .events = POLLIN };
    if (poll(&p, 1, -1) < 0) { close(pfd); return -1; }

    /* Readable means the process has exited; the status is collected
       separately, with waitid and P_PIDFD. */
    close(pfd);
    return 0;
}

The readability event means the process has terminated, not that it has been reaped. The exit status still has to be collected with waitid using the descriptor, or with waitpid on the pid while the process is still the caller’s child. The distinction matters: a pidfd held by a process that is not the parent becomes readable at termination, but the holder cannot reap, and the zombie remains until the actual parent does.

Signaling without resolving a number

pidfd_send_signal (Linux 5.1) takes a descriptor, a signal number, a siginfo_t pointer, and flags. The kernel resolves the descriptor to the process it refers to and delivers the signal to that process. There is no lookup by number at any point, so there is no window.

The call also accepts a null siginfo_t, in which case it behaves like kill with a default payload. Passing a non-null structure allows a real-time signal to carry a value, which kill cannot do; sigqueue can, but only by pid. The combination of a stable handle and a payload is what makes a supervisor able to notify a specific process with data rather than just a signal number.

The call fails with ESRCH if the process referred to by the descriptor has already terminated and been reaped, which is a meaningful answer rather than a wrong target. A supervisor that receives ESRCH knows the process is gone; a supervisor using kill receives success and has signaled something else.

Signals sent this way are subject to the same permission checks as kill. A process may signal another only if their real or effective user ids match, or if the sender has the appropriate capability. The descriptor does not confer authority the sender did not have; it confers identity.

Waiting and the exit status

waitid accepts P_PIDFD in its id type field, with the descriptor in place of the pid. The call waits for the process the descriptor refers to, and fills a siginfo_t with the status in the same form as for a pid-based wait. The status encoding — CLD_EXITED, CLD_KILLED, CLD_DUMPED and the accompanying si_status — is the same, and the exit code is still truncated to eight bits.

The advantage over waitpid is that the descriptor identifies the child unambiguously even if the pid has been reused. The advantage over a blocking waitpid in a signal handler is that the descriptor can be polled, so the supervisor does not need SIGCHLD at all. A supervisor that uses pidfds for every child can block SIGCHLD entirely and avoid the handler-safety constraints that come with it, which are described in signal handling and async-signal safety.

There is a subtlety about which descriptor to use. pidfd_open on a pid returns a descriptor for that process if it exists and the caller can see it. clone with CLONE_PIDFD returns a descriptor for the newly created child as part of the same call, which removes the window between creation and opening. The second is the correct choice for a supervisor starting processes, because the first has a gap in which the child could exit and the pid be reused before pidfd_open runs — a small window, but the same class of window the interface exists to eliminate.

What the handle does not carry

A pidfd refers to a process, not to a process group, a session, or a tree. Signaling a group still requires a negative pid and still has the reuse problem in the sense that the group may have been repopulated. There is no descriptor form of a group.

A pidfd does not survive the process it refers to in a useful way. Once the process is gone and reaped, the descriptor remains open and valid but every operation on it fails with ESRCH. It is a handle to something that no longer exists, and the only remaining action is to close it. Keeping it open holds the task structure allocated, which is a small but real resource cost per exited process.

The descriptor is also local to a pid namespace in the sense that the process it refers to may be invisible from another namespace, but the descriptor itself can be transferred across a socket and remains usable by the receiver if the receiver has the appropriate view. Passing a pidfd into a container’s namespace is a way to grant a supervisor inside the container the ability to signal a process outside it, and whether that works depends on the namespace relationship rather than on the descriptor.

What this pattern generalises to

The pidfd is one instance of a broader move in the interface: replace a name that can be reused with a descriptor that refers to an object and holds a reference to it. The same move appears in openat with directory descriptors, which resolve paths relative to a directory that cannot be swapped out from under the caller; in O_PATH descriptors, which refer to a location without opening it for I/O; in signalfd, which turns a signal number into a readable descriptor; and in the file descriptor itself, which refers to an open file description rather than to a path.

The pattern’s cost is uniform: a descriptor must be closed, and a descriptor table slot is consumed. A program that tracks a thousand processes with pidfds consumes a thousand entries against RLIMIT_NOFILE, where tracking them by pid consumed none. The limit is adjustable, and the entries are cheap, but the accounting is real and the failure mode when the limit is reached is EMFILE from the call that would have created the handle.

The pattern’s benefit is also uniform: the operation either applies to the intended object or fails with a specific error. There is no third outcome in which it applies to something else. For a supervisor, that is the difference between a termination signal that is guaranteed to reach the right process and one that is merely likely to.

The same reasoning about identity applies to files, where the equivalent of a reused pid is a path that has been replaced or a descriptor number that has been closed and reallocated. The descriptor layer described in file descriptors as table indices is what makes the pidfd possible: it is a table entry, with all the lifetime and duplication semantics that entails, pointing at a task structure instead of a file.

References

  1. pidfd_open(2) — Linux manual page — man7.org
  2. pidfd_send_signal(2) — Linux manual page — man7.org
  3. clone(2) — Linux manual page — man7.org
  4. The Open Group Base Specifications Issue 7, 2018 edition — System Interfaces — The Open Group