pidfd Process File Descriptors

A process file descriptor (pidfd) is a stable, kernel-backed handle that refers to one specific process for the process’s entire lifetime — and, crucially, not to whatever process happens to inherit that process’s numeric process identifier (PID) afterward. It exists to close a decades-old race in Unix: a PID is reusable, so any code that records a PID and later acts on it (kill(pid, …), waitpid(pid, …)) can — if the original process died and the kernel recycled its PID for an unrelated new process — deliver the signal or the wait to the wrong process. This is a classic time-of-check-to-time-of-use (TOCTOU) hazard. A pidfd removes it: signalling, waiting on, and inspecting a process all happen through a file descriptor that the kernel guarantees never silently re-points. If the process the pidfd refers to has exited, operations fail cleanly with ESRCH instead of hitting an impostor (pidfd_send_signal(2)). As a bonus, a pidfd is an ordinary fd, so it slots straight into an epoll event loop: it becomes readable when the process exits, letting one loop wait on child death the same way it waits on sockets and timers. This note pins its mechanics to Linux 6.12 LTS (released 2024-11-17), with the introducing release dated for each piece of the API.

Mental Model

Think of a PID as a name and a pidfd as a reference. The name (pid_t, a small integer like 4217) is drawn from a finite, recycled pool; once the process bearing it is reaped, the kernel is free to hand 4217 to the next fork(). Any data structure that stored the bare integer now points at a stranger. A pidfd is instead a reference to the underlying kernel object — a struct pid — pinned by the fd. As long as you hold the fd, that struct pid cannot be reused for a different process, so the reference can never be confused. The process can exit (the reference then refers to a dead/zombie task), but it can never quietly become a different process.

flowchart TB
  subgraph BAD["Raw PID — racy"]
    A1["record pid = 4217"] --> A2["child 4217 exits"]
    A2 --> A3["kernel recycles 4217<br/>to an unrelated process"]
    A3 --> A4["kill(4217, SIGTERM)<br/>hits the WRONG process"]
  end
  subgraph GOOD["pidfd — race-free"]
    B1["pidfd = pidfd_open(4217)<br/>(or CLONE_PIDFD at fork)"] --> B2["holds struct pid,<br/>pins the reference"]
    B2 --> B3["child exits"]
    B3 --> B4["pidfd_send_signal(pidfd, SIGTERM)<br/>=> ESRCH (clean failure)"]
    B2 --> B5["epoll: pidfd becomes<br/>readable on exit"]
  end

Figure: the same operation, racy with a PID and safe with a pidfd. What it shows: the top row is the TOCTOU window — between recording 4217 and using it, the PID can be recycled, so kill lands on an impostor. The bottom row holds a reference to the actual process object, so after the process dies the operation fails visibly (ESRCH) rather than mis-firing. The insight to take: a pidfd converts a silent, dangerous mis-target into an explicit, catchable error — and makes process death a pollable event into the bargain.

Why PID Reuse Is a Real Bug, Not a Theoretical One

The PID space is small and recycled. On Linux the maximum PID defaults to 32768 (/proc/sys/kernel/pid_max, raisable to ~4 million on 64-bit), and PIDs are allocated roughly cyclically, so on a busy machine that churns short-lived processes the numbers wrap and reuse quickly. The danger window is precisely between the moment your program learns a PID and the moment it acts on it. A supervisor that does pid = fork_child(); … ; kill(pid, SIGKILL) can, if the child exits and is reaped and the PID is reused before the kill, send SIGKILL to an innocent bystander — historically the source of real init-system and container-runtime bugs. The kill(2)-via-PID interface has no way to express “signal this process, and only if it is still the one I meant.” The pidfd interface does, because the fd carries identity, not just a number (pidfd_send_signal(2)).

Three Ways to Obtain a pidfd

There are three distinct entry points, each suited to a different situation.

1. pidfd_open(pid, flags) — from an existing PID (since Linux 5.3). This is the explicit “give me a handle to process pid” call. There is no glibc wrapper, so you invoke it via syscall(SYS_pidfd_open, …) (pidfd_open(2)). The kernel implementation in kernel/pid.c (v6.12) is short and shows exactly what is validated:

SYSCALL_DEFINE2(pidfd_open, pid_t, pid, unsigned int, flags)
{
	int fd;
	struct pid *p;
 
	if (flags & ~(PIDFD_NONBLOCK | PIDFD_THREAD))   // only two flags accepted
		return -EINVAL;
 
	if (pid <= 0)                                    // pid must be positive
		return -EINVAL;
 
	p = find_get_pid(pid);                           // look up struct pid, take a reference
	if (!p)
		return -ESRCH;                               // no such process
 
	fd = pidfd_create(p, flags);                     // build the anon-inode fd
 
	put_pid(p);
	return fd;
}

