$RodHat_
Console Tips

pidfd: Unix finally has a handle on your processes (only took 50 years)

Published by · Updated

pidfd: Unix finally has a handle on your processes (only took 50 years)
Photo: AI-generated — no human photographer / RodHat AI Cover

Unix process management is built on a number. A PID. A 32-bit integer the kernel hands you at fork time and will absolutely give to someone else the moment your child exits and gets reaped. The entire process management API (kill(2), wait(2), waitpid(2)) takes that number and assumes it still means what you think it means. It often doesn’t.

Here’s the race, as it’s existed since Edition 6 Unix: you fork a child, it returns pid_t child = fork(). The child runs for a while and exits. You’re busy doing something else. Something reaps it, and the kernel recycles that PID. A completely unrelated process is now running as child. You call kill(child, SIGTERM). Congratulations, you just signaled the wrong process. If you’re lucky the recipient ignores the signal or crashes unspectacularly. If you’re unlucky you’ve sent SIGKILL to someone else’s nginx.

This is not theoretical. PID space on Linux defaults to 32768 slots (/proc/sys/kernel/pid_max, adjustable up to 4194304 on 64-bit), and under load a PID gets recycled fast. PID files are the worst offender: written when the daemon started, read an hour later, by which point the daemon may have restarted three times and the number belongs to a cron job. The standard advice was “don’t hold PIDs for long” and “use SIGCHLD immediately.” The POSIX answer, waitpid(2), works because an unreaped zombie keeps its PID reserved, but only if you’re the direct parent and nobody else reaps it out from under you. Supervisors, worker pools, and anything that hands a PID to another thread don’t get that guarantee. All of it is correct advice and also a paperclip solution to a problem that should have been a design decision.

Linux 5.1 through 5.6 made the design decision, in instalments. The answer is pidfd.

The concept: a reference, not a number

A pidfd is a file descriptor that refers to a specific process instance. File descriptors are not recycled out from under you. The fd holds a kernel reference to that process, not to whoever currently owns the number. The process can still exit, get reaped, and have its PID handed to somebody else; your fd keeps pointing at the dead original, and every operation through it returns ESRCH instead of landing on the new tenant. When you signal or wait via the fd, the kernel checks the actual process the fd was opened against, not a PID lookup.

Three syscalls make up the API:

  • pidfd_send_signal(pidfd, sig, info, flags): signal via fd, atomically (5.1, landed before there was a clean way to get a pidfd)
  • pidfd_open(pid, flags): open a pidfd for an existing process (5.3)
  • pidfd_getfd(pidfd, targetfd, flags): grab an fd from another process’s table (5.6)

Plus additions to existing syscalls:

  • CLONE_PIDFD flag to clone(2) (5.2) and clone3(2) (which itself arrived in 5.3): get a pidfd for the child at fork time
  • P_PIDFD selector for waitid(2): wait on a child by fd, not PID (5.4)

And the bonus: pidfds are pollable with poll(2), select(2), and epoll(7). They become readable when the process exits.

pidfd_open: take a stable handle on an existing process

#define _GNU_SOURCE
#include <sys/types.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

/* call it directly; see the glibc note below */
static int pidfd_open(pid_t pid, unsigned int flags) {
    return (int)syscall(SYS_pidfd_open, pid, flags);
}

int main(void) {
    pid_t target = 12345;  /* some process you care about */

    int pfd = pidfd_open(target, 0);
    if (pfd < 0) {
        perror("pidfd_open");
        /* ESRCH means the process is already gone, so PID reuse isn't your
           problem here because the open itself failed cleanly */
        return 1;
    }

    /* pfd now refers to that specific process. Even if PID 12345 exits
       and gets recycled, pfd still refers to the original process. */

    /* Signal it without the race */
    int r = syscall(SYS_pidfd_send_signal, pfd, SIGTERM, NULL, 0);
    if (r < 0)
        perror("pidfd_send_signal");  /* ESRCH here means it already exited */

    close(pfd);
    return 0;
}

There is still a window between you learning the number 12345 and calling pidfd_open on it; pidfd_open can’t know which process you meant. It closes every window after that. If you created the process, don’t use pidfd_open at all; use CLONE_PIDFD, below.

Flags: 0 gets you the normal behaviour. PIDFD_NONBLOCK (5.10) makes waitid(2) on the fd return EAGAIN instead of blocking if the process hasn’t terminated. PIDFD_THREAD (6.9) gets you a fd for a specific thread instead of the thread-group leader. Anything else is EINVAL.

