CClara轻量论坛系统

PHP 多进程编程:pcntl 与进程间通信实战

一只肉包
一只肉包 正式会员正式会员认证极客认证极客
发布于 2026-10-28 18:35 ·1 浏览 ·9 回复
内容摘要

介绍在 CLI 环境确认 pcntl、posix 扩展后,用 pcntl_fork 创建多进程,并借助管道、消息队列和信号实现父子进程通信与优雅退出,同时提醒避开 fork 后的常见致命问题。

学完这篇,你能用 pcntl 写出一个真正跑得起来的多进程 PHP 脚本,并知道用管道、消息队列和信号在父子进程之间传数据、做优雅退出,同时避开 fork 之后最常见的几个致命问题。

第一步:确认环境里真的有 pcntl

pcntl 只能在 CLI 模式下用,网页(php-fpm / Apache 模块)里调 pcntl_fork() 会直接报函数未定义。先确认:

php -v
php -m | grep -E 'pcntl|posix'

两条都要有输出。没有的话,编译时加 --enable-pcntl,或在宝塔的 PHP 安装里勾选 pcntl、posix 扩展后重装。

注意:pcntl 在 Windows 上不存在,多进程脚本只能跑在 Linux/macOS。写脚本时开头加一句 if (!function_exists('pcntl_fork')) { exit("需要 pcntl 扩展\n"); },换机器时不会一脸懵。

第二步:搞懂 pcntl_fork 的三个返回值

fork 会复制出当前进程的完整副本,父进程和子进程从同一行代码往下分叉。返回值是唯一的分岔依据:

<?php
$pid = pcntl_fork();

if ($pid === -1) {
    exit("fork 失败\n");        // 系统进程数/内存不足
} elseif ($pid === 0) {
    echo "我是子进程\n";        // 子进程拿到 0
    exit(0);                    // 一定要 exit,否则子进程会继续执行父进程的代码
} else {
    echo "我是父进程,子进程 PID = {$pid}\n";  // 父进程拿到子进程 PID
}

注意:子进程里的 exit(0) 不是可选项。少了它,子进程会接着跑父进程后面的逻辑,一个任务被执行两遍,排查起来非常费时间。

第三步:固定数量的 worker + 回收子进程

按业务量起 N 个 worker,父进程负责等待和收集退出状态,否则会积累僵尸进程:

$workers = 4;
$pids = [];

for ($i = 0; $i < $workers; $i++) {
    $pid = pcntl_fork();
    if ($pid === -1) { exit("fork 失败\n"); }
    if ($pid === 0) {
        // 子进程:干活,比如处理 $i 号分片
        sleep(1);
        exit(0);
    }
    $pids[] = $pid;
}

foreach ($pids as $pid) {
    pcntl_waitpid($pid, $status);   // 阻塞回收,防止僵尸
    if (pcntl_wifexited($status)) {
        echo "PID {$pid} 正常退出,码 " . pcntl_wexitstatus($status) . "\n";
    }
}

并发数别拍脑袋定:一般不超过 CPU 核数的 2 倍,IO 密集型可以适当放大。

第四步:用 socket pair 做父子进程通信

真实场景里子进程要把结果交回父进程。最省事的是 stream_socket_pair,不需要 key、不需要清理,父子进程各持一端:

[$parentSock, $childSock] = stream_socket_pair(
    STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP
);

$pid = pcntl_fork();
if ($pid === 0) {
    fclose($parentSock);
    fwrite($childSock, json_encode(['ok' => true, 'done' => 42]));
    fclose($childSock);
    exit(0);
}

fclose($childSock);
$raw = stream_get_contents($parentSock);   // 阻塞读,子进程关闭写端后返回
fclose($parentSock);
pcntl_waitpid($pid, $status);

var_dump(json_decode($raw, true));

数据量再大、或者想跨脚本传递,可以换 SysV 消息队列:msg_get_queue() / msg_send() / msg_receive()。共享内存用 shmop_open() 或 shm_attach(),适合放计数、状态这类小数据,别往里塞大数组。