Line by line: only PIDFD_NONBLOCK and PIDFD_THREAD are legal flags (anything else is -EINVAL); pid must be positive (so you cannot ask for a process group); find_get_pid resolves the integer to the kernel’s struct pid and bumps its refcount; if there is no such process you get -ESRCH; otherwise pidfd_create allocates the descriptor and put_pid drops the temporary reference (the fd now holds its own). There is still a residual race in pidfd_open itself — between you learning the PID and the find_get_pid call the PID could already have been reused — which is exactly why obtaining the pidfd at creation time (option 2) is strictly safer.

2. clone(CLONE_PIDFD) / clone3() — at creation, atomically (since Linux 5.2). When you are the one creating the child, you can ask the kernel to hand back a pidfd as part of the same clone/clone3 that creates it, eliminating the window entirely — there is never a moment where you hold the PID but not the pidfd. With clone3() the descriptor is written to cl_args.pidfd; with legacy clone() it is written through the parent_tid pointer, which is why CLONE_PIDFD is mutually exclusive with CLONE_PARENT_SETTID (they share that argument) and, historically, with CLONE_DETACHED (clone(2)). In kernel/fork.c (v6.12) the relevant fragment is:

if (clone_flags & CLONE_PIDFD) {
	int flags = (clone_flags & CLONE_THREAD) ? PIDFD_THREAD : 0;
	...
	retval = __pidfd_prepare(pid, flags, &pidfile);
	...
}

Note that since Linux 6.9 CLONE_PIDFD may be combined with CLONE_THREAD (it then yields a thread-level pidfd, PIDFD_THREAD); before 6.9 that combination was rejected (clone(2)). This atomic form is what modern supervisors (systemd, container runtimes) use: fork the child and receive its race-free handle in one syscall.

3. pidfd_getfd(pidfd, targetfd, flags) — pull an fd out of another process (since Linux 5.6). This is a different beast: given a pidfd for some target process and the number of one of its open file descriptors, it duplicates that descriptor into your own table — “the equivalent of SCM_RIGHTS but without the sender’s cooperation” (pidfd_getfd(2)). The duplicate refers to the same open file description (shared offset and status flags), and is created with close-on-exec set. Because it lets you reach into another process’s resources, it is gated by a ptrace access check — PTRACE_MODE_ATTACH_REALCREDS, i.e. you need the same rights you would to ptrace-attach (CAP_SYS_PTRACE, or being the same user subject to the Yama LSM’s ptrace_scope). flags is reserved and must be 0. The syscall body in kernel/pid.c (v6.12) resolves the pidfd to its struct pid, then calls the internal pidfd_getfd(pid, fd) helper, which ultimately does receive_fd(file, NULL, O_CLOEXEC) to install the copy. This is how debuggers, sandboxes, and CRIU-style checkpoint/restore tools acquire another process’s sockets or files.

Operations on a pidfd

Once you hold a pidfd, three things matter: signalling, waiting, and inspecting.

Signal race-free with pidfd_send_signal(pidfd, sig, info, flags) (since Linux 5.1). This is the safe replacement for kill(pid, sig). Because the pidfd is a stable reference, the kernel knows exactly which process you mean; if that process has terminated, the call returns ESRCH rather than signalling someone else (pidfd_send_signal(2)). The info argument is an optional siginfo_t; passing NULL reproduces kill(2)’s default siginfo. The kernel implementation in kernel/signal.c (v6.12) shows the access control and scope logic:

SYSCALL_DEFINE4(pidfd_send_signal, int, pidfd, int, sig,
		siginfo_t __user *, info, unsigned int, flags)
{
	...
	if (flags & ~PIDFD_SEND_SIGNAL_FLAGS)            // only the three scope flags allowed
		return -EINVAL;
	if (hweight32(flags & PIDFD_SEND_SIGNAL_FLAGS) > 1)  // at most one scope flag
		return -EINVAL;
	...
	pid = pidfd_to_pid(fd_file(f));                  // recover the struct pid
	...
	if (!access_pidfd_pidns(pid))                    // pid must be visible in caller's pid namespace
		goto err;
 
	switch (flags) {
	case 0:                                          // scope inferred from the pidfd kind
		if (fd_file(f)->f_flags & PIDFD_THREAD)
			type = PIDTYPE_PID;                      //   thread-pidfd -> the single thread
		else
			type = PIDTYPE_TGID;                     //   process-pidfd -> whole thread group
		break;
	case PIDFD_SIGNAL_THREAD:        type = PIDTYPE_PID;  break;
	case PIDFD_SIGNAL_THREAD_GROUP:  type = PIDTYPE_TGID; break;
	case PIDFD_SIGNAL_PROCESS_GROUP: type = PIDTYPE_PGID; break;
	}
	...
}