pidfd_open itself does no permission check worth mentioning; its error list has no EPERM. The permission checks happen when you use the fd: pidfd_send_signal wants the same rights kill(2) does, pidfd_getfd wants ptrace rights.

Note the glibc situation: glibc 2.36 added pidfd_open() and pidfd_send_signal() wrappers, but the man pages still tell you to go through syscall(2), and so does anything that has to build on an older distro. SYS_pidfd_open is in <sys/syscall.h> on any toolchain whose kernel headers are ≥ 5.3; if yours predate that, it’s 434 on x86-64 (check arch/x86/entry/syscalls/syscall_64.tbl for other archs).

pidfd_send_signal: kill(2) that can’t miss

pidfd_send_signal(pidfd, sig, info, flags) is kill(2) with a fd instead of a number. Pass NULL for info and the kernel fills in what kill would: si_signo is the signal, si_code is SI_USER, si_pid and si_uid are yours. Fill in your own siginfo_t if you want to send a specific si_code/si_value, same rules as rt_sigqueueinfo(2).

int ret = syscall(SYS_pidfd_send_signal, pidfd, SIGTERM, NULL, 0);
if (ret < 0) {
    if (errno == ESRCH) {
        /* the process this fd refers to is gone. Not "maybe recycled". Gone. */
    } else {
        perror("pidfd_send_signal");  /* EPERM, EINVAL, EBADF */
    }
}

ESRCH here is unambiguous. With kill(2), ESRCH means “nobody has that number right now” and success means “somebody has that number,” which is not the same as “your process is alive.” With a pidfd, success means your process got the signal, and ESRCH means your process is dead. That’s also the replacement for the kill(pid, 0) liveness probe: pidfd_send_signal(pidfd, 0, NULL, 0).

flags was 0-only until 6.9, which added PIDFD_SIGNAL_THREAD, PIDFD_SIGNAL_THREAD_GROUP, and PIDFD_SIGNAL_PROCESS_GROUP to pick the delivery scope. You also have to be in the target’s PID namespace or an ancestor of it, or you get EINVAL. More on namespaces below.

CLONE_PIDFD: get the fd at fork time, before there’s ever a race

The cleanest usage doesn’t use pidfd_open at all. Pass CLONE_PIDFD to clone3() and the kernel hands you the pidfd atomically with the fork:

#define _GNU_SOURCE
#include <linux/sched.h>   /* struct clone_args, CLONE_PIDFD */
#include <sched.h>
#include <signal.h>
#include <stdint.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>

static long clone3(struct clone_args *args, size_t size) {
    return syscall(SYS_clone3, args, size);
}

int main(void) {
    int child_pidfd = -1;

    struct clone_args args = {
        .flags     = CLONE_PIDFD,
        .pidfd     = (uint64_t)(uintptr_t)&child_pidfd,
        .exit_signal = SIGCHLD,
    };

    long pid = clone3(&args, sizeof(args));
    if (pid < 0) {
        perror("clone3");
        return 1;
    }

    if (pid == 0) {
        /* child: do something */
        sleep(2);
        _exit(0);
    }

    /* parent: child_pidfd is valid immediately, no window for PID reuse */
    printf("child PID %ld, pidfd %d\n", pid, child_pidfd);

    /* Wait via the fd, not the PID number */
    siginfo_t info = {};
    int r = waitid(P_PIDFD, (id_t)child_pidfd, &info, WEXITED);
    if (r == 0)
        printf("child exited with status %d\n", info.si_status);

    close(child_pidfd);
    return 0;
}

clone3() (5.3) is the replacement for the original clone(); it takes a struct instead of a stack of flags arguments, which is how they keep adding features without breaking the existing ABI. The pidfd field in clone_args is a uint64_t containing the address where the kernel should write the new fd. Yes, that’s an out-pointer smuggled through a 64-bit integer. It works. glibc has no clone3() wrapper, hence the syscall(); SYS_clone3 is 435 on x86-64 if your headers are old.

CLONE_PIDFD actually predates clone3() by one release: on plain clone(2) (5.2) the pidfd lands wherever the parent_tid argument points, which is why you can’t combine it with CLONE_PARENT_SETTID there. Either way the new fd has close-on-exec set, so your children don’t inherit handles on their siblings. Since 6.9, CLONE_PIDFD | CLONE_THREAD gets you a fd for the specific thread.

waitid(P_PIDFD): reaping by handle

siginfo_t info = {0};
waitid(P_PIDFD, (id_t)pidfd, &info, WEXITED);

