A supervisor reads PID 18427, verifies the command line, and prepares to send SIGTERM. Between those operations, the old process exits and the kernel assigns PID 18427 to an unrelated process. kill(18427, SIGTERM) now targets the wrong task. This is the classic race caused by treating a numeric PID as a stable identity.
Linux pidfds solve the problem with a file descriptor referring to one specific task. They can be monitored with poll/epoll, signaled with pidfd_send_signal(), and used to wait for a child with waitid(P_PIDFD) without resolving a recycled PID again. This guide follows Linux man-pages for pidfd_open(2), pidfd_send_signal(2), waitid(2), and the kernel's procfs documentation.
1. A PID is a reusable name
A PID exists within a PID namespace and the kernel reuses it after a process terminates. This sequence contains a time-of-check/time-of-use race:
read_pid_from_file();
verify_proc_cmdline(pid);
kill(pid, SIGTERM);
Between verification and kill, the target may exit. A new process that receives the PID can be signaled if permissions allow. Similar races appear when:
- polling
/proc/<pid>for exit; - reading status before a namespace operation;
- attaching worker timeouts to numeric PIDs;
- watching thousands of children through signal handlers;
- passing PIDs across delayed components.
Opening /proc/<pid> is not a full pidfd substitute. Kernel documentation notes that a procfs descriptor does not prevent numeric PID reuse and lacks pidfd's poll and wait semantics.
2. What is a pidfd?
A PID file descriptor is a kernel file descriptor referring to a specific task. Once obtained, target exit does not make the descriptor point to a new process that inherits the same number. Operations through the pidfd retain identity.
Key API milestones are:
| Kernel | Capability |
|---|---|
| Linux 5.1 | pidfd_send_signal() |
| Linux 5.3 | pidfd_open() and pollable pidfds |
| Linux 5.4 | waitid(P_PIDFD, ...) |
| Linux 5.10 | PIDFD_NONBLOCK |
| Linux 6.9 | PIDFD_THREAD and thread/group signal scopes |
Applications must detect runtime support and handle ENOSYS or EINVAL. Pidfds are Linux-specific, not portable POSIX APIs.
3. Obtain a handle for an existing process
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
static int pidfd_open_sys(pid_t pid, unsigned int flags)
{
return syscall(SYS_pidfd_open, pid, flags);
}
int pidfd = pidfd_open_sys(target_pid, 0);
if (pidfd == -1) {
perror("pidfd_open");
/* ESRCH: no matching process at system-call time */
}
pidfd_open() resolves the PID and obtains a reference in one system call, eliminating identity races after the handle is opened. The descriptor is close-on-exec.
Pidfd cannot repair a mistake made before the call. If the PID came from a stale file or untrusted API and was already reused, pidfd_open() stabilizes the current owner—which may not be the intended target. A supervisor still needs a trusted source or checks for expected UID, cgroup, executable, and build identity.
4. Create a child with its pidfd: prefer clone3(CLONE_PIDFD)
For a process created by the supervisor, obtain the pidfd atomically during creation with clone() or clone3() and CLONE_PIDFD. This removes the gap between process creation and handle acquisition.
fork() followed by pidfd_open(child_pid) can work when zombie retention conditions hold: SIGCHLD is not ignored, SA_NOCLDWAIT is absent, and no other thread or handler has reaped the child. Otherwise the child may disappear before the handle is acquired.
Design rule: for a process you create, obtain its pidfd at creation; for an existing process, use pidfd_open(); never preserve a numeric PID for a long time and assume it still denotes one process.
5. Monitor process exit with poll or epoll
A pidfd fits an event loop like a socket or pipe:
#include <poll.h>
struct pollfd item = {
.fd = pidfd,
.events = POLLIN,
};
int rc = poll(&item, 1, timeout_ms);
if (rc > 0 && (item.revents & POLLIN)) {
/* target terminated and became a zombie */
}
For a process-level pidfd without PIDFD_THREAD, the descriptor becomes readable after the last thread exits. After reaping, poll or epoll may also report hangup. read(pidfd, ...) does not return event data; current implementations fail with EINVAL. A pidfd is a pollable handle and syscall argument, not a readable stream.
For thousands of workers, putting every pidfd in one epoll instance lets a supervisor handle lifecycle in the same event loop without scanning procfs or maintaining a race-prone PID map.
6. Signal the correct process with pidfd_send_signal
static int pidfd_send_signal_sys(
int pidfd,
int sig,
siginfo_t *info,
unsigned int flags
) {
return syscall(
SYS_pidfd_send_signal,
pidfd,
sig,
info,
flags
);
}
if (pidfd_send_signal_sys(pidfd, SIGTERM, NULL, 0) == -1) {
if (errno == ESRCH) {
/* task exited or was reaped; no unrelated task was signaled */
} else {
perror("pidfd_send_signal");
}
}
Pidfd preserves identity but does not bypass permission checks. The caller still needs signal permission under credentials, namespaces, and LSM policy. The caller must be in the target's PID namespace or an appropriate ancestor.
A safe shutdown sequence is:
- Send
SIGTERMthrough the pidfd. - Poll the pidfd for a graceful timeout.
- If necessary, send
SIGKILLthrough the same pidfd. - Wait and reap when the target is a child.
- Close the pidfd and remove it from epoll.
Both signals use the same stable reference; unrelated PID reuse does not affect them.
7. Wait for a child with waitid(P_PIDFD)
When the pidfd identifies a child of the caller, retrieve exit information:
#define _GNU_SOURCE
#include <sys/wait.h>
siginfo_t status = {0};
if (waitid(P_PIDFD, pidfd, &status, WEXITED) == -1) {
perror("waitid");
} else {
printf("code=%d status=%d\n", status.si_code, status.si_status);
}
waitid() can reap only a child under the normal parent-child relationship; owning a pidfd does not turn an arbitrary process into a child. WNOWAIT can observe status without reaping, but a later reap step is required to avoid zombies.
If the pidfd was opened with PIDFD_NONBLOCK and the child is still running, waitid() may return EAGAIN instead of blocking. Choose blocking behavior to match the event loop.
8. A supervisor event-loop model
Each worker record should store the pidfd as primary identity and retain the numeric PID only for diagnostics:
struct worker {
int pidfd; /* stable identity */
pid_t pid; /* diagnostics only */
enum worker_state state;
uint64_t generation;
};
The flow is:
- Spawn the worker and obtain its pidfd atomically.
- Register the pidfd with epoll and the record or generation.
- On readability, call
waitid(P_PIDFD, ...). - Record exit cause, apply restart budgets, and release resources.
- Close the pidfd; a restart receives a new record and pidfd.
Never reuse a worker record solely because the numeric PID matches. A separate generation makes telemetry distinguish incarnations of the same logical worker.
9. PID namespaces and containers
A task may have different PIDs from the host and a container. A pidfd references the kernel object instead of a number that must be reinterpreted in each namespace. This helps runtimes and supervisors, but does not remove namespace rules: signaling still checks PID-namespace relationships and permissions.
A file descriptor can be transferred over a UNIX domain socket with SCM_RIGHTS. One component can create a process and pass its pidfd to another supervisor without sending an ambiguous number. The receiver must still authenticate the peer and protocol; an untrusted pidfd does not prove the process has the expected role.
To stop an entire container workload or process tree, one process-level pidfd may be insufficient. Descendants may fork, daemonize, or change process groups. Use cgroups, a systemd scope, or the container runtime for group lifecycle; use pidfds for exact individual-process identity.
10. Thread pidfds and signal scope
Since Linux 6.9, PIDFD_THREAD allows pidfd_open() to identify a particular thread instead of the thread-group leader. New signal flags distinguish a thread, thread group, and process group.
Do not enable these flags merely because build headers define them. An older runtime kernel may return EINVAL. Libraries should feature-detect, keep a clear fallback, and test multithreaded semantics, especially when one thread exits while the process remains alive.
11. Advanced APIs that accept pidfds
A pidfd can also be used with:
pidfd_getfd()to duplicate a target descriptor, subject to ptrace-style and LSM checks;setns()to join namespaces of the referenced process when permitted;process_madvise()to provide memory-use advice for the target;- event-loop and service-manager libraries with direct pidfd support.
These are powerful APIs and remain permission-checked. A pidfd is not a privilege bypass; service permissions, seccomp, LSM policy, and auditing remain necessary.
12. A pidfd does not authenticate a process
A pidfd answers: “will later operations target the same task whose handle was opened?” It does not answer: “is this task an authorized workload?”
For a process outside the supervisor, authentication may combine:
- a trusted, fresh source for the PID;
- expected UID and GID;
- cgroup, systemd unit, or container identity;
- executable path, inode, or build identity;
- an application-level handshake;
- peer credentials when receiving a pidfd over a socket.
Gather metadata immediately after opening the pidfd and fail closed if identity does not match. Avoid “read any PID file and signal it.”
13. Resource lifecycle and descriptor limits
Every pidfd consumes a file descriptor and kernel resources. A large supervisor must:
- set an appropriate
RLIMIT_NOFILE; - close pidfds when lifecycle work is complete;
- remove descriptors from epoll during safe cleanup;
- avoid descriptor leaks across exec;
- measure open pidfds and
EMFILE/ENFILEfailures; - rate-limit restarts to avoid resource churn.
pidfd_open() sets close-on-exec automatically, but duplicated descriptors or descriptors received over IPC still require auditing.
14. Fallback on older kernels
No fallback on pre-pidfd Linux is fully equivalent. Risk can be reduced by:
- having the direct parent use
waitpid()for its children; - placing workloads in a cgroup or systemd unit and controlling them through the manager;
- checking PID plus start time and identity close to the action;
- minimizing delay between lookup and action;
- failing closed for dangerous operations when identity cannot be established.
Checking /proc/<pid>/stat before kill() still leaves a race between check and signal. Never present it as equivalent to pidfd.
15. Test the race itself
Ordinary unit tests rarely force PID reuse. Integration tests should:
- Spawn a target, obtain a pidfd, and verify it is not readable.
- Exit the target and verify the pidfd becomes readable.
- Create process churn in an isolated PID namespace.
- Verify signaling through the old pidfd fails and never touches a new process.
- Test graceful timeout, SIGKILL escalation, and wait/reap.
- Test worker exec into another binary while the handle follows the same task.
- Test missing system calls or flags and the defined fallback.
- Test descriptor exhaustion and epoll cleanup.
Do not lower pid_max or create PID churn in production. Use a VM, PID namespace, or dedicated runner with resource limits.
16. Diagnose common errors
| Error | Typical meaning | Response |
|---|---|---|
ESRCH from pidfd_open | No matching PID at system-call time | Treat the target as gone; do not retry blindly with the stale PID. |
ESRCH while signaling | Task exited or was reaped | Finish lifecycle; do not resolve the PID again. |
EPERM | Credentials, namespace, or LSM denied access | Correct the permission model; pidfd does not bypass it. |
EINVAL with a flag | Kernel lacks the requested semantics | Feature-detect and use a controlled fallback. |
ECHILD from waitid | Target is not a waitable child | Use poll for observation; reap only children. |
EMFILE/ENFILE | Per-process or system-wide descriptor exhaustion | Find leaks, adjust limits, and control restart storms. |
17. Production checklist
- Use pidfd as the operational identity and PID only for logs.
- For new processes, prefer
clone3(CLONE_PIDFD). - For existing processes, call
pidfd_open()and authenticate identity. - Monitor exit with poll or epoll instead of scanning procfs.
- Signal with
pidfd_send_signal(). - For children, obtain status and reap with
waitid(P_PIDFD). - Use cgroups or a service manager for whole process trees.
- Feature-detect the kernel and define fallback or fail-closed behavior.
- Budget descriptors, close handles, and monitor leaks.
- Test PID churn in an isolated environment.
Conclusion
A numeric PID is convenient for display but is not a stable handle for asynchronous operations. Pidfd brings process lifecycle into Linux's familiar file-descriptor model: identity remains fixed, the handle is pollable, signals reach the correct target, and event loops integrate naturally. A modern supervisor should acquire a pidfd as early as possible, use it throughout the lifecycle, and retain the PID only for diagnostics. This removes an entire class of races that “check one more time before kill” cannot eliminate.




No comments yet. Be the first to share your thoughts.