4、进程控制:pcntl扩展介绍、创建子进程、进程间通信、信号处理、实战:多进程任务管理器

进程控制,说白了就是让 PHP 不再只是「单线程跑脚本」的工具。你想想看,一个耗时的任务,比如处理一万张图片,单进程跑得等到猴年马月。这时候,多进程就派上用场了。

我个人习惯把进程控制比作「开分店」。主进程是总店,子进程就是分店。各干各的活,最后汇总结果。嗯,这个比喻虽然糙,但道理不糙。

pcntl 扩展介绍

pcntl 是 PHP 的进程控制扩展。它提供了 fork、wait、signal 等底层操作。说白了,就是让你能在 PHP 里直接操控 Linux 的进程 API。

安装很简单,编译时加上 --enable-pcntl 就行。不过要注意,pcntl 只能在 CLI 模式下运行。Web 模式下用不了,因为 Apache 或 Nginx 的进程模型不允许你乱 fork。

警告:pcntl 扩展不支持 Windows 系统。如果你在 Windows 上开发,建议用 Docker 或虚拟机跑 Linux 环境。

我记得刚开始用 pcntl 时,犯过一个低级错误——在 Web 请求里调了 pcntl_fork()。结果整个 Apache 进程树都乱了,服务器直接挂掉。嗯,从那以后我记住了:pcntl 只属于 CLI。

创建子进程

创建子进程的核心函数是 pcntl_fork()。这个函数很特别,它调用一次,返回两次。父进程返回子进程的 PID,子进程返回 0。如果返回 -1,说明 fork 失败了。

<?php
$pid = pcntl_fork();

if ($pid == -1) {
    die('fork 失败');
} elseif ($pid) {
    // 父进程代码
    echo "我是父进程,子进程 PID 是 $pid\n";
    pcntl_wait($status); // 等待子进程结束
} else {
    // 子进程代码
    echo "我是子进程,PID 是 " . getmypid() . "\n";
    exit(0); // 子进程执行完毕
}

这里有个坑:子进程会继承父进程的所有资源,包括文件描述符、数据库连接等。我曾经在项目中遇到过,子进程里操作了父进程的数据库连接,结果导致连接混乱。解决办法很简单——子进程里重新建立连接。

技巧:fork 之后,子进程和父进程是独立的。子进程里的变量修改不会影响父进程。这一点和线程不同,别搞混了。

进程间通信

进程间通信,简称 IPC。多进程之间怎么传数据?常见方式有管道、消息队列、共享内存、信号等。

我个人最常用的是管道。PHP 提供了 proc_open()stream_select() 来实现。不过更简单的方式是用 posix_mkfifo() 创建命名管道。

<?php
// 创建管道
$pipe = '/tmp/my_pipe';
if (!file_exists($pipe)) {
    posix_mkfifo($pipe, 0666);
}

$pid = pcntl_fork();

if ($pid) {
    // 父进程:写数据
    $fp = fopen($pipe, 'w');
    fwrite($fp, "任务数据\n");
    fclose($fp);
    pcntl_wait($status);
} else {
    // 子进程:读数据
    $fp = fopen($pipe, 'r');
    $data = fgets($fp);
    echo "子进程收到: $data";
    fclose($fp);
    exit(0);
}

还有一种方式是用共享内存。PHP 的 shmop 扩展可以操作共享内存段。不过说实话,共享内存容易出并发问题,我一般只在性能要求极高的场景下用。

通信方式 优点 缺点
管道 简单、可靠 只能单向通信
消息队列 支持多对多 需要额外扩展
共享内存 速度快 需要处理同步
信号 轻量级 只能传简单信号

信号处理

信号是进程间通信的一种特殊形式。它不传数据,只传一个数字编号。比如 SIGTERM(15)表示终止,SIGKILL(9)表示强制杀死。

PHP 里用 pcntl_signal() 来注册信号处理函数。注意,信号处理是异步的,可能会打断正在执行的代码。

<?php
// 注册信号处理
pcntl_signal(SIGTERM, function ($signo) {
    echo "收到 SIGTERM 信号,准备退出\n";
    exit(0);
});

// 分发信号
while (true) {
    pcntl_signal_dispatch();
    // 做其他工作
    sleep(1);
}

我曾经在项目中遇到过一个问题:信号处理函数里调用了 exit(),但子进程没有正常退出,变成了僵尸进程。后来我加上了 pcntl_wait() 来回收子进程,问题就解决了。

重要:信号处理函数里不要做复杂操作。尽量只设置一个标志位,然后在主循环里检查。否则容易出死锁。

实战:多进程任务管理器

好了,理论讲完了。我们来写一个真正的多进程任务管理器。这个工具可以同时处理多个任务,比如批量下载文件、处理日志等。

<?php
class TaskManager {
    private $maxWorkers = 5;
    private $tasks = [];
    private $workers = [];

    public function addTask($task) {
        $this->tasks[] = $task;
    }

    public function run() {
        while (!empty($this->tasks) || !empty($this->workers)) {
            // 启动新子进程
            while (count($this->workers) < $this->maxWorkers && !empty($this->tasks)) {
                $task = array_shift($this->tasks);
                $pid = pcntl_fork();

                if ($pid == -1) {
                    die('fork 失败');
                } elseif ($pid) {
                    // 父进程记录子进程
                    $this->workers[$pid] = $task;
                } else {
                    // 子进程执行任务
                    $this->executeTask($task);
                    exit(0);
                }
            }

            // 等待子进程结束
            $pid = pcntl_wait($status, WNOHANG);
            if ($pid > 0) {
                unset($this->workers[$pid]);
                echo "任务完成: " . $this->workers[$pid] . "\n";
            }
        }
    }

    private function executeTask($task) {
        // 模拟任务处理
        echo "处理任务: $task\n";
        sleep(rand(1, 3));
    }
}

// 使用示例
$manager = new TaskManager();
$manager->addTask('下载文件1');
$manager->addTask('下载文件2');
$manager->addTask('处理日志');
$manager->addTask('生成报告');
$manager->run();

这个管理器有几个关键点:

  • 控制并发数$maxWorkers 限制了同时运行的子进程数量。避免一下子 fork 太多,把系统资源吃光。
  • 非阻塞等待pcntl_wait($status, WNOHANG) 不会阻塞主进程。主进程可以继续启动新的子进程。
  • 任务队列:用数组模拟队列,先进先出。你也可以用 Redis 或数据库来实现更复杂的队列。
避坑指南:我曾经在生产环境里跑这个管理器,结果发现子进程越来越多,最后把服务器内存吃光了。排查后发现是 pcntl_wait 没有正确回收子进程。后来我加上了 WNOHANG 标志,并在循环里持续检查,问题才解决。

嗯,到这里你应该对 PHP 多进程控制有了基本了解。记住几个要点:fork 后资源独立、信号处理要轻量、子进程一定要回收。下次遇到批量任务,别再用单线程傻等了,试试多进程吧。