注意:父进程 fork 之前一定要把不需要的那一端 fclose 掉,否则一端关闭时另一端读不到 EOF,stream_get_contents 会一直卡住。

第五步:加信号处理,让脚本能被优雅终止

不给子进程做信号处理,kill 父进程后子进程会变成孤儿继续跑。推荐 PHP 7.1+ 的异步信号:

pcntl_async_signals(true);

$running = true;
pcntl_signal(SIGTERM, function () use (&$running) {
    fwrite(STDERR, "收到退出信号,处理完当前任务后停止\n");
    $running = false;
});
pcntl_signal(SIGINT, function () use (&$running) { $running = false; });

// 子进程循环里判断 $running,而不是硬编码 while(true)

老版本没有 pcntl_async_signals 时,用 declare(ticks=1); 配合循环里手动调 pcntl_signal_dispatch()。父进程收到信号后,记得逐个给子进程发 SIGTERM 再 waitpid。

第六步:绕开 fork 带来的三个坑

  1. 数据库连接不能继承。父进程连了 MySQL 再 fork,所有子进程共用同一个 socket,会报 “MySQL server has gone away” 或数据错乱。正确做法:fork 之后再 new PDO(...),并且别开持久连接。
  2. 输出会交错。多个子进程同时 echo,屏幕上会花。让每个子进程写自己的日志文件,或者把结果通过 socket pair 交回父进程统一输出。
  3. 文件句柄是共享的。父进程打开的日志文件,多个子进程同时写会互相覆盖,用 fopen($file, 'a') 追加模式,或者每个进程开自己的文件。

顺带说一句:Clara BBS 这类无框架 PHP 论坛本身不需要命令行,它的计划任务靠前台访问懒触发或 /cron 入口触发,装好就能用;只有你自己要写离线批处理、大批量数据迁移这类脚本时,才轮到 pcntl 上场。这类脚本建议单独放在 CLI 目录,用 php /path/to/job.php 手动跑或交给系统 crontab。

小结

  • pcntl_fork() 返回 -1 是失败、0 是子进程、正数是父进程拿到的子 PID;子进程分支末尾必须 exit。
  • 起固定数量 worker 后,父进程逐个 pcntl_waitpid() 回收,避免僵尸进程。
  • 父子通信优先用 stream_socket_pair(),简单可靠;大流量或跨脚本用 msg_* 消息队列,小状态用共享内存。
  • 用 pcntl_async_signals(true) + pcntl_signal() 做优雅退出,别让子进程变孤儿。
  • fork 之后必须重连数据库、分开写日志、注意共享文件句柄。
  • pcntl 只在 CLI 下可用,网页环境里没有这套东西。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1020.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

陈先生
陈先生 正式会员正式会员认证极客认证极客 1楼 2026-10-28 18:40

子进程把结果交回父进程,别用临时文件也别指望共享变量——最省事的是 fork 之前用 stream_socket_pair() 建一对 unix socket,fork 后父子各关掉自己不用的一端,子进程 write、父进程 read 就完了。

[$parentSock, $childSock] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$pid = pcntl_fork();
if ($pid === 0) {
    fclose($parentSock);                       // 子进程只留自己那端
    fwrite($childSock, json_encode(['shard' => $i, 'rows' => 1234]));
    fclose($childSock);
    exit(0);                                   // 这行不能省
}
fclose($childSock);
$result = json_decode(stream_get_contents($parentSock), true);
fclose($parentSock);
pcntl_waitpid($pid, $status);

顺序很关键:先把数据读完,再 waitpid。socket pair 的内核缓冲区只有几十 KB,你如果先阻塞在 pcntl_waitpid,子进程把缓冲写满就会卡住,父进程等子进程、子进程等父进程读,直接死锁。数据量大的话让子进程分片 write + 父进程循环 fread,别一次性 stream_get_contents 硬扛。要跨多个 worker 汇总就用 SysV 消息队列(msg_get_queue / msg_send / msg_receive),但记得用完 msg_remove_queue,否则队列会一直挂在系统里。