info.si_code is CLD_EXITED, CLD_KILLED, or CLD_DUMPED; info.si_status is the exit code or the signal number depending on which. WNOHANG works as with waitpid: returns 0 immediately if the child hasn’t exited, with si_pid zeroed. On a PIDFD_NONBLOCK fd you get EAGAIN instead of a block.

The catch nobody reads: P_PIDFD waits for a child. It’s still waitid(2), and it still only reaps your own children. A pidfd from pidfd_open on some unrelated process lets you signal it and poll it for exit, but waitid on it gets you ECHILD. Reaping is the parent’s job, same as it was in 1979.

Polling: epoll on a process exit, full working example

This is the part that makes SIGCHLD handlers obsolete for most use cases. A pidfd becomes readable (EPOLLIN) when the process exits, and reports EPOLLHUP once it’s been reaped. Without PIDFD_THREAD, “exits” means the whole thread group is gone, not just the leader. Drop it in your epoll set alongside your network fds and your event loop handles process lifecycle the same way it handles everything else.

The whole lifecycle, end to end, no signal handler in sight:

#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 <stdint.h>
#include <stdio.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);

    /* ... add your sockets, timerfds, signalfds here ... */

    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;
}

gcc -o pidfd_demo pidfd_demo.c && ./pidfd_demo. The parent blocks in epoll_wait, the child sleeps two seconds and exits 42, the parent wakes up and reaps it. Scale that to a few hundred children and you have the core of a process supervisor.

The epoll approach replaces the old pattern of a SIGCHLD handler, the self-pipe trick, SA_RESTART juggling, and waitpid(-1, WNOHANG) in a loop followed by “now which of my children was that?” Signal delivery to your handler is async and affects global state. An fd in epoll is just data. The fd version is composable; the signal version is not.

pidfd_getfd: the container runtime feature

pidfd_getfd(pidfd, targetfd, 0) returns a duplicate of file descriptor targetfd from the process referenced by pidfd. It lands in your process’s fd table, close-on-exec set, sharing the same open file description (offset, status flags, the lot) as the original. flags must be 0. The permission check is PTRACE_MODE_ATTACH_REALCREDS: if you couldn’t attach a debugger to the target, you can’t do this either, and Yama’s ptrace_scope applies. That’s the right model, since you’re reaching into another process’s guts.

Container tooling uses this to pull fds across the container boundary without going through /proc/<pid>/fd/<n> symlinks. The /proc path has a TOCTOU window: the PID could get recycled between when you resolved the symlink and when you opened it. pidfd_getfd has no such window because you’re going through a stable handle. It’s also cleaner than SCM_RIGHTS over a Unix socket, which needs the target’s cooperation and a channel set up in advance. Debuggers and checkpoint/restore tools like CRIU are the other customers.

/* Assuming you have pidfd for a container process and want its fd 3 */
int stolen = syscall(SYS_pidfd_getfd, container_pidfd, 3, 0);

Combine it with CLONE_PIDFD and you get fd-passing with no plumbing: spawn the child, let it open the thing you need, pull it out. Niche, but when you need it there is no cleaner way. The same pidfd is also accepted by setns(2) for entering the process’s namespaces and by process_madvise(2) for pushing memory hints at it, which tells you where Linux is going with this: the pidfd is becoming the process handle, and the number is becoming a label.

What it looks like in /proc

A pidfd shows up in /proc/<pid>/fd/ like any other descriptor, as an anonymous inode:

ls -l /proc/$$/fd/
# lrwxrwxrwx 1 rod rod 64 ... 7 -> anon_inode:[pidfd]

The interesting part is fdinfo, which tells you which process the fd actually refers to:

cat /proc/$$/fdinfo/7
# pos:    0
# flags:  02000002
# mnt_id: 15
# ino:    1057
# Pid:    1234
# NSpid:  1234

Pid: is the target’s PID as seen from the namespace of whoever’s reading. NSpid: lists it in every PID namespace it’s a member of, outermost on the left, successively nested namespaces to the right, same ordering as /proc/<pid>/status. For a process inside a container you’ll see something like NSpid: 48213 1. That’s how you debug “what the hell is fd 7 holding” in somebody else’s supervisor, instead of guessing from ls -l output.

Across PID namespaces

The pidfd tracks the process, not the number, and numbers are per-namespace anyway. The container’s init is PID 1 inside its namespace and something like 48213 outside. pidfd_open takes a PID as you see it, so from the host you open the container’s init by its host PID; from inside a container you can’t name processes in the parent namespace at all, because they don’t exist from your point of view.

