编程 php -S 长跑后的 fd 泄漏与 pcntl 丢信号:三个只在容器里出现的故障

2026-10-30 21:05:34

PHP 8.5.12:php -S 的 fd 泄漏、pcntl 丢信号与 fork 后的 /proc/self

2026-10-22 的 PHP 8.5.12 changelog 里,CLI 与 PCNTL 下有几条都属于同一个类型:短时间内测不出来,进程长期运行、fd 慢慢累积、信号偶发不响应之后才会暴露出来。下面按故障现象分开说,并给出对应的复现思路。

一、php -S 对每个静态文件 HEAD 请求泄漏一个 fd(GH-23764)

内建服务器处理静态文件时,请求由 SAPI 自己直接返回,不进入 PHP 的请求生命周期。HEAD 路径上打开的文件描述符没有在响应结束时关闭,每处理一个 HEAD 请求就泄漏一个 fd。

复现时把 fd 上限调低,以加快触达上限:

# 终端 1
ulimit -n 256
php -S 127.0.0.1:8080 -t .

# 终端 2
for i in $(seq 1 500); do
  curl -sI http://127.0.0.1:8080/style.css > /dev/null
done

PID=$(pgrep -f 'php -S 127.0.0.1:8080' | head -1)
ls /proc/$PID/fd | wc -l

起始时 /proc/$PID/fd 下有七八个条目,之后基本与 HEAD 请求数保持线性增长。达到上限后的表现是:服务器 stderr 开始出现 Too many open files,accept 失败,新连接被拒绝,curl 端报连接失败或空回复。整个过程是单进程,因此一个 fd 表达到上限,整个开发服务器就无法继续接受连接,而不是某个 worker 退出。

对 .php 请求不会触发这条泄漏,只针对静态文件;这也意味着只测动态路由的自建脚本发现不了这个问题。

二、客户端在 accept 之前 reset 导致内建服务器崩溃

连接进入 backlog 之后、accept 取走之前被客户端 RST 掉,内建服务器在错误路径上崩溃(整进程退出,不是单个请求 500)。复现用 SO_LINGER 0 强制发 RST:

python3 - <<'EOF'
import socket, struct
for _ in range(2000):
    s = socket.socket()
    s.connect(("127.0.0.1", 8080))
    s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
    s.close()
EOF

多跑几轮后,php -S 进程直接消失。压测工具或扫描器的大量短连接、超时取消,是最容易触发这种时序的客户端行为。

三、部分写之后错误页被截断、未发送部分泄漏

错误响应(404/500 页面)在 socket 上只写出了一部分(发送缓冲满、客户端读得慢或提前断开),剩余部分既没有继续发送,也没有释放。客户端看到的是一段截断的 HTML,未发送的缓冲留在进程里。

这一条不像 fd 泄漏那样有稳定的计数指标,通常表现为「错误页偶尔只显示半截」,同时伴随内存缓慢增长。触发条件是错误页体积较大、客户端读取速率受限,可以用 curl --limit-rate 请求一个不存在的长路径来靠近这个场景。

四、pcntl_signal_dispatch() 丢弃排队信号

pcntl_signal_dispatch() 是手动分发模式,与 declare(ticks)、pcntl_async_signals(true) 是两种处理方式。这一版修掉了它两种丢弃信号的情况:

  1. 调用时已有异常挂起(例如在 catch 块里调用 dispatch),排队中的信号被直接丢弃;
  2. 某个处理器抛出异常,排在它后面、已经入队的信号一并丢失。

两种情况的共同表现是「进程似乎没有收到某个信号」:SIGTERM 不触发退出逻辑、计数器漏加,且与负载无关,只在异常路径附近出现。

第一种的形态:

pcntl_async_signals(false);
pcntl_signal(SIGTERM, function () { echo "term\n"; });
pcntl_signal(SIGINT,  function () { echo "int\n"; });

posix_kill(posix_getpid(), SIGINT);
posix_kill(posix_getpid(), SIGTERM);

