Holding a process by file descriptor instead of PID: Linux pidfds and PID reuse
kill(PID) can signal the wrong process once a PID is recycled. What pidfd_open, polling for exit, pidfd_send_signal and waitid(P_PIDFD) do and do not guarantee, per the man pages.
A supervisor remembers a worker’s PID and, some time later, sends kill(pid, SIGTERM). If the worker had already finished, something had already reaped it, and the kernel had handed the same number to a new process, the signal lands on a process that has nothing to do with it. A PID names “whichever process has this number right now”, not the process you originally meant.
Linux PID file descriptors (pidfds) are the answer to that problem. Using the pidfd_open(2), pidfd_send_signal(2) and waitid(2) man pages, this post separates what a pidfd pins down from what it still leaves open.
The problem: a PID is a number that gets reused
The NOTES section of pidfd_send_signal(2) puts it this way. Traditional interfaces such as kill(2) name the target by PID, so if the intended target has terminated and its PID has been recycled for another process, the sender may accidentally signal the wrong process. A PID file descriptor, by contrast, is a stable reference to a specific process; if that process terminates, pidfd_send_signal() fails with ESRCH.
One point worth making first: for your own children, there is a defined moment when a PID can be recycled. Per waitid(2), a child that has terminated but not been waited for remains a zombie, and the kernel keeps a minimal set of information about it (PID, termination status, resource usage) so the parent can wait for it later. Until the zombie is removed by a wait, its PID does not go to anyone else, so sending kill() to your own not-yet-reaped child is not dangerous in itself.
The danger appears in cases like these:
- Programs where another thread or an asynchronous signal handler might reap the child first.
- Programs that explicitly set
SIGCHLDtoSIG_IGNor useSA_NOCLDWAIT, so children never stay around as zombies. - Processes that are not your children. You have no control over when they are reaped.
pidfd_open: getting a file descriptor from a PID
#include <sys/syscall.h> /* SYS_* constants */
#include <unistd.h>
int syscall(SYS_pidfd_open, pid_t pid, unsigned int flags);
pidfd_open() creates a file descriptor that refers to the task referenced by pid and returns it; the close-on-exec flag is set on it. This edition of the man page (man-pages 6.19) says glibc provides no wrapper, so you call it through syscall(2). It arrived in Linux 5.3, and its only standard is “Linux”: this is not a portable API.
flags is either 0 or contains:
PIDFD_NONBLOCK(since Linux 5.10): return a nonblocking file descriptor. If the task it refers to has not yet terminated, an attempt to wait on it withwaitid(2)returns the errorEAGAINimmediately instead of blocking.PIDFD_THREAD(since Linux 6.9): create a descriptor that refers to a specific thread rather than a process (thread-group leader). Without this flag,pidmust refer to a process.
The documented errors are invalid flags or pid (EINVAL), descriptor limits (EMFILE, ENFILE), a kernel without the anonymous inode filesystem (ENODEV), insufficient memory (ENOMEM), and no process with that PID (ESRCH).
The race only goes away once you hold the descriptor
pidfd_open() still takes a PID, so it refers to whichever task has that number at the moment of the call. If the PID you saved has already been recycled, pidfd_open() succeeds and hands you a pidfd for the wrong process. A pidfd guarantees that it keeps referring to the same process from the moment you obtain it; it does not check that the PID in your hand is still the process you meant.
The NOTES in pidfd_open(2) spell out one case where that window is safe: the parent calling pidfd_open() on the child’s PID right after fork(2). Even if the child has already terminated by then, its PID will not have been recycled and the descriptor refers to the resulting zombie. That is guaranteed only if all of these hold:
- the disposition of
SIGCHLDhas not been explicitly set toSIG_IGN; - the
SA_NOCLDWAITflag was not specified while establishing aSIGCHLDhandler or setting its disposition toSIG_DFL; and - the zombie was not reaped elsewhere in the program (an asynchronously executed signal handler,
wait(2)in another thread, and so on).
If any of these does not hold, the man page says to create the child together with its pidfd using clone(2) with the CLONE_PIDFD flag. Then there is no step that goes through a PID at all.
There is no such guarantee for processes that are not your children. If you got a PID some other way (a PID file, say) and pass it to pidfd_open(), the race at that moment is still there.
Detecting exit: poll, select, epoll
A pidfd can be monitored with poll(2), select(2) and epoll(7). Per pidfd_open(2), when the task terminates and becomes a zombie the descriptor is reported readable (EPOLLIN), and when the task is reaped there is a hangup event (EPOLLHUP).
Some details:
- “Readable” does not mean there is anything to read. In the current implementation
read(2)on the descriptor fails withEINVAL. To get the exit status you usewaitid(), below. - A descriptor obtained without
PIDFD_THREADbecomes readable only when the last thread in the thread group exits. - A descriptor obtained with
PIDFD_THREADbecomes readable when that thread exits and becomes a zombie, even if the thread group is not empty.
This monitoring also works for processes that are not your children. The example program in pidfd_open(2) does exactly that: it calls pidfd_open() on an arbitrary PID from the command line and waits for POLLIN with poll(). The practical benefit is learning “that process has ended” inside an event loop without a SIGCHLD handler.
Getting the exit status: waitid(P_PIDFD)
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
Since Linux 5.4, waitid(2) accepts P_PIDFD as idtype and waits for the child referred to by the PID file descriptor passed in id. The key word is child. pidfd_open(2) likewise says a pidfd can be waited on with waitid(2) if it refers to a child of the calling process. waitid(2) fails with ECHILD when the specified process does not exist or is not a child of the caller, and that can happen even for your own child if SIGCHLD is set to SIG_IGN.
In short:
| Target | Exit detection with poll | waitid(P_PIDFD) |
|---|---|---|
| Child of the caller | Yes | Yes: returns the status and reaps the zombie |
| Not a child | Yes | ECHILD |
None of the three man pages I checked describe a way to get a non-child’s exit status through a pidfd. It is safer to assume you can learn that it ended, and nothing more.
In options you put one or more of the state changes to wait for, WEXITED (terminated), WSTOPPED (stopped) and WCONTINUED (resumed), plus optionally:
WNOHANG: do not block if no child is waitable.WNOWAIT: leave the child in a waitable state, so a later wait can retrieve the same information again.
On success infop has si_pid, si_uid, si_signo (always SIGCHLD), si_status and si_code filled in. With si_code of CLD_EXITED, si_status is the exit status; with CLD_KILLED or CLD_DUMPED it is the signal that ended the child. POSIX.1-2008 requires infop to point to a siginfo_t. Linux succeeds even with NULL, but the man page says not to rely on that nonstandard behaviour.
Note that this signature is the glibc and POSIX interface; the raw system call takes a fifth struct rusage * argument. When a waited-for child’s CPU time is added to the parent’s totals is covered in the real, user and sys post.
PIDFD_NONBLOCK is not WNOHANG
Both look like “don’t wait”, but they report the result differently.
| Applies to | When the process hasn’t finished | |
|---|---|---|
PIDFD_NONBLOCK |
The file descriptor (flags of pidfd_open()) |
waitid() returns -1 with errno set to EAGAIN |
WNOHANG |
That one waitid() call (options) |
waitid() returns 0 |
With WNOHANG, 0 is also the success value. If 0 came back because no child was waitable, the contents of the siginfo_t depend on the implementation. waitid(2) says that to distinguish this case portably, zero si_pid before the call and check for a nonzero value afterwards. Linux follows the POSIX.1-2008 TC1 requirement and zeroes si_pid and si_signo, but not every implementation does.
PIDFD_NONBLOCK is a property of the descriptor, so it applies to every waitid() on that descriptor, and it reports through failure, so you have to check errno. The documented conditions are also worded differently: EAGAIN is for “the process has not terminated”, while WNOHANG’s 0 is for “no children in a waitable state”.
pidfd_send_signal: sending a signal
#include <linux/signal.h> /* SIG* constants */
#include <signal.h> /* SI_* constants */
#include <sys/syscall.h> /* SYS_* constants */
#include <unistd.h>
int syscall(SYS_pidfd_send_signal, int pidfd, int sig,
siginfo_t *_Nullable info, unsigned int flags);
This sends sig to the process referred to by pidfd. It arrived in Linux 5.1, and per the man page it too has no glibc wrapper. With info set to NULL, it behaves as if the siginfo_t held the values kill(2) supplies implicitly (si_code of SI_USER, si_pid of the caller’s PID, si_uid of the caller’s real UID). If you fill in info yourself, follow the rules in rt_sigqueueinfo(2).
With flags at the default 0, the scope is inferred from pidfd; a pidfd from PIDFD_THREAD sends to that thread. Since Linux 6.9 you can state the scope with PIDFD_SIGNAL_THREAD, PIDFD_SIGNAL_THREAD_GROUP or PIDFD_SIGNAL_PROCESS_GROUP.
The man page words ESRCH slightly differently in two places. NOTES says that if the process terminates, the call fails with ESRCH; ERRORS describes ESRCH as “the target process does not exist (i.e., it has terminated and been waited on)”. Either way the point is the same: once the original process is gone, the signal does not move on to some other process that got the same PID. Exactly what happens when you signal a zombie that hasn’t been reaped yet is something I would not pin down from these pages alone.
Besides pidfd_open(), you can get a pidfd by opening a /proc/<pid> directory, or from clone(2)/clone3(2) with CLONE_PIDFD. Per pidfd_open(2), though, a descriptor from /proc/<pid> requires the proc filesystem to be mounted, is not pollable, and cannot be waited on with waitid(2). It does work for sending signals.
What a pidfd does not solve
A pidfd addresses the reference problem: which process. These remain:
- Permission. Holding a pidfd does not grant permission to signal. If the caller lacks permission to signal the target,
pidfd_send_signal()fails withEPERM. It is alsoEPERMwhen the pidfd doesn’t refer to the caller andinfo.si_codeis invalid. - PID namespaces. The caller must be in the same PID namespace as the target or in an ancestor of it; otherwise it is
EINVAL. A pidfd does not make processes visible across namespace boundaries. - The race up to the point you hold the PID. As above, calling
pidfd_open()with a stale PID is still racy. For children, get the pidfd right afterfork()(when the conditions hold) or from the start withCLONE_PIDFD. - A non-child’s exit status. You can learn when it ended, but
waitid(P_PIDFD)givesECHILD. - Every other TOCTOU. What a pidfd pins down is one thing: the process reference. It does not remove other check-then-use races, such as what that process is doing or whether its state changed after you checked.
- Portability. All three calls list only “Linux” as their standard (
waitid()itself is POSIX, butP_PIDFDis a Linux extension).
pidfds are also used to duplicate another process’s file descriptor with pidfd_getfd(2), to join the target’s namespaces with setns(2), and to give memory-usage advice with process_madvise(2). This post does not cover those.
Example: watch a child, and terminate it after a timeout
The program creates one child, waits up to 2 seconds for it to exit, sends SIGTERM through the pidfd if it is still alive, and finally collects the exit status with waitid(P_PIDFD). The signal only ever goes to the child it created.
#define _GNU_SOURCE
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int pidfd_open(pid_t pid, unsigned int flags)
{
return syscall(SYS_pidfd_open, pid, flags);
}
static int pidfd_send_signal(int pidfd, int sig, siginfo_t *info,
unsigned int flags)
{
return syscall(SYS_pidfd_send_signal, pidfd, sig, info, flags);
}
int main(void)
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
sleep(30); /* stand-in for long-running work */
_exit(0);
}
/* Default SIGCHLD handling and nobody has waited yet: the PID has not been recycled */
int pidfd = pidfd_open(pid, 0);
if (pidfd == -1) {
perror("pidfd_open");
return 1;
}
struct pollfd pfd = { .fd = pidfd, .events = POLLIN };
int ready = poll(&pfd, 1, 2000);
if (ready == -1) {
perror("poll");
return 1;
}
if (ready == 0 && pidfd_send_signal(pidfd, SIGTERM, NULL, 0) == -1)
perror("pidfd_send_signal");
siginfo_t info;
memset(&info, 0, sizeof(info));
if (waitid(P_PIDFD, (id_t) pidfd, &info, WEXITED) == -1) {
perror("waitid");
return 1;
}
if (info.si_code == CLD_EXITED)
printf("exited, status=%d\n", info.si_status);
else if (info.si_code == CLD_KILLED || info.si_code == CLD_DUMPED)
printf("killed by signal %d\n", info.si_status);
close(pidfd);
return 0;
}
A few notes:
- You need kernel 5.4 or later and headers that define
SYS_pidfd_open,SYS_pidfd_send_signalandP_PIDFD. If your headers lack them, updating the headers is safer than hard-coding numbers. I wrote this on a non-Linux machine and did not build or run the code. - The child might exit on its own right after
poll()returns 0. Because it hasn’t been waited for yet, the signal still cannot go to another process. Whetherpidfd_send_signal()then succeeds or givesESRCHis, as said above, not something I pin down; the example just prints a message on failure and moves on. PIDFD_NONBLOCKis not used. The program confirms exit withpoll()before a blockingwaitid(), so it isn’t needed. To check without blocking in an event loop, pick eitherPIDFD_NONBLOCK(EAGAIN) orWNOHANG(returns 0, checksi_pid) and handle the result according to the difference above.- Referring to the target through an open descriptor instead of a changeable name (the PID) mirrors the relationship between file names and descriptors in the post on deleted files and disk space. A pidfd does not prevent the process from exiting or being reaped, though; once it is reaped you just get
EPOLLHUP. Close pidfds when you are done.
Scope and limits
- I checked the Linux man-pages 6.19 HTML (page date 2026-02-08) published on man7.org on 2026-10-27. Man pages installed by your distribution, and your glibc and kernel versions, may differ. In particular, “no glibc wrapper” reflects this edition of the man pages.
- Versions given in the pages:
pidfd_send_signalLinux 5.1,pidfd_openLinux 5.3,P_PIDFDforwaitidLinux 5.4,PIDFD_NONBLOCKLinux 5.10,PIDFD_THREADandPIDFD_SIGNAL_*Linux 6.9. - I did not check the man pages for
CLONE_PIDFDinclone(2)/clone3(2),pidfd_getfd(2),setns(2)orprocess_madvise(2)this time, so they are only named here. - No performance figures or measured output are given.
Sources
Checked on 2026-10-27.