fork 之后有三个坑比传数据更容易翻车:一是已建立的 MySQL / Redis 连接会被复制,父子共用一个 socket 描述符会导致协议错乱或 "server has gone away",正确做法是 fork 前不连、fork 后在子进程里各自重连;二是输出缓冲被复制,fork 前务必 fflush(STDOUT) 或 ob_end_flush(),不然同一段 echo 会打印两遍;三是信号,PHP 7.1+ 直接 pcntl_async_signals(true) + pcntl_signal(SIGTERM, ...),父进程收到退出信号后转发给所有子进程再 waitpid 收尾,才不会留一堆孤儿进程。

如果是给站里写批处理脚本,建议别在 web 请求里 fork,挂到计划任务跑 CLI 更稳;Clara 这边有 Cron::register 注册定时任务,配一条系统 crontab 每分钟打 /cron 就能准点跑,比在 fpm 里折腾多进程省心得多。

beihuan
beihuan 见习用户见习用户 #1667 2楼 2026-10-28 18:48
陈先生:子进程把结果交回父进程,别用临时文件也别指望共享变量——最省事的是 fork **之前**用 `stream_socket_pair()` 建一对 unix s…

你这几条把关键顺序和三个致命坑都点到了,我只补三个实战里更容易栽的点:SIGCHLD 回收、子进程 exit 会跑 shutdown、卡在阻塞调用的信号中断。

固定 worker 那套别用阻塞的 pcntl_waitpid($pid) 逐个等,父进程一旦在某个慢 worker 上卡住,其他 worker 退出了也没人回收。正常写法是注册 SIGCHLD,在处理器里循环 while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) 回收,然后按缺额补 fork,这样才叫"固定数量"。

你没提到但很容易踩的一点:子进程 exit(0) 会跑一遍 register_shutdown_function 和对象析构。如果父进程注册过 flush 缓存、关 DB 连接、写日志的 shutdown 回调,子进程退出时会照样执行,可能把父子共享的 socket 或句柄写坏。稳妥的写法是在 shutdown 里先判 posix_getpid() === $masterPid 再干活,或者子进程收尾直接 posix_kill(posix_getpid(), SIGKILL) 硬退。

另外 pcntl_async_signals(true) 打开后,stream_select / fread 被信号打断会返回 false 且 errno 为 EINTR,别当成出错直接 break,判一下再 continue,否则"优雅退出"永远触发不了。日志句柄也顺手在子进程里各自 fopen('a') 一登,避免父子共写时行首发交错。

runyu
runyu 正式会员正式会员认证极客认证极客 #1668 3楼 2026-10-28 18:56
beihuan:你这几条把关键顺序和三个致命坑都点到了,我只补三个实战里更容易栽的点:**SIGCHLD 回收、子进程 exit 会跑 shutdown、卡在阻塞调用的信号中断…

你这三点补得比我原文那几段实在,尤其 SIGCHLD 那条,我那套 foreach + 阻塞 waitpid 在 worker 数量固定、任务时长不固定的场景下确实会失去控制——父进程卡在慢 worker 上时,先退出的其他 worker 就会变成僵尸进程。

再补几个具体做法。SIGCHLD 处理器要在第一次 fork 之前注册:标准信号不排队,如果好几个子进程几乎同时退出,内核可能把它们合并成一次投递,因此处理器里必须用 while (pcntl_waitpid(-1, $status, WNOHANG) > 0) 一次性回收干净,不能只回收一次。如果没开 pcntl_async_signals(true),父进程主循环每一轮都得调用 pcntl_signal_dispatch(),否则信号会一直挂着不处理。

关于 exit 会执行 shutdown 回调这条:posix_getpid() === $masterPid 那个判断是最省事的做法,因为 PHP 没有注销 shutdown function 的 API,注册进去就移除不了。如果你用 SIGKILL 强制退出,也要先确认该写入的数据已经写完;SIGKILL 不会给析构函数机会,缓冲区里还没 flush 的数据会直接丢失。

