A process ID looks like an identity, but it is really a reusable number.
That distinction matters in long-running supervisors, job managers, test harnesses, and other programs that observe a process and then act on it later. Between those two operations, the original process can exit and Linux can eventually reuse the same PID for an unrelated process.
Traditional PID-based code can therefore have a time-of-check/time-of-use race:
check PID 4242 -> original process exits -> PID 4242 is reused -> signal PID 4242Linux PID file descriptors, usually called pidfds, provide another model. Instead of repeatedly identifying a task by a reusable integer, a program obtains a file descriptor that refers to a particular process and can use that descriptor with pidfd-aware APIs.
The useful mental model is:
PID -> reusable numeric name
pidfd -> open handle referring to one process identitypidfds are Linux-specific. pidfd_open() was added in Linux 5.3, and pidfd support in related interfaces has arrived across several kernel releases. When portability to non-Linux systems or older kernels matters, you still need another process-management strategy.
See the race in PID-only coordination
Suppose a supervisor remembers the PID of a worker:
pid_t worker_pid = start_worker();
/* Time passes. */
if (kill(worker_pid, 0) == 0) {
kill(worker_pid, SIGTERM);
}kill(pid, 0) can check whether the caller is permitted to signal a process with that PID. But it does not reserve the PID between the check and the later kill().
If the original worker disappears after the check and the numeric PID is reused, the second call can refer to a different process. Making the interval smaller reduces exposure but does not remove the race.
This is a general rule: two PID-based operations do not become atomic merely because they are adjacent in source code.
Open a pidfd for an existing process
pidfd_open() returns a file descriptor that refers to the task identified by the PID at the time of the call.
At the system-call level, a small wrapper looks like this:
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
static int
pidfd_open_process(pid_t pid)
{
return (int) syscall(SYS_pidfd_open, pid, 0);
}Current Linux man-pages document pidfd_open() through syscall() because glibc does not provide a wrapper for it.
On success, the returned descriptor has close-on-exec set. Like other file descriptors, it consumes an entry from the process’s descriptor table and must eventually be closed.
A minimal program can open a pidfd for itself:
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
static int
pidfd_open_process(pid_t pid)
{
return (int) syscall(SYS_pidfd_open, pid, 0);
}
int
main(void)
{
int pidfd = pidfd_open_process(getpid());
if (pidfd == -1) {
perror("pidfd_open");
return EXIT_FAILURE;
}
printf("pidfd=%d\n", pidfd);
if (close(pidfd) == -1) {
perror("close");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}The descriptor number printed here is not the PID. It is an ordinary descriptor number in the calling process, just as a socket or open file has a descriptor number.
Poll a pidfd to observe process exit
One practical advantage of pidfds is that they participate in file-descriptor readiness APIs.
For a normal process pidfd, poll(), select(), and epoll() report the descriptor as readable when the process terminates and becomes a zombie. After the process is reaped, polling also reports a hangup condition.
That means an event loop can observe process exit alongside sockets, pipes, timers, and other descriptor-driven events.
Here is a small example that forks a child and waits for its pidfd to become readable:
#define _GNU_SOURCE
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int
pidfd_open_process(pid_t pid)
{
return (int) syscall(SYS_pidfd_open, pid, 0);
}
int
main(void)
{
pid_t child = fork();
if (child == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (child == 0) {
_exit(0);
}
int pidfd = pidfd_open_process(child);
if (pidfd == -1) {
perror("pidfd_open");
return EXIT_FAILURE;
}
struct pollfd watched = {
.fd = pidfd,
.events = POLLIN,
};
if (poll(&watched, 1, -1) == -1) {
perror("poll");
close(pidfd);
return EXIT_FAILURE;
}
if ((watched.revents & POLLIN) != 0) {
puts("child exited");
}
if (waitpid(child, NULL, 0) == -1) {
perror("waitpid");
close(pidfd);
return EXIT_FAILURE;
}
close(pidfd);
return EXIT_SUCCESS;
}poll() tells us that the process reached the relevant exit state. It does not replace reaping a child process. The example still calls waitpid() so the parent collects the child’s termination state and releases the zombie.
Also note that a pidfd is pollable, but it is not a byte stream. Reading from a pidfd with read() is not how process status is retrieved.
Signal the process through its pidfd
A pidfd becomes more valuable when later operations also use the stable handle.
pidfd_send_signal() sends a signal to the process referred to by a pidfd. At the raw system-call level:
#include <signal.h>
#include <sys/syscall.h>
#include <unistd.h>
static int
pidfd_send_signal_simple(int pidfd, int signal_number)
{
return (int) syscall(
SYS_pidfd_send_signal,
pidfd,
signal_number,
NULL,
0
);
}A supervisor can therefore obtain a pidfd once and later signal the same process identity through that descriptor:
int pidfd = pidfd_open_process(worker_pid);
if (pidfd == -1) {
/* The process may already be gone, or another error occurred. */
handle_open_error();
}
/* Later: */
if (pidfd_send_signal_simple(pidfd, SIGTERM) == -1) {
handle_signal_error();
}This avoids the specific PID-reuse race that exists when the later operation addresses the target only by numeric PID.
It does not bypass permissions. The kernel still checks whether the caller is allowed to send the requested signal.
Prefer atomic pidfd creation when creating the child yourself
There is an important boundary around pidfd_open(existing_pid).
If another part of the program can reap a newly created child before you call pidfd_open(), opening the pidfd after fork() can itself race with child cleanup. Linux documents conditions under which opening a pidfd for a terminated but unreaped child is safe because the zombie keeps its PID from being recycled.
When those conditions cannot be guaranteed, Linux provides a stronger pattern: create the child and obtain its pidfd as part of the process-creation operation with clone() or clone3() and CLONE_PIDFD.
That removes the gap between “child now exists” and “obtain stable handle for child.”
This distinction is worth keeping explicit:
existing process -> pidfd_open()
new process you control -> consider CLONE_PIDFDUse pidfd_open() when attaching to an already existing process. When building a low-level supervisor that creates its own workers and must avoid lifecycle races, atomic pidfd creation can provide a cleaner ownership boundary.
Wait with waitid() when the pidfd refers to your child
Linux 5.4 added P_PIDFD support to waitid().
If the pidfd refers to a child of the calling process, waitid() can wait for that child using the descriptor rather than looking it up again by PID.
Conceptually:
siginfo_t info;
if (waitid(P_PIDFD, (id_t) pidfd, &info, WEXITED) == -1) {
perror("waitid");
}This is useful when the rest of a supervisor already treats the pidfd as the primary process handle.
Waiting has normal parent-child rules. A pidfd referring to an arbitrary unrelated process does not make that process your child or grant permission to reap it.
Close pidfds when their ownership ends
A pidfd is still a file descriptor, so descriptor-lifetime rules apply.
If a supervisor tracks thousands of processes and forgets to close pidfds, it can run into the same per-process or system-wide descriptor limits that affect sockets and files.
Make ownership visible:
int pidfd = pidfd_open_process(pid);
if (pidfd == -1) {
return -1;
}
int result = supervise(pidfd);
if (close(pidfd) == -1) {
/* Decide whether close failure is relevant to this program. */
}
return result;In larger C programs, a single cleanup path can make descriptor ownership easier to audit. In higher-level languages, use that language’s deterministic file-descriptor or resource wrapper where available.
Do not treat a pidfd as a general capability token
A pidfd gives you a stable reference to a process identity, but each operation still has its own authorization and semantic rules.
For example:
- signaling still requires signal permission;
- waiting still requires the appropriate child relationship;
- duplicating another process’s file descriptor with
pidfd_getfd()has additional permission checks; - namespace and process-management operations have their own constraints.
The pidfd solves identity stability. It does not erase the security model around the operation you perform with that identity.
That separation is important because “I have the pidfd” and “I am authorized to do everything to that process” are not equivalent statements.
Account for kernel-version and portability boundaries
pidfds are a Linux API, not a POSIX process-management abstraction.
Relevant pieces also have different minimum kernel versions:
pidfd_send_signal()appeared beforepidfd_open();pidfd_open()is available starting with Linux 5.3;waitid(P_PIDFD, ...)is available starting with Linux 5.4;- later kernels added additional pidfd flags and capabilities.
If software must run on older kernels, handle ENOSYS or another documented unsupported-path result as appropriate for the specific call and environment. Do not silently assume that successful compilation means the running kernel supports every system call the source references.
For portable software, hide pidfd-specific coordination behind a Linux backend rather than spreading Linux-only assumptions through application logic.
Know when a plain PID is still enough
Not every PID use needs a pidfd.
A short-lived command that starts a child and immediately calls waitpid() in one simple parent can already have clear ownership and no meaningful opportunity for another component to reuse the PID between unrelated operations.
pidfds become especially useful when process identity crosses time or subsystem boundaries:
- an event loop watches many workers;
- a supervisor signals a process long after launch;
- multiple threads coordinate process lifecycle;
- a service attaches to an existing process and keeps a stable handle;
- PID reuse would make a mistake operationally dangerous.
The trade-off is extra Linux-specific code and one file descriptor per live handle.
The practical rule is simple: if your program performs separated operations that must refer to the same process, do not assume a numeric PID remains a unique identity forever. On modern Linux, a pidfd lets you hold that identity as a descriptor, integrate process exit into descriptor-based event loops, and use pidfd-aware operations without reopening the PID-reuse race.