Using the fd has the same shape: pidfd_send_signal requires you to be in the target’s PID namespace or an ancestor of it, otherwise EINVAL. Parents can reach down, children can’t reach up, which is the only sane rule.

For a supervisor that creates its own namespaces, CLONE_NEWPID | CLONE_PIDFD together is the clean path: the supervisor gets a handle on the new namespace’s init at creation time and never has to translate between the two number spaces to control it.

FreeBSD got there first

FreeBSD 9.0, released in 2012, shipped process descriptors with Capsicum: pdfork(2), pdgetpid(2), and pdkill(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’s a novel idea.

pdfork(int *fdp, int pdflags) is fork(2) that also hands back a process descriptor; pdkill signals through it; pdgetpid gets you the number for logging. For the wait side, older man pages documented a pdwait4() that never actually got implemented; FreeBSD 15.1 finally added pdwait() (and pdrfork()). Until then you polled the descriptor and reaped the old way.

The semantics differ in ways that matter if you write for both:

  • Closing the last descriptor kills the process. On FreeBSD, if the child is alive when the last reference to its descriptor goes away, it gets SIGKILL, unless you passed PD_DAEMON. On Linux, closing a pidfd does nothing to the process. FreeBSD’s version is a leash; Linux’s is a pair of binoculars.
  • No SIGCHLD. A pdforked child doesn’t send SIGCHLD to the parent when it dies. Linux sends whatever exit_signal you asked for in clone_args.
  • Events. FreeBSD reports exit as POLLHUP from poll(2)/select(2), or EVFILT_PROCDESC with NOTE_EXIT in kqueue. Linux reports EPOLLIN on exit and EPOLLHUP after reaping.
  • Close-on-exec is opt-in on FreeBSD (PD_CLOEXEC), default on Linux’s CLONE_PIDFD.

Noted without further comment. Mostly.

Checking kernel support

# Kernel version check: 5.3 for pidfd_open/clone3, 5.4 for P_PIDFD, 5.6 for pidfd_getfd
uname -r

# Confirm the syscall exists (SYS_pidfd_open = 434 on x86-64)
python3 -c "
import ctypes, os
libc = ctypes.CDLL(None, use_errno=True)
r = libc.syscall(434, os.getpid(), 0)
print('pidfd_open supported' if r >= 0 else f'error: {ctypes.get_errno()}')
if r >= 0: os.close(r)
"

# See if your glibc has the wrappers (2.36+)
getconf GNU_LIBC_VERSION

Process managers written before 5.3 (s6, runit, daemontools) handle this entirely with PID tracking and SIGCHLD. They’re not wrong; they predated the API. New code in 2026 doesn’t have that excuse.

The practical upshot

Every place in your code that looks like this:

pid_t child = fork();
/* ... time passes ... */
kill(child, sig);    /* WRONG: PID may have been recycled */
waitpid(child, ...); /* WRONG: same problem */

Can be made correct by replacing the fork() call with clone3(..., CLONE_PIDFD, ...) and holding the returned pidfd instead of (or alongside) the PID. The rest of the old toolbox maps over just as directly:

  • kill(pid, 0) to check liveness → pidfd_send_signal(pidfd, 0, NULL, 0), ESRCH when gone, no recycling ambiguity.
  • Polling /proc/<pid> for existence → poll/epoll the pidfd.
  • SIGCHLD handler + waitpid(-1, ...) + “which child was that” → waitid(P_PIDFD, ...) on the fd that just went readable.
  • /proc/<pid>/fd/<n> symlink tricks → pidfd_getfd.

Don’t go refactoring working code for novelty, though. A parent that forks a child and immediately waitpids on it has no race worth fixing: the zombie holds the PID until you reap it, and nobody else has the number. The fd earns its setup cost the moment you hold a PID across any nontrivial time window, hand it to another thread, or let anything other than you do the reaping.

The PID is still useful for logging and /proc access; the fd is what you use for control operations. PIDs for display, pidfds for control. That distinction is the whole point.


Kerrisk covers the pre-pidfd process management machinery exhaustively in The Linux Programming Interface: chapter 26 on monitoring children is the canonical reference for what pidfd replaces and why. Stevens’ APUE has the foundational material on signals and wait semantics that you need to understand before the fd-based API makes sense. If you’re building anything with containers or supervisors and want the full picture of what pidfds unlock in that context, the cgroups v2 post and the network namespaces post cover the surrounding APIs that container runtimes wire together with pidfd. The new mount API post covers detached mount fds, the same race-free-handle idea applied to filesystems, and the statx(2) post is another syscall from the same “we finally got this right” generation.