登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

Linux pidfd怎样安全等待进程结束

来源:17golang原创

时间:2026-11-15 19:59:55 170浏览 收藏

在守护进程、任务执行器或容器运行时里,我们经常需要等待“某一个进程”结束。只保存一个 PID 看起来够用,但 PID 会被系统回收后重新分配;如果轮询 /proc/ 或延迟调用 kill(pid, 0),就可能把已经结束的旧进程误认成同一个新进程。

Linux 的 pidfd 把进程绑定成一个文件描述符,之后可以把它交给 poll、epoll 监听生命周期;如果它指向调用者的子进程,还可以用 waitid(P_PIDFD, ...) 读取退出状态。这样,“等待它结束”和“取得它的退出原因”都有稳定的对象可追踪。

最实用的组合是:pidfd_open 获取句柄,poll/epoll 判断进程是否结束,waitid(P_PIDFD) 只在目标是自己的子进程时负责读取并回收退出状态。不要对 pidfd 调用 read,它不是可读的普通文件。

官方参考地址:https://www.man7.org/linux/man-pages/man2/pidfd_open.2.html

先把进程身份从 PID 换成 pidfd

pidfd_open(pid, 0) 的输入仍然是 PID,但返回值是指向该任务的文件描述符。这个描述符在目标进程结束后仍然代表原来的任务,不会因为 PID 号码后来被复用而自动指向另一个进程。对于已经存在的进程,官方手册把 pidfd_open 作为优先获取方式。

这和打开 /proc/pid 目录不是一回事:后者不能用于 poll,也不能交给 waitid。因此,如果程序需要把多个子进程统一放进一个 epoll 集合,pidfd 是更自然的对象。

Linux pidfd 从目标子进程和 PID 连接到 pidfd 文件描述符,再分到 poll epoll 与 waitid 退出状态的结构说明图
图1:pidfd 生命周期契约结构图,展示进程身份、稳定句柄和用户态消费边界;这是静态说明图,不是运行截图。

支持范围决定你能把它用到哪一步

写代码前先确定内核与目标对象的边界:

能力内核起点它解决的问题注意事项
pidfd_openLinux 5.3为已有 PID 获取任务文件描述符进程不存在时返回错误;权限与 PID 命名空间仍然有效
poll/epoll 监视 pidfd随 pidfd 能力提供在任务变成 zombie 时收到可读事件pidfd 本身不能 read;被回收后会出现挂起事件
waitid(P_PIDFD)Linux 5.4按 pidfd 读取子进程的退出状态目标必须是调用者的子进程
PIDFD_NONBLOCKLinux 5.10让针对 pidfd 的等待不阻塞目标尚未结束时要处理 EAGAIN
PIDFD_THREADLinux 6.9让句柄指向线程而非线程组组长线程与整个线程组的可读时机不同

其中最容易误解的是 waitid(P_PIDFD):它不是“所有 pidfd 都能读取退出码”。手册限定了目标必须是调用者的子进程;对于只是观察同一机器上已有的非子进程,通常只能把 pidfd 当作可轮询的生命周期信号。

最小 C 示例:poll 等结束,waitid 取状态

下面的示例创建一个短生命周期子进程。父进程先用 pidfd_open 得到稳定句柄,再用 poll 等待句柄变为可读,最后用 waitid(P_PIDFD) 读取 siginfo_t。代码展示接口关系,不把终端输出当作文章证据。

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

// 某些较旧的头文件没有暴露 P_PIDFD,数值来自 Linux waitid 接口定义。
#ifndef P_PIDFD
#define P_PIDFD 3
#endif

static int open_pidfd(pid_t pid) {
    // glibc 环境可能没有 pidfd_open 包装器,因此直接调用系统调用。
    return (int)syscall(SYS_pidfd_open, pid, 0U);
}

int main(void) {
    pid_t child = fork();
    if (child == -1) {
        perror("fork");
        return EXIT_FAILURE;
    }

    if (child == 0) {
        // 子进程只用于提供一个可等待的生命周期,不读取任何外部输入。
        sleep(1);
        _exit(7);
    }

    int pidfd = open_pidfd(child);
    if (pidfd == -1) {
        perror("pidfd_open");
        return EXIT_FAILURE;
    }

    struct pollfd watched = {
        .fd = pidfd,
        .events = POLLIN, // 进程变成 zombie 后,pidfd 会报告可读事件。
    };
    while (poll(&watched, 1, -1) == -1) {
        // 被信号打断时继续等待;其它错误必须关闭句柄并退出。
        if (errno != EINTR) {
            perror("poll");
            close(pidfd);
            return EXIT_FAILURE;
        }
    }

    siginfo_t info = {0};
    // 目标是自己的子进程,所以可以按 pidfd 读取并回收退出状态。
    if (waitid(P_PIDFD, (id_t)pidfd, &info, WEXITED) == -1) {
        perror("waitid");
        close(pidfd);
        return EXIT_FAILURE;
    }

    if (info.si_code == CLD_EXITED) {
        printf("child exited with status %d\\n", info.si_status);
    } else {
        // 被信号终止时,si_status 表示导致终止的信号编号。
        printf("child changed state, code=%d status=%d\\n",
               info.si_code, info.si_status);
    }

    // waitid 已完成回收,pidfd 自身也要释放。
    close(pidfd);
    return EXIT_SUCCESS;
}

