学完这篇,你能用 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 带来的三个坑
- 数据库连接不能继承。父进程连了 MySQL 再 fork,所有子进程共用同一个 socket,会报 “MySQL server has gone away” 或数据错乱。正确做法:fork 之后再
new PDO(...),并且别开持久连接。
- 输出会交错。多个子进程同时
echo,屏幕上会花。让每个子进程写自己的日志文件,或者把结果通过 socket pair 交回父进程统一输出。
- 文件句柄是共享的。父进程打开的日志文件,多个子进程同时写会互相覆盖,用
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 下可用,网页环境里没有这套东西。