try {
    throw new RuntimeException("pending");
} catch (Throwable $e) {
    pcntl_signal_dispatch();   // 8.5.12 之前:排队信号被丢掉
}

第二种的形态:

pcntl_signal(SIGUSR1, function () { throw new Exception("boom"); });
pcntl_signal(SIGUSR2, function () { echo "usr2\n"; });

posix_kill(posix_getpid(), SIGUSR1);
posix_kill(posix_getpid(), SIGUSR2);

try { pcntl_signal_dispatch(); } catch (Throwable $e) {}
pcntl_signal_dispatch();   // SIGUSR2 的处理器在修复前不会执行

排查信号丢失时,先确认是「没收到」还是「收到后没分发」:

grep -E 'Sig(Q|Pnd|Blk)' /proc/$PID/status

SigPnd 有值而业务逻辑没跑,说明卡在 dispatch 路径上;再看调用点是否位于 try/catch 内、处理器里是否有会抛异常的代码。

五、pcntl_fork() 之后 /proc/self 解析到父进程(GH-23986)

子进程通过 /proc/self 路径去读 fd 或解析链接时,拿到的是父进程的 pid。子进程继承了一份已经固化的路径解析结果,/proc/self 本应随 task 变化,但缓存让结果一直指向 fork 时的进程。

php -r '
$pid = pcntl_fork();
if ($pid === 0) {
    printf("child=%d /proc/self=%s\n", getmypid(), readlink("/proc/self"));
    exit(0);
}
pcntl_waitpid($pid, $st);
printf("parent=%d\n", getmypid());
'

修复前子进程打印的 /proc/self 与父进程 pid 一致。

在容器里,这会造成以下几类问题:

  • 扫描 /proc/self/fd 做 fd 泄漏排查时,读到的是父进程的 fd 表,数量对不上,会误报或漏报;
  • 按扫描出来的 fd 号做关闭、dup 一类操作,作用在错误的 fd 表上;
  • 依赖 /proc/self/fd/N 读取已打开文件内容的代码(临时文件、php://temp 转存),在 fork 后读到父进程那一份。

变通办法是绕开 self,直接用 posix_getpid() 拼路径:

$dir = '/proc/' . posix_getpid() . '/fd';

在升级到 8.5.12 之前,这是 fork 子进程里访问 /proc 的写法。

php -S 单进程的取舍

内建服务器是单线程的,一次只处理一个请求,静态文件由 SAPI 直接返回,不经过 PHP 请求生命周期。带来的直接后果是:上文提到的 fd 泄漏、缓冲区泄漏都计入同一个长驻进程,没有 worker 轮换可以缓解;一个请求卡住,后面的请求全部排队;进程崩溃等于服务整体中断。

它的优点也同样来自这一点:启动时零配置,不需要 worker 管理,也不存在多进程共享状态的问题。本地开发和调试够用;作为并发基线或长时间运行的服务来用,就会遇到上面这几条修复针对的场景。

同一版本的其他条目

  • CLI:Windows ZTS CLI SAPI 在 request activation 时刷新 TSRMLS 缓存(GH-22567)。
  • OpenSSL:stream_socket_enable_crypto() 握手超时后不再把 socket 留在非阻塞状态;Windows 上 TLS 流收到 close_notify 后 feof() 不再恒为 false。
  • Core:dl() 加载的扩展声明了 frameless 函数、后续请求调用时的 use-after-free;Fiber 中 GC 期间析构函数抛出的异常未重新抛回触发 GC 的栈帧。
  • Session:session.use_strict_mode 拒绝所给 ID 后,session_start() 不再在 create_sid() 失败后继续;用户自定义 create_sid() 抛出的异常不再被返回值校验错误覆盖。
  • Standard:proc_open() 在 descriptor 建立失败时泄漏 fd 0;三个 Windows 专属缺陷(未初始化的 PROCESS_INFORMATION、comspec 查找失败后的不确定指针、未检查的 CreateFileA() 失败);sha1_file() 在流读取失败后不再对不完整数据返回摘要。
复制全文 生成海报 PHP CLI pcntl 运维排查

推荐文章

程序员茄子在线接单