chaowen.← Back to archive

Linux process lifecycle / pidfds

Linux pidfds: race-free process control through file-descriptor identity

A numeric PID names a process only temporarily. A pidfd is a file descriptor that keeps referring to the same kernel process identity, so signaling, waiting, and event-loop supervision no longer depend on a fragile PID lookup.

Linux deep dive · Process lifecycle and supervision

The concrete problem

Suppose a supervisor starts a worker, stores its PID, and later wants to terminate and reap it. The obvious sequence is kill(pid, SIGTERM) followed by waitpid(pid, ...). The problem is that a PID is a small integer allocated from a namespace. After the original process exits and is reaped, the kernel may reuse that integer for an unrelated process.

That creates three distinct races:

OperationPID-based failurepidfd-based form
Send a signalkill(1234, SIGTERM) can eventually target a new process that inherited PID 1234.pidfd_send_signal(fd, SIGTERM, NULL, 0) targets the process represented by fd, or fails if that process is gone.
Observe exitPolling /proc/1234 or checking kill(pid, 0) is a lookup, not a stable wait object.poll(), epoll(), or select() can watch the pidfd.
Collect statusA separate waitpid(pid, ...) can race with another thread or child reaper.waitid(P_PIDFD, fd, ...) identifies the wait target by the descriptor.
Core invariant: a pidfd is a reference to a particular kernel struct pid, not a promise that a particular integer will remain assigned to the same process. Closing the fd releases your reference; PID reuse cannot retarget it.
numeric PID pidfd start ───────▶ 4711 ───────┐ start ───────▶ fd 7 ───────▶ struct pid A │ │ exit + reap ──▶ PID 4711 free│ exit ─────────────────────┘ │ poll(fd 7) → readable new process ──▶ 4711 again ──┘ signal(fd 7) → ESRCH, never process B

What a pidfd is—and what it is not

A pidfd is an ordinary-looking file descriptor with special kernel file operations. It refers to a task or thread-group identity in the caller’s PID namespace. It is not a readable file: read(fd, ...) currently fails with EINVAL. Its useful operations are descriptor operations such as polling, passing through Unix sockets, duplicating, and the pidfd-specific syscalls.

For the common process-supervisor case, create the descriptor with pidfd_open(child_pid, 0). The descriptor is created with close-on-exec set. The process must still be alive or, under the documented conditions, be an unreaped zombie at the time of the lookup. The fd then keeps the kernel identity alive long enough for the supervisor to act on it.

Linux added the main pieces in stages: pidfd_send_signal() in Linux 5.1, waitid(P_PIDFD, ...) in Linux 5.4, pidfd_open() in Linux 5.3, and PIDFD_NONBLOCK in Linux 5.10. Modern process creation can avoid even the initial PID lookup with clone3() and CLONE_PIDFD; that is the strongest design when the supervisor creates the child itself.

The kernel data and control flow

At the kernel level, a numeric PID is a namespace-relative number stored in a struct upid. The object that carries the stable identity is struct pid. It contains a reference count, the task lists for the different PID types, namespace mappings, and a wait_pidfd wait queue used for exit notification.

pidfd_open(pid, flags) │ ▼ find_get_pid(pid) kernel/pid.c │ numeric lookup, then take a reference to struct pid ▼ pidfd_create() → pidfd_prepare() │ allocate an fd and install a file referring to struct pid ▼ userspace receives fd pidfd_send_signal(fd, SIGTERM, ...) │ ├─ pidfd_to_pid() / pidfd_get_pid() ├─ verify PID-namespace relationship and signal permission └─ kill_pid_info_type() → target task child exits │ └─ do_notify_pidfd(task) └─ wake_up(wait_pidfd, EPOLLIN | EPOLLRDNORM) waitid(P_PIDFD, fd, ...) │ ├─ kernel_waitid_prepare() ├─ pidfd_get_pid(fd, ...) └─ normal wait machinery consumes the child status