The key reads: access_pidfd_pidns walks the PID-namespace hierarchy and refuses to signal a process that is not visible in the caller’s active PID namespace (a container-isolation guarantee); and the scope of delivery defaults to the whole process (thread group, PIDTYPE_TGID) for a normal pidfd, or the single thread (PIDTYPE_PID) for a PIDFD_THREAD pidfd. The three PIDFD_SIGNAL_* flags (Linux 6.9+) let you override that default — e.g. PIDFD_SIGNAL_THREAD_GROUP to signal the whole group through a thread pidfd. There is a deliberate restriction in the info != NULL path: you may only forge arbitrary siginfo (with si_code >= 0 or SI_TKILL) when signalling yourself — you cannot fabricate a “sent by the kernel” siginfo to another process.

Wait on exit via poll/epoll — the part that makes pidfd an IPC primitive. A pidfd becomes readable (EPOLLIN) when the referred-to process exits. This is what lets a single event loop wait on child death alongside sockets, timers, and signalfds, instead of relying on a SIGCHLD handler racing with the main loop (the reactor pattern of The epoll Readiness Model and IPC). The mechanism, in fs/pidfs.c (v6.12), is the pidfd’s ->poll method:

static __poll_t pidfd_poll(struct file *file, struct poll_table_struct *pts)
{
	struct pid *pid = pidfd_pid(file);
	bool thread = file->f_flags & PIDFD_THREAD;
	struct task_struct *task;
	__poll_t poll_flags = 0;
 
	poll_wait(file, &pid->wait_pidfd, pts);          // register on the per-pid wait queue
	guard(rcu)();
	task = pid_task(pid, PIDTYPE_PID);
	if (!task)
		poll_flags = EPOLLIN | EPOLLRDNORM | EPOLLHUP;   // already reaped/gone
	else if (task->exit_state && (thread || thread_group_empty(task)))
		poll_flags = EPOLLIN | EPOLLRDNORM;              // exited (zombie)
	return poll_flags;
}

The pidfd registers the waiter on a wait queue that lives on the struct pid itself (pid->wait_pidfd), and reports readable once task->exit_state is set — but, for a normal (non-thread) pidfd, only when the whole thread group has emptied (thread_group_empty), so a multithreaded process is “exited” only after its last thread is gone. The wakeup that fires this comes from the exit path. In kernel/exit.c (v6.12), exit_notify sets tsk->exit_state = EXIT_ZOMBIE and, for a sub-thread, calls do_notify_pidfd(tsk); and in kernel/signal.c, do_notify_parent calls do_notify_pidfd(tsk) for an emptied group leader. do_notify_pidfd is simply:

void do_notify_pidfd(struct task_struct *task)
{
	struct pid *pid = task_pid(task);
	WARN_ON(task->exit_state == 0);
	__wake_up(&pid->wait_pidfd, TASK_NORMAL, 0,
			poll_to_key(EPOLLIN | EPOLLRDNORM));     // wake epoll waiters with the ready mask
}

So process exit pushes an EPOLLIN wakeup to anyone polling the pidfd — exactly the same wait-queue-and-key plumbing epoll uses for sockets. Note you cannot read() a pidfd for data: a read(2) on it returns EINVAL (pidfd_open(2)). The fd is purely a readiness signal; to harvest the exit status you still call waitid.

Reap with waitid(P_PIDFD, pidfd, …) (since Linux 5.4). waitid grew a P_PIDFD idtype: pass the pidfd as the id and it waits on (and reaps) exactly that process, race-free (waitid(2)). Combined with PIDFD_NONBLOCK (Linux 5.10+), a waitid(P_PIDFD, …) on a not-yet-exited process returns EAGAIN immediately instead of blocking — the natural fit for an event loop that has just seen the pidfd become readable and now wants the status without risking a block.

A Worked Event-Loop Example

The canonical use is a supervisor that spawns a child, then waits on its death as a pollable event. Sketched with clone3() for the atomic pidfd and epoll:

struct clone_args args = {
	.flags  = CLONE_PIDFD,
	.pidfd  = (uintptr_t)&pidfd,   // kernel writes the child's pidfd here
	.exit_signal = SIGCHLD,
};
pid_t pid = syscall(SYS_clone3, &args, sizeof(args));
if (pid == 0) { execvp(argv[0], argv); _exit(127); }   // child
 
int ep = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = pidfd };
epoll_ctl(ep, EPOLL_CTL_ADD, pidfd, &ev);              // watch the child for exit
...
struct epoll_event out[8];
int n = epoll_wait(ep, out, 8, -1);
for (int i = 0; i < n; i++) {
	if (out[i].data.fd == pidfd) {                    // child exited
		siginfo_t si;
		waitid(P_PIDFD, pidfd, &si, WEXITED);         // reap race-free; si.si_status has the code
		close(pidfd);                                 // drop the reference
	}
}

