pidfd_open(2): your PID is not your process
Published by RodHat

PIDs lie. This is not a new observation. It is a design constraint so old that every serious systems programmer has a scar from it, and most of them have at least one debug session that ended with “oh, we were signaling the wrong process.”
fork(2) returns the child’s PID to the parent. What it cannot tell you is how long that PID stays valid. Once a process exits, the kernel recycles its PID. On a busy system with a short-lived child and a parent that is slow to react, the PID your fork() returned is already pointing at something else by the time your kill() fires.
The POSIX answer is waitpid(2). Fine for simple parent-child relationships. Falls apart when you have a process supervisor that outlives the direct parent, or you passed the PID to another thread that needs to signal it, or you are managing a worker pool and something else may reap them out from under you.
Linux 5.2 (2019) started fixing this with CLONE_PIDFD. Linux 5.3 added pidfd_open(2). The result is a file descriptor that refers to a specific process instance, not a number that refers to whoever happens to hold that PID right now.
The problem in concrete terms
pid_t child = fork();
if (child == 0) {
/* do work */
_exit(0);
}
/* parent, some time later */
sleep(10);
kill(child, SIGTERM); /* who are we actually killing? */
If child exited during those 10 seconds and nothing called wait() on it, the PID is still alive as a zombie and you are fine. If something did reap it, the PID is free. In 10 seconds on a busy system, PID reuse is not theoretical. kill() hits whatever process now owns that number.
This bites hardest in init systems, process supervisors, and anything that holds PID files. The PID file was written when the daemon started. You read it an hour later. The daemon may have restarted three times since then.
pidfd_open(2)
#include <sys/syscall.h>
#include <unistd.h>
int pidfd = syscall(SYS_pidfd_open, pid, 0);
glibc added a pidfd_open(3) wrapper in 2.36. Before that, syscall() directly.
pidfd_open returns a file descriptor tied to the process with the given PID at the time of the call. The kernel holds an internal reference to that specific process instance. If the process exits, the fd stays valid: you can still call waitid(P_PIDFD, ...) on it. A new process that reuses the PID gets a different kernel reference. There is no race.
The flags argument is currently 0; no flags are defined yet. Pass anything else and you get EINVAL.
You need PTRACE_MODE_READ_FSCREDS permission to open a pidfd for a process you do not own. Same check as opening /proc/<pid>.
Sending signals
int ret = syscall(SYS_pidfd_send_signal, pidfd, SIGTERM, NULL, 0);
pidfd_send_signal(2) landed in Linux 5.1. Third argument is a siginfo_t *; pass NULL and the kernel fills in a standard siginfo. Fourth argument is flags, currently 0.
The difference from kill(2): if the process has exited and its PID has been reused, pidfd_send_signal returns ESRCH. The fd is still valid; the kernel knows the process it refers to is gone. You get a real error instead of silently killing something unrelated.
glibc 2.36 added pidfd_send_signal(3) as a proper wrapper.
Getting a pidfd at fork time
The right place to get a pidfd is at the moment you create the process, before any race window opens:
int pidfd = -1;
struct clone_args args = {
.flags = CLONE_PIDFD,
.pidfd = (uint64_t)(uintptr_t)&pidfd,
.exit_signal = SIGCHLD,
};
pid_t child = syscall(SYS_clone3, &args, sizeof(args));
CLONE_PIDFD was added in Linux 5.2. clone3(2) (Linux 5.3) takes a struct clone_args from <linux/sched.h> and is the cleaner path to it. The syscall writes the pidfd into your provided pointer before returning. You have a fd in hand before the child has a chance to exit and have its PID recycled.
If clone3 is too new for your toolchain, the older clone(2) with CLONE_PIDFD also works; the pidfd lands in the third argument.
Waiting on a pidfd
siginfo_t info = {0};
waitid(P_PIDFD, (id_t)pidfd, &info, WEXITED);
waitid(2) with P_PIDFD waits for the process referenced by the pidfd to exit, regardless of what happened to the PID number. info.si_code is CLD_EXITED, CLD_KILLED, or CLD_DUMPED. info.si_status is the exit code or signal number.
WNOHANG works here the same as with waitpid: returns immediately if the process has not yet exited, with si_pid set to 0.
Polling for exit with epoll
The part that surprises people: a pidfd is pollable. Add it to an epoll set and you get a notification when the process exits:
struct epoll_event ev = {
.events = EPOLLIN,
.data.fd = pidfd,
};
epoll_ctl(epfd, EPOLL_CTL_ADD, pidfd, &ev);
/* in your epoll_wait loop: when pidfd is readable, the process exited */
/* call waitid(P_PIDFD, ...) to reap it */
This is how a modern process supervisor handles hundreds of children with a single event loop: no SIGCHLD handler, no signal self-pipe trick, no SA_RESTART juggling, no global signal mask nonsense. The fd shows up in epoll_wait when the process exits. You call waitid(P_PIDFD, ...) to reap it. The fd drives the entire lifecycle.
FreeBSD got there first
FreeBSD 9.0, 2012, shipped pdfork(2), pdkill(2), and pdwait4(2). Seven years before Linux 5.2. The BSDs continue their tradition of being a reference implementation that Linux eventually catches up to, at which point everyone acts like it is a novel idea.
pdfork(2) is a drop-in for fork(2) that takes an extra int *fdp argument and fills in a process descriptor. pdkill(2) signals via the descriptor. pdwait4(2) waits on it. Same semantics, and it has been in production FreeBSD deployments since 2012. Noted without further comment.
Full working example
#define _GNU_SOURCE
#include <sys/syscall.h>
#include <sys/epoll.h>
#include <sys/wait.h>
#include <linux/sched.h>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main(void)
{
int pidfd = -1;
struct clone_args args = {
.flags = CLONE_PIDFD,
.pidfd = (uint64_t)(uintptr_t)&pidfd,
.exit_signal = SIGCHLD,
};
pid_t child = syscall(SYS_clone3, &args, sizeof(args));
if (child < 0) { perror("clone3"); return 1; }
if (child == 0) {
printf("child: pid=%d, sleeping 2s\n", getpid());
sleep(2);
_exit(42);
}
printf("parent: child pid=%d pidfd=%d\n", child, pidfd);
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = pidfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, pidfd, &ev);
struct epoll_event out;
int n = epoll_wait(epfd, &out, 1, 5000);
if (n <= 0) { fprintf(stderr, "timeout or error\n"); return 1; }
siginfo_t info = {0};
if (waitid(P_PIDFD, (id_t)pidfd, &info, WEXITED | WNOHANG) == 0) {
if (info.si_code == CLD_EXITED)
printf("parent: child exited status=%d\n", info.si_status);
else
printf("parent: child killed signal=%d\n", info.si_status);
} else {
perror("waitid");
}
close(pidfd);
close(epfd);
return 0;
}
Compile with gcc -o pidfd_demo pidfd_demo.c. Requires Linux 5.3+ for clone3. The SYS_clone3 constant is in <sys/syscall.h> on any reasonably recent toolchain; on x86-64 it is syscall 435.
Run it. The parent blocks in epoll_wait, the child sleeps two seconds and exits with status 42, and the parent wakes up and reaps it. No SIGCHLD, no signal masks, no self-pipe, no races.
When to use it
Use pidfd when you are writing a process supervisor or init system, when you pass a child’s PID to a thread or another process that will signal or wait on it, or when you want epoll-based process-exit notification without SIGCHLD plumbing.
For straightforward parent-child code where the parent immediately waits on the child it forked, waitpid(2) is fine. The race window is short and the PID is not shared with anyone. Do not refactor working code for novelty.
But if you are holding a PID across any nontrivial time window, or handing it off, the fd is worth the extra setup. pidfd_send_signal returning ESRCH because your target already exited is correct behavior. It is much better than kill(2) landing silently on a process that had nothing to do with yours.
The statx(2) post covers another syscall from the same “we finally got this right” generation: birth time, inode attribute flags, and mount IDs that stat(2) could never surface. The new mount API post covers fsopen/fsmount and detached mount fds, which pair naturally with pidfd if you are building a container runtime that needs race-free handles on both processes and mount namespaces.