The reference-counting detail matters. find_get_pid() first finds the namespace-local number and increments the struct pid reference. The descriptor’s file then owns a reference. A later PID-table reuse creates or names a different struct pid; it cannot change the object already held by the fd.

Exit notification is a wait-queue event, not data delivery. When the process becomes a zombie, the kernel wakes the pidfd wait queue with readable-style readiness. After the task is reaped, polling produces hangup-style readiness. The supervisor must still call waitid() to collect the exit status; polling only says that a lifecycle transition occurred.

Important functions, structures, and flags

SymbolRole
struct pidReference-counted kernel identity, with namespace mappings, task lists, and the pidfd wait queue.
pidfd_open(2)Convert an existing PID into a close-on-exec fd. Linux 5.3; no glibc wrapper, so use syscall().
pidfd_send_signal(2)Resolve a pidfd to its referenced identity and send a signal. Linux 5.1.
waitid(P_PIDFD, fd, ...)Wait for the child represented by the pidfd. Linux 5.4.
poll()/epoll()Observe exit: EPOLLIN when a process becomes a zombie, EPOLLHUP after it is reaped.
PIDFD_NONBLOCKMake pidfd waiting nonblocking; an early waitid() returns EAGAIN instead of sleeping.
PIDFD_THREADOn newer kernels, make pidfd_open() refer to a specific thread rather than its thread group.
CLONE_PIDFDWith clone3(), ask the kernel to return a pidfd at process creation, avoiding a PID-to-pidfd lookup window.
do_notify_pidfd()Kernel exit path that wakes the pidfd wait queue.
kernel_waitid_prepare()Kernel wait setup that recognizes P_PIDFD and resolves the fd to a referenced struct pid.

The syscall interface is intentionally lower-level than many libc APIs. As of current Linux man pages, glibc does not provide wrappers for pidfd_open() or pidfd_send_signal(). Code therefore usually includes <sys/syscall.h> and calls syscall(SYS_pidfd_open, ...). The syscall numbers are architecture-specific; use the header-provided macros rather than hard-coding one architecture’s number.

Why this fixes the race

The unsafe design has a time-of-check/time-of-use gap:

if (kill(pid, 0) == 0) {       /* process existed during this lookup */
    sleep_or_do_other_work();
    kill(pid, SIGTERM);          /* PID may now name somebody else */
}

The pidfd design moves the identity into a kernel-held reference before the gap:

int fd = syscall(SYS_pidfd_open, pid, 0);
/* fd names the same struct pid even if the integer pid is later reused. */
syscall(SYS_pidfd_send_signal, fd, SIGTERM, NULL, 0);

If the target has already terminated and been reaped, the signal operation fails with ESRCH; it does not search the PID table again and accidentally find a replacement. If the target is a child, waitid(P_PIDFD, ...) likewise waits for the referenced child, not whatever now happens to have the old number.

The guarantee does not make every process-control operation race-free. A pidfd does not automatically serialize two supervisors acting on the same process, prevent a signal from being delivered before a configuration step, or make a process group equivalent to one process. You still need ownership, state transitions, and policy in userspace.

Tradeoffs and failure modes

ChoiceBenefitCost or failure mode
Store a PID onlyPortable and simple.PID reuse, non-pollable liveness checks, and awkward coordination between signal and wait.
Open a pidfd after fork()Works with existing launchers and removes later PID reuse races.There is still a small initial lookup window; another reaper may reap the child before pidfd_open().
clone3(CLONE_PIDFD)Returns the pidfd as part of creation, with no PID lookup step.Linux-specific, more complicated syscall ABI, and not available through older libc wrappers.
Poll then waitid()Fits an epoll-based supervisor and avoids one blocking wait per worker.Readiness is level-like lifecycle state; careless loops can repeatedly see it until the child is reaped.
PIDFD_NONBLOCKPrevents an accidental blocking wait in an event loop.EAGAIN becomes an expected state; code must retry after readiness.
Pass pidfds over a Unix socketTransfers stable process capabilities between cooperating processes.Descriptor possession is authority; access control and lifecycle ownership must be explicit.