Walking it: clone3 with CLONE_PIDFD returns the child’s PID and writes its pidfd into our variable atomically, so there is no window in which the child could exit and have its PID recycled before we hold the handle. We register the pidfd with EPOLLIN; when the child exits, the exit path’s do_notify_pidfd wakes the epoll wait and the pidfd appears in out[]. We then waitid(P_PIDFD, …) to collect the exit status — guaranteed to be this child’s, never an impostor’s — and close the fd to release the kernel reference. The supervisor’s main loop can interleave this with sockets, a signalfd, and a timerfd heartbeat in the same epoll_wait.

Failure Modes and Common Misunderstandings

“My pidfd_open still raced.” pidfd_open(pid, …) resolves a PID you already hold, so it inherits whatever race produced that PID. It is race-free for subsequent operations on the resulting fd, but the lookup itself can still grab a recycled PID if the original died before the call. The fully safe pattern is to get the pidfd at creation via CLONE_PIDFD; reserve pidfd_open for processes you did not spawn and where a small residual lookup race is acceptable.

read() on the pidfd returns EINVAL.” Correct and expected — a pidfd carries no readable payload. It is a readiness fd: poll it for exit, waitid it for status. Treating it like a pipe is the most common first mistake.

“The pidfd never became readable for my multithreaded child.” A non-thread pidfd reports readable only when the entire thread group has exited (thread_group_empty in pidfd_poll). A process whose leader returned but whose worker threads linger is not yet “exited.” Use PIDFD_THREAD if you genuinely want per-thread exit notification.

pidfd_getfd returned EPERM.” It requires ptrace-attach rights over the target (PTRACE_MODE_ATTACH_REALCREDS). Under the default Yama ptrace_scope=1, you can only reach into your own descendants unless you hold CAP_SYS_PTRACE. This is a security feature, not a bug.

“Signalling across a container boundary failed with EINVAL.” access_pidfd_pidns refuses to signal a process not visible in your active PID namespace. A pidfd opened in one namespace cannot be used to signal into a sibling namespace it cannot see.

“I leaked file descriptors under load.” Every pidfd is an open fd counting against RLIMIT_NOFILE. A supervisor that spawns thousands of children and forgets to close each pidfd after reaping will exhaust its descriptor table. The pidfd is set close-on-exec by default, but you must still close it on the exit path.

Alternatives and When to Choose Them

  • Raw kill(pid) + SIGCHLD handler: the classic approach. Simpler and universally portable, but racy (PID reuse) and awkward to integrate with an event loop (the async signal handler must hand off to the main loop via a self-pipe or signalfd). Choose it only for trivial programs or maximum portability.
  • /proc/<pid> directory fd: you can open /proc/<pid> and it is pinned to the process, but the man page notes such fds “are not pollable and can’t be waited on with waitid(2)” (pidfd_open(2)) — so they give you stable inspection but not the readiness/wait integration that makes pidfds useful.
  • waitpid(pid) blocking in a dedicated thread: works, but burns a thread per child and does not compose with other event sources. The pidfd-in-epoll pattern collapses all of it into one loop.

Production Notes

pidfds are now the backbone of modern Linux process management. systemd uses CLONE_PIDFD and pidfd-based tracking to supervise services without PID-reuse races; container runtimes (runc, crun) use pidfds to signal and wait on container init processes safely across namespace boundaries; and CRIU-style checkpoint/restore and sandboxing tools use pidfd_getfd to extract a frozen process’s open descriptors. The Go runtime added os.Process pidfd support so Process.Signal/Wait are race-free on Linux. The recurring real-world lesson the pidfd interface encodes is that identity must be carried by a reference, not re-derived from a recycled name — the same principle behind capability-style design. For diagnosis, /proc/<pid>/fdinfo/<pidfd> shows a Pid: line (and NSpid: under PID namespaces) emitted by pidfd_show_fdinfo in fs/pidfs.c, so you can confirm which process a given pidfd actually refers to.

See Also

  • The epoll Readiness Model and IPC — the reactor pattern that makes the pidfd’s poll-on-exit useful; one loop over child exits, sockets, signals, and timers
  • epoll and Scalable Readiness Notification — the epoll internals (wait queues, ready list) the pidfd’s do_notify_pidfd wakeup plugs into
  • The task_struct Process Descriptor — the task_struct/struct pid objects a pidfd references and whose exit_state drives readiness
  • eventfd — sibling fd primitive; a counter used as a self-wake/notify channel in the same kind of event loop
  • signalfd — sibling fd primitive; turns signals (including SIGCHLD) into a readable fd, a natural companion to pidfds
  • timerfd — sibling fd primitive; a timer as a readable fd
  • Linux IPC MOC — parent map (§8, File-Descriptor Event Primitives)