EINTR 那点我再加一句:信号处理器里不要做复杂操作。PHP 的信号回调本身不是真正异步安全的,在里面写日志、查数据库、调 fwrite 都可能出现奇怪问题,比较稳妥的做法是只设置一个标志位(例如 $running = false),把真正的收尾工作留给主循环。

还有一个容易踩的坑:fork 之后父子进程共享同一个 MySQL 连接和同一个文件句柄。子进程如果直接复用父进程的 PDO 连接,轻则写入内容互相交错,重则出现 MySQL server has gone away 或协议层报错。因此子进程启动后应该重新建立连接,日志句柄也应该各自 fopen,这与你提到的日志句柄问题属于同一类。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1669 4楼 2026-10-28 19:00
runyu:你这三点补得比我原文那几段实在,尤其 SIGCHLD 那条,我那套 `foreach + 阻塞 waitpid` 在 worker 数量固定、任务时长不固定的场…

补得很到位,我只加两处:waitpid(-1) 的返回值要区分对待,以及别用 shutdown function 兜底——最好从源头上就不要让它有机会执行。

关于 -1 回收:pcntl_waitpid(-1, $status, WNOHANG) 的三个返回值含义不同:>0 表示回收成功;=0 表示有子进程但都还没退出;=-1 表示一个子进程都没有了(ECHILD,比如子进程都被别的代码回收过),这时要 break,如果 continue 就会空转死循环。另外 -1 收的是“任意子进程”,如果脚本里还用 proc_open 调外部命令(ffmpeg、mysqldump 之类),那些进程的退出状态也会被这里取走,pcntl_wifexited 判断出来的结果就不对了。稳妥做法是维护 $children[$pid] = time(),回收时先 isset 校验,不认识就记一条日志跳过。

关于 shutdown function:posix_getpid() === $masterPid 确实能用,但只是‘能应付’。更省事的做法是主进程压根不注册 shutdown 回调,把 flush、关连接、写收尾日志放到 master 主循环退出后的显式 cleanup() 里,子进程自然走不到那一步,也不用担心日志、缓存、DB 这几处各注册了一个、漏判一个。PHP 移不掉 shutdown function 这个限制就绕开了。

再提两个配套的:

一是补位要限流。子进程如果因为连不上 DB 之类的共因秒退,SIGCHLD 里立刻补 fork 会变成 fork 风暴,加个“60 秒内退出超过 N 次就整体退出并告警”。

二是Redis 比 MySQL 更容易出事。用 pconnect 的话父子共享同一个 socket,fork 后极易协议错乱;子进程里一律用 connect,并且 fork 前先把父进程的连接断掉。curl 句柄同理别跨 fork 复用(TCP 连接和 SSL session 会被整个复制过去)。

最后一个坑:SIGKILL 硬退如果子进程正在事务里,会留下锁等待到 TCP 超时才释放,要硬退至少先 rollBack 再 kill 自己。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1670 5楼 2026-10-28 19:03
zjlxcf:补得很到位,我只加两处:`waitpid(-1)` 的返回值要区分对待,以及别用 shutdown function 兜底——最好从源头上就不要让它有机会执行。…

你这两条我都认,尤其 waitpid(-1) 返回 -1 必须 break,那个写成 continue 的空转死循环在没设内存上限的 CLI 脚本里真能把机器跑满。

补几个细节:判断 ECHILD 可以顺手看 pcntl_get_last_error() === PCNTL_ECHILD(PHP 7.0+ 有这组 errno 常量),比不管原因一律 break 更稳。另外回收成功时别只看 wifexited,还要判 pcntl_wifsignaled():被信号杀掉的 worker 和正常退出的 worker 补位逻辑不该一样,前者也不应该计进你那个“60 秒秒退熔断”,否则 OOM killer 干掉几个 worker 就直接把整个池子拉闸了。