waitid(P_PIDFD, ...) is a child-wait operation. A supervisor may obtain a pidfd for an unrelated process and use it for signaling or polling, but it cannot use that pidfd to reap a process it does not own as a waitable child. Expect ECHILD or permission-related failures when the relationship is wrong.

There is another common trap: a process can become a zombie before it is reaped. The pidfd may become readable at that point, but the target’s resources are not fully released until a permitted parent or subreaper waits for it. A pidfd is not a substitute for designing who reaps children.

Security and isolation implications

A pidfd behaves like a capability-shaped reference: possession lets code name a particular process without guessing its PID. It is not, however, an authorization bypass. pidfd_send_signal() still checks signal permissions, and the caller must be in the same PID namespace as the target or an ancestor namespace. Linux security modules and normal credential rules still apply.

Descriptor leakage is therefore worth treating as a security bug. A leaked pidfd can let another process repeatedly attempt signals, observe termination readiness, or interfere with shutdown policy. The kernel creates pidfds close-on-exec, but audit explicit descriptor passing, dup(), service-manager handoff, and Unix-socket ancillary data.

PID namespaces change the numbers visible to processes, but not the stable-reference principle. A supervisor in an ancestor namespace can often address a descendant process through a pidfd, while a process in a child namespace cannot necessarily address an ancestor. Test namespace boundaries rather than assuming that a numeric PID shown in one namespace is meaningful in another.

Debugging method

  1. Log both the numeric PID and the pidfd integer at creation, but treat the fd—not the log—as the identity. Include the kernel error and the operation that produced it.
  2. Trace syscalls with strace -ff -tt -e trace=process,signal,poll,wait4,waitid. Look for pidfd_open, pidfd_send_signal, poll readiness, and the final wait.
  3. Inspect /proc/<pid>/fd/<fd> and /proc/<pid>/fdinfo/<fd> while the target is alive. Do not try to read the pidfd as though it were a text file.
  4. Force the interesting states: poll before exit, signal before exit, signal after exit but before reaping, and signal after reaping. These produce different combinations of readiness and ESRCH.
  5. Use an explicit child-reaper policy. If another thread calls waitpid(-1, ...), it may consume the status before the pidfd supervisor calls waitid(P_PIDFD, ...).
  6. For a containerized failure, check kernel version and syscall availability first. A new userspace header does not create an old-kernel implementation, and a restricted seccomp profile may return ENOSYS.
# Linux tracing examples
strace -ff -tt -e trace=process,signal,poll,wait4,waitid ./pidfd-lab

# Check the running kernel and headers before debugging application logic
uname -r
grep -R -n 'P_PIDFD\|SYS_pidfd_open\|__NR_pidfd_open' /usr/include/linux /usr/include/sys 2>/dev/null

# A pidfd is not readable data; this is expected to fail with EINVAL
readlink /proc/$PID/fd/$PIDFD
dd if=/proc/$PID/fd/$PIDFD bs=1 count=1 2>&1 || true

Compact reproducible lab

Goal

Create a child, obtain a pidfd, prove that it is initially not ready, terminate the child through the pidfd, observe readiness with poll(), and reap it using waitid(P_PIDFD, ...). The code deliberately never calls kill(child_pid, ...) or waitpid(child_pid, ...).

Prerequisites

Linux 5.4 or newer with a C compiler and headers. The program uses the syscall macros supplied by Linux headers. It is intentionally Linux-specific; macOS and other Unix systems do not provide pidfds.

Exact commands

cc -O2 -Wall -Wextra -o /tmp/pidfd-lab -x c - <<'C'
#define _GNU_SOURCE
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#ifndef SYS_pidfd_open
# ifdef __NR_pidfd_open
#  define SYS_pidfd_open __NR_pidfd_open
# else
#  error "Linux headers do not define pidfd_open"
# endif
#endif
#ifndef SYS_pidfd_send_signal
# ifdef __NR_pidfd_send_signal
#  define SYS_pidfd_send_signal __NR_pidfd_send_signal
# else
#  error "Linux headers do not define pidfd_send_signal"
# endif
#endif
#ifndef P_PIDFD
# define P_PIDFD 3
#endif