这段代码里有两个不能互换的动作。poll 只负责把“还没结束”变成“可以处理”;它不会把退出码写进 pidfd。waitid 才负责取得 si_status,并且在没有使用 WNOWAIT 时完成子进程回收。

poll 和 waitid 各自回答什么问题

把两个接口混成一个“读取进程结果”的动作,是 pidfd 代码最常见的误区。可以按下面的职责拆开:

接口或事件能回答不能回答
POLLIN/EPOLLIN被监视的任务已经终止并成为 zombie,可以进入状态处理不能直接得到退出码;也不能对 pidfd 使用 read
EPOLLHUP任务已被回收,句柄进入挂起状态不能说明是正常退出还是信号终止
waitid(P_PIDFD, WEXITED)为子进程填写 siginfo_t 并完成回收不能等待不属于调用者的任意进程
waitid(..., WNOWAIT)读取状态但暂时保留子进程的可等待状态不能代替最终的回收动作
pidfd 文件描述符连接 POLLIN EPOLLIN EPOLLHUP 与 waitid WEXITED WNOWAIT 和子进程回收边界的结构说明图
图2:poll、waitid 与回收边界结构图,帮助区分可观测事件和退出状态读取;这是静态说明图,不是运行截图。

如果程序用 epoll 管理大量任务,收到 EPOLLIN 后可以把 pidfd 从“等待集合”转入“状态处理集合”,随后调用 waitid。处理完成就关闭描述符;不要反复把同一个已经回收的 pidfd 当成普通输入源。

已有进程、子进程和线程 pidfd 要分开判断

只观察已有的非子进程

对于已经运行的、但不属于当前进程的任务,pidfd_open 可以建立稳定引用,也可以用 poll 或 epoll 观察它结束。但此时不能把 waitid(P_PIDFD) 当成通用的退出码查询接口;权限、父子关系和目标是否已经被其它地方回收都可能使等待失败。

自己创建的子进程

自己通过 fork 创建的子进程,适合使用本文的组合。若程序一开始就需要在创建动作中拿到 pidfd,可以考虑带 CLONE_PIDFD 的 clone 方案,避免“先拿 PID、稍后再打开 pidfd”之间的额外窗口。

线程级句柄

不带 PIDFD_THREAD 时,pidfd 默认针对进程;线程组内还有其它线程时,整个线程组的结束边界才是主要关注点。使用 PIDFD_THREAD 后,句柄可以指向特定线程,线程退出成为 zombie 时的可读时机也不同。只有确实需要线程级生命周期时才启用它,不要把线程 pidfd 和普通进程 pidfd 的事件语义混用。

旧内核和降级路径怎么处理

部署环境低于 Linux 5.3 时,不能假设 pidfd_open 存在;低于 Linux 5.4 时,也不能假设 P_PIDFD 可用于 waitid。建议把能力探测放在启动或任务管理器初始化阶段,并把“等待对象”抽象出来:

  • 目标是自己的子进程且内核支持 pidfd:优先使用 pidfd + poll/epoll + waitid。
  • 目标是自己的子进程但内核较旧:使用 waitpid,不要在外层再用 PID 轮询模拟稳定句柄。
  • 目标不是自己的子进程且内核支持 pidfd:使用 pidfd 做结束监视,但把退出码查询视为能力之外。
  • 只有在明确接受限制时才使用 /proc/PID:它不是 pollable 文件,也不能消除 PID 复用风险。

降级路径的关键不是“找一个语法相同的替代函数”,而是向上层说明能力差异:是否能可靠识别原对象、是否能收到结束事件、是否能读取退出状态、是否由当前进程负责回收。

生产代码里的资源和安全注意

  • 始终关闭 pidfd:每个监视对象都会消耗文件描述符;任务结束、超时取消或错误返回都要走统一清理路径。
  • 处理 EINTR 和 EAGAIN:poll 可能被信号打断;启用 PIDFD_NONBLOCK 后,目标尚未结束时 waitid 可能直接返回 EAGAIN。
  • 不要把事件当退出码:POLLIN 只代表生命周期进入可处理状态,最终结果仍由 siginfo_t 的 si_code 和 si_status 解读。
  • 尊重命名空间与权限:pidfd 稳定的是任务引用,不会绕过 PID namespace、权限检查或目标已不存在的事实。
  • 避免重复回收:多个线程不要同时对同一个子进程调用 wait 系列接口;如果需要先观察后回收,明确使用 WNOWAIT 并安排唯一的最终回收者。

一句话记住这套组合

Linux pidfd 解决的是“我监视的到底是不是原来那个进程”这一层身份问题。pidfd_open 建立稳定句柄,poll/epoll 提供可复用的结束事件,waitid(P_PIDFD) 只在子进程场景下负责退出状态和回收。把这三个职责分清,再根据内核版本、父子关系和线程粒度选择降级路径,就不会把 PID 复用、不可读句柄和重复回收混在一起。

pidfd 可以直接 read 吗?

不可以。当前实现中对 pidfd 调用 read 会失败,应该通过 poll、epoll 或 waitid 获取生命周期与状态信息。

收到 EPOLLIN 后还要 waitid 吗?

如果需要子进程退出码或完成回收,仍然需要。EPOLLIN 只是告诉你进入了可以处理的状态。

pidfd 能替代所有 kill(pid, 0) 检查吗?

它能避免用裸 PID 反复确认同一对象的复用竞态,但不能替代所有权限判断、信号发送和进程管理策略。发送信号时应使用与 pidfd 对应的专用接口,并继续处理权限和目标状态错误。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>