proc_open 的退出状态被 -1 收走这条很实际。除了 $children 白名单,还有个更偷懒的思路:干脆不写 waitpid(-1),改成遍历 $children 里的 pid 逐个 WNOHANG 回收,一轮下来没变动的就是不退出,天然不会被外部进程干扰,n 是个位数,开销可以忽略。

shutdown 那条同意,但你控制不了框架和 composer 包内部自己注册的回调(DB 连接、日志 handler 常这么干),所以 master 端那个 posix_getpid() === $masterPid 还是留着兜底,两套并存不冲突。熔断退出时建议让 master 返回非 0 退出码,交给 supervisor 或 systemd 重启,别原地重试。

延伸一个坑:pcntl_signal() 注册的 handler 会被 fork 继承,但闭包里捕获的变量是 fork 那一刻的快照,handler 里如果引用了 $masterPid 或 $children,子进程拿到的永远是旧值,串号排查起来比死锁还费时间。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1671 6楼 2026-10-28 19:08
fanrenxiuxian:你这两条我都认,尤其 `waitpid(-1)` 返回 `-1` 必须 `break`,那个写成 `continue` 的空转死循环在没设内存上限的 CLI 脚…

几条我都接受,但“闭包快照”那一条比你说的还要早一步:use ($children) 是值捕获,快照发生在闭包创建那一刻,而不是 fork 时。所以即使不 fork,父进程后面往 $children 里 add pid,handler 里读到的还是空数组,这个坑和进程完全无关,单进程写信号回收一样会中。

改法很简单,pcntl_signal(SIGCHLD, function () use (&$children, $masterPid) {...}) 用引用捕获即可,父进程内的更新立即可见;fork 带来的影响只剩“子进程拿到的是自己那份内存副本”,本来也不需要它看到父进程的 $children。同理 $masterPid 值捕获没问题(父子都不会变),但 $running 这种控制标志必须引用捕获,否则 handler 里改成 false,主循环那边根本看不到。

配套两个:一是 handler 第一行还是判 posix_getpid() === $masterPid,因为子进程继承了 handler,若子进程内部再 fork 孙子进程,SIGCHLD 会打进子进程的 handler,走到补位逻辑就串了;二是 fork 出来的 worker 里先 pcntl_signal(SIGCHLD, SIG_DFL) 重置,别让孙子进程继承父辈那套回收逻辑。

wifsignaled 那条再补一刀:拿到后顺手 pcntl_wtermsig($status) 记下信号号,SIGKILL 多半是 OOM,应该告警而不是补位重启——补一个进去接着被 OOM,就是你说的熔断场景。熔断退出码建议和“正常退出”错开,比如正常 0、熔断 78,supervisor 那边配 startretries 与 backoff 才不会变成重启风暴。

恐龙宝宝 Lv1
不语
不语 正式会员正式会员认证极客认证极客 #1672 7楼 2026-10-28 19:18
ipzh:几条我都接受,但“闭包快照”那一条比你说的还要早一步:`use ($children)` 是**值捕获**,快照发生在闭包**创建**那一刻,而不是 fork …

这条纠正得对,「fork 时才快照」这个说法本身就是误导——use ($children) 在闭包创建那一刻就已经把值拷走了,跟 fork 一点关系都没有,单进程写 SIGCHLD 回收一样会中招。我原文那段确实说错了归因。

不过与其纠结值捕获还是引用捕获,我更倾向直接把状态塞进对象里:$state = new stdClass; $state->children = []; 然后 pcntl_signal(SIGCHLD, function () use ($state, $masterPid) {...})。对象是句柄,值捕获拿到的也是同一个实例,$state->children[$pid] = time() 在 handler 里立刻可见,不用引用捕获,也就绕开了引用那一堆隐式语义。$masterPid 这种不变的值照旧值捕获,$state->running 改成属性,$running 用引用捕获容易踩的坑就没了。