static void die(const char *what) { perror(what); exit(EXIT_FAILURE); }

int main(void) {
    pid_t child = fork();
    if (child < 0) die("fork");
    if (child == 0) {
        for (;;) pause();
    }

    int pidfd = syscall(SYS_pidfd_open, child, 0);
    if (pidfd < 0) die("pidfd_open");
    printf("child-pid=%ld pidfd=%d\n", (long)child, pidfd);

    struct pollfd pfd = {.fd = pidfd, .events = POLLIN};
    int rc = poll(&pfd, 1, 100);
    if (rc < 0) die("poll before exit");
    printf("before-exit: poll=%d revents=0x%x\n", rc, pfd.revents);

    if (syscall(SYS_pidfd_send_signal, pidfd, SIGTERM, NULL, 0) < 0)
        die("pidfd_send_signal");
    printf("signal-sent-through-pidfd\n");

    pfd.revents = 0;
    if (poll(&pfd, 1, 2000) <= 0) die("poll after exit");
    printf("after-exit: revents=0x%x (POLLIN=%d)\n", pfd.revents, POLLIN);

    siginfo_t si = {0};
    if (waitid(P_PIDFD, (id_t)pidfd, &si, WEXITED) < 0)
        die("waitid(P_PIDFD)");
    printf("reaped: si_pid=%ld si_code=%d si_status=%d\n",
           (long)si.si_pid, si.si_code, si.si_status);
    close(pidfd);
    return 0;
}
C

/tmp/pidfd-lab

# Optional: watch the exact kernel interactions
strace -f -tt -e trace=process,signal,poll,wait4,waitid /tmp/pidfd-lab

Expected result

The first poll should time out: before-exit: poll=0. The signal call succeeds through the fd. The second poll should report POLLIN (often with additional flags depending on timing). Finally, waitid(P_PIDFD, ...) reports the child’s PID and a signal-termination status, usually si_status=15 for SIGTERM.

The important observation is not the specific fd number. It is the control flow: the supervisor uses the fd for signal delivery, readiness, and waiting, while the numeric PID is only printed for human correlation.

Revealing variations

Move waitid() before the second poll(). With a normal blocking pidfd it waits until the child exits; with pidfd_open(child, PIDFD_NONBLOCK) and <linux/pidfd.h>, an early wait returns EAGAIN. Next, replace the signal syscall with kill(child, SIGTERM); the program still works, but it no longer demonstrates that the control operation is bound to the stable descriptor identity.

Environment result: this host is macOS (Darwin 24.6, Apple clang 17), and its SDK has no Linux pidfd headers or syscalls. The Linux compile command therefore fails at the intended header-macro check and cannot run here. The corrected procedure is to run it in a Linux 5.4+ VM, container, or native Linux host; a Docker container must use a sufficiently new host kernel because containers share the host kernel.

Production example: a supervised worker pool

A robust worker supervisor can store one record per worker: pidfd, desired state, restart budget, and the last observed exit status. It registers all pidfds with one epoll instance. On EPOLLIN, it calls waitid(P_PIDFD, ...), removes the fd, closes it, and decides whether to restart. On shutdown, it sends SIGTERM through each pidfd, waits for readiness, then escalates to SIGKILL through the same fd if a deadline expires.

That design avoids the classic “PID disappeared, then a new worker acquired the same number” bug. It also makes descriptor ownership visible: the epoll registration and worker record are the authoritative state, not a periodically rescanned process table.

Service managers and container runtimes can use the same pattern when handing process authority between components. When the creator owns the child, clone3(CLONE_PIDFD) or an equivalent modern process-creation API is preferable to creating a child, publishing its PID, and opening a pidfd later.

Primary sources

Remember: a PID is a reusable name; a pidfd is a held kernel identity. Use the descriptor for signal, readiness, and wait, but still design permissions, ownership, namespace boundaries, and reaping policy.