$RodHat_
Console Tips

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

Published by

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. 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. 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 a process that was doing something important.

This is not theoretical. It’s been the answer to “why is my process manager occasionally buggy” for decades. The standard advice was “don’t hold PIDs for long” and “use SIGCHLD immediately.” That advice is correct and also a paperclip solution to a problem that should have been a design decision.

Linux 5.3 made the design decision. The answer is pidfd.

The concept: a reference, not a number

A pidfd is a file descriptor that the kernel ties to a specific struct task_struct. File descriptors are not recycled. They hold a reference. As long as you hold the fd open, the kernel keeps the task alive in the table, in zombie state if the process has exited, but the slot doesn’t get repurposed. When you signal or wait via the fd, the kernel checks the actual task struct, not a PID lookup.

Three syscalls make up the API:

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

Plus two additions to existing syscalls:

  • CLONE_PIDFD flag to clone(2) / clone3(2): get a pidfd for the child at fork time (5.2+)
  • P_PIDFD selector for waitid(2): wait on a process by fd, not PID (5.4+)

And the bonus: pidfds are pollable with poll(2) / 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>

/* pidfd_open is newer than most libc wrappers, so call it directly */
#include <sys/syscall.h>
#include <linux/types.h>

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 task_struct. 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 exited cleanly */

    close(pfd);
    return 0;
}

Note the glibc situation: pidfd_open() and pidfd_send_signal() got libc wrappers in glibc 2.36. If you’re on an older system, use syscall(SYS_pidfd_open, ...) directly. The SYS_pidfd_open constant is in <sys/syscall.h> on kernels ≥ 5.3; if your kernel headers predate that, you’ll need #define SYS_pidfd_open 434 on x86-64 (check arch/x86/entry/syscalls/syscall_64.tbl for other archs).

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 <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() is the replacement for the original clone(); it takes a struct instead of a stack of flags arguments, which is how they added CLONE_PIDFD 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 inside a struct passed by value. It works.

Polling: epoll on a process exit

This is the part that makes SIGCHLD handlers obsolete for most use cases. A pidfd becomes readable (POLLIN) when the process exits. Drop it in your epoll set alongside your network fds and your event loop handles process lifecycle the same way it handles everything else:

#include <sys/epoll.h>

int epfd = epoll_create1(EPOLL_CLOEXEC);

struct epoll_event ev = {
    .events   = EPOLLIN,
    .data.fd  = child_pidfd,
};
epoll_ctl(epfd, EPOLL_CTL_ADD, child_pidfd, &ev);

/* ... add your other fds ... */

struct epoll_event events[16];
int n = epoll_wait(epfd, events, 16, -1);

for (int i = 0; i < n; i++) {
    if (events[i].data.fd == child_pidfd) {
        /* child exited, now call waitid to reap it */
        siginfo_t info = {};
        waitid(P_PIDFD, (id_t)child_pidfd, &info, WEXITED | WNOHANG);
        printf("child exited, status %d\n", info.si_status);
        close(child_pidfd);
    }
}

The epoll approach replaces the old pattern of SIG_DFL SIGCHLD + sigaction + waitpid(-1, WNOHANG) scattered across the codebase. 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, pointing at the same underlying kernel object. This requires CAP_SYS_PTRACE or the target process must have the same credentials.

Container runtimes (runc, containerd, podman) use this to hand off 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 the old SCM_RIGHTS over a Unix socket, which requires the target to cooperate.

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

If you’re doing anything with container management or supervisor code, this is the API. The old approach via /proc/<pid>/fd/ is a workaround for the absence of this.

Checking kernel support

# Kernel version check: you need 5.3 for pidfd_open, 5.4 for P_PIDFD waitid
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 wrapper (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 PID is still useful for logging and /proc access; the fd is what you use for control operations.

The pattern becomes: 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.