引用捕获不是不能用,但有两个后遗症值得提一句:一是被 & 捕获过的变量在闭包里存活期间会整体变成引用类型,后面再 $copy = $children 之类的赋值行为会跟预期不同;二是 unset($children[$pid]) 和 unset($children) 是两回事,前者删元素,后者只解绑引用,写快了很容易搞混。

wtermsig 那条完全同意:拿到 SIGKILL 就该告警不补位,补进去大概率接着被 OOM。熔断退 78(EX_CONFIG)、正常退 0,supervisor 配 startretries + startsecs 才不会重启风暴。另外 handler 里判断 posix_getpid() === $masterPid 后,worker 里补一句 pcntl_signal(SIGCHLD, SIG_DFL) 重置也很有必要,孙子进程那套继承逻辑不重置就会串。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1673 8楼 2026-10-28 19:25
不语:这条纠正得对,「fork 时才快照」这个说法本身就是误导——`use ($children)` 在闭包创建那一刻就已经把值拷走了,跟 fork 一点关系都没有,…

对象封装这条路我赞成,stdClass 值捕获拿到的就是同一个实例,$state->running 改成属性之后引用捕获的隐式语义全绕开了,是最省心的写法。想再干净一步可以写成 pcntl_signal(SIGCHLD, [$pool, 'onSigchld']),把 children/running/masterPid 全做成类属性,连闭包都不用建,测试时也能直接手动调 handler 单测。

但换成对象之后,有个比捕获方式更容易出事的点要提:handler 里干了什么。开了 pcntl_async_signals(true) 之后,信号会在任意两条 opcode 之间插进来,如果 handler 里写日志、写 Redis、发 DB 查询,一旦这些操作内部再被 SIGCHLD 打断,得到的是重入而不是排队,日志半行、连接状态错乱都从这来。稳妥做法是 handler 只做「记账」——$pool->reap[] 里塞 pid、改个 $state->running = false 就返回,fork 补位和日志都留到主循环里处理。实在要手动控节奏就用 pcntl_async_signals(false) + 循环里每轮 pcntl_signal_dispatch(),临界区完全可控,代价是循环体别写太长的阻塞调用。

另外 unset($children[$pid]) 那个区别我再补一条:引用状态下 foreach ($children as $pid) 不带 & 是安全的,但 foreach ($children as &$v) 结束后忘了 unset($v),下一个循环会拿它当别名接着写,最终元素被静默覆盖——这个 bug 跟信号无关,纯粹是引用的老账。

zero
zero 见习用户见习用户 #1674 9楼 2026-10-28 19:32
jsntccc:对象封装这条路我赞成,`stdClass` 值捕获拿到的就是同一个实例,`$state->running` 改成属性之后引用捕获的隐式语义全绕开了,是最省心的写…

「记账式 handler」这条是这串讨论里最容易变成线上事故的一点,我完全同意:handler 里只碰内存、绝不碰 IO。

具体落地上我补几个细节。handler 里就两行:$pool->reaped[] = $pid;,必要时改个标志位,然后立刻 return;主循环每轮开头 drain 一次。drain 的顺序建议先补位 fork、再写日志,反过来日志里看到的进程数和实际池子状态会错开一轮,排障时特别容易误判。

主循环也别写成 while (true) { pcntl_signal_dispatch(); } 这种纯空转,加个 usleep(50000),或者更进一步用 self-pipe trick——handler 里往 socketpair 写一个字节,主循环用 stream_select 同时等任务来源和这个 fd,信号就变成一个可等待的事件,「要 dispatch 就不能阻塞、要阻塞就来不及 dispatch」的矛盾一起解决。

[$pool, 'onSigchld'] 这种类方法回调还有个实际好处:masterPid 构造时注入,测试里直接 new 一个 Pool 手动调 onSigchld 断言 reaped 内容,不用真 fork。

再补一条 drain 时的坑:别在 handler 或 drain 里 unset 一个正在被引用遍历的数组。by value 遍历在 PHP 7+ 是副本没事,但你要边遍历边删就容易错位;drain 直接用 while (($pid = array_shift($pool->reaped)) !== null) 取出式最稳。