PHP `pcntl` 扩展:多进程编程、进程间通信 (`IPC`) 与信号处理

各位观众老爷们,今天咱们来聊聊PHP里那些“不安分”的小家伙——进程!别害怕,不是要你真的去学操作系统原理,而是用 pcntl 扩展,让你的 PHP 代码也能玩转多进程、进程间通信,甚至还能优雅地处理信号。准备好了吗?咱们开始!

一、PHP 的“人格分裂”:多进程编程

PHP 默认是单线程执行的,就像一个勤勤恳恳的工蜂,一次只能处理一个任务。但如果你的任务非常耗时,比如要处理大量的图片、视频,或者要调用外部服务,单线程就显得力不从心了。这时候,就需要让 PHP 来一次“人格分裂”,变成多个进程,每个进程负责处理一部分任务,从而提高整体的效率。

pcntl 扩展就是 PHP 提供的“分裂工具”。它允许你创建、控制和管理进程。

1. 创建子进程:pcntl_fork()

pcntl_fork() 函数是创建子进程的核心。它会复制当前进程的所有内容(代码、数据、文件描述符等等),然后产生一个完全相同的子进程。

<?php

$pid = pcntl_fork();

if ($pid == -1) {
    die('啊哦,fork失败了!');
} else if ($pid) {
    // 父进程
    echo "我是父进程,我的PID是:". getmypid() . ", 子进程的PID是:". $pid . "n";
} else {
    // 子进程
    echo "我是子进程,我的PID是:". getmypid() . ", 我爹的PID是:". getmypid() . "n";
    exit(0); // 子进程执行完毕,一定要退出
}

?>

这段代码简单地创建了一个子进程。注意,pcntl_fork() 会返回不同的值:

  • -1: 表示 fork 失败了,可能是系统资源不足。
  • 0: 表示当前进程是子进程。
  • > 0: 表示当前进程是父进程,返回值是子进程的 PID (进程ID)。

重要提示: 子进程创建后,会从 pcntl_fork() 的下一行代码开始执行。所以,你需要使用 if...else 结构来区分父进程和子进程,并让它们执行不同的任务。子进程执行完自己的任务后,一定要调用 exit() 函数退出,否则可能会出现意想不到的问题。

2. 等待子进程:pcntl_wait()pcntl_waitpid()

父进程创建了子进程后,通常需要等待子进程执行完毕。pcntl_wait()pcntl_waitpid() 函数就是用来做这件事的。

  • pcntl_wait(&$status): 等待任意一个子进程结束。$status 参数用来接收子进程的退出状态。
  • pcntl_waitpid($pid, &$status, $options): 等待指定 PID 的子进程结束。$pid 参数指定要等待的子进程的 PID。 $options 参数可以控制等待的行为,例如是否阻塞等待。
<?php

$pid = pcntl_fork();

if ($pid == -1) {
    die('啊哦,fork失败了!');
} else if ($pid) {
    // 父进程
    echo "我是父进程,我的PID是:". getmypid() . ", 子进程的PID是:". $pid . "n";
    pcntl_waitpid($pid, $status); // 等待指定的子进程
    echo "子进程 ". $pid . " 已经结束,退出状态是:". $status . "n";

} else {
    // 子进程
    echo "我是子进程,我的PID是:". getmypid() . ", 我爹的PID是:". getmypid() . "n";
    sleep(2); // 模拟耗时操作
    exit(0); // 子进程执行完毕,一定要退出
}

?>

3. 执行外部程序:pcntl_exec()

除了执行 PHP 代码,子进程还可以执行外部程序。pcntl_exec() 函数就是用来做这件事的。

<?php

$pid = pcntl_fork();

if ($pid == -1) {
    die('啊哦,fork失败了!');
} else if ($pid) {
    // 父进程
    echo "我是父进程,我的PID是:". getmypid() . ", 子进程的PID是:". $pid . "n";
    pcntl_waitpid($pid, $status);
    echo "子进程 ". $pid . " 已经结束,退出状态是:". $status . "n";

} else {
    // 子进程
    pcntl_exec('/usr/bin/ls', ['-l']); // 执行 ls -l 命令
    exit(0); // 理论上这里不会执行,因为exec已经替换了当前进程
}

?>

pcntl_exec() 函数的第一个参数是要执行的程序路径,第二个参数是传递给程序的参数数组。需要注意的是,pcntl_exec() 函数会替换当前进程,所以执行完 pcntl_exec() 后,子进程的代码就不会再执行了。

二、兄弟齐心,其利断金:进程间通信 (IPC)

多进程之间需要交换数据、共享资源,这就是进程间通信 (IPC)。 pcntl 扩展并没有提供非常完善的 IPC 机制,但我们可以借助一些其他的方法来实现。

1. 管道 (Pipes):proc_open()

管道是一种简单的 IPC 方式,它允许进程之间单向地传递数据。 proc_open() 函数可以创建管道,并启动一个新的进程。

<?php

$descriptorspec = array(
   0 => array("pipe", "r"),  // 标准输入,子进程从这里读取
   1 => array("pipe", "w"),  // 标准输出,子进程向这里写入
   2 => array("pipe", "w")   // 标准错误输出,子进程向这里写入
);

$process = proc_open('wc -w', $descriptorspec, $pipes);

if (is_resource($process)) {
    // 向子进程的标准输入写入数据
    fwrite($pipes[0], "Hello World!n");
    fclose($pipes[0]);

    // 从子进程的标准输出读取数据
    $output = stream_get_contents($pipes[1]);
    fclose($pipes[1]);

    // 从子进程的标准错误输出读取数据
    $error = stream_get_contents($pipes[2]);
    fclose($pipes[2]);

    // 关闭进程
    $return_value = proc_close($process);

    echo "Word count: " . trim($output) . "n";
    echo "Error: " . $error . "n";
    echo "Return value: " . $return_value . "n";
}

?>

这段代码使用 proc_open() 函数启动了 wc -w 命令,并将标准输入、标准输出和标准错误输出都连接到管道。父进程可以通过管道向子进程发送数据,并从子进程接收数据。

2. 共享内存 (Shared Memory):shmop 扩展

共享内存是一种更高级的 IPC 方式,它允许进程之间直接访问同一块内存区域。PHP 提供了 shmop 扩展来支持共享内存。

注意: shmop 扩展默认没有启用,需要在 php.ini 中启用。

<?php

// 创建共享内存段
$shm_key = ftok(__FILE__, 't'); // 生成一个唯一的键
$shm_id = shmop_open($shm_key, "c", 0644, 1024); // 创建一个 1024 字节的共享内存段

if (!$shm_id) {
    die("无法创建共享内存段n");
}

// 父进程
$pid = pcntl_fork();

if ($pid == -1) {
    die('啊哦,fork失败了!');
} else if ($pid) {
    // 父进程
    echo "我是父进程,我的PID是:". getmypid() . "n";
    shmop_write($shm_id, "Hello from parent!", 0); // 向共享内存写入数据
    pcntl_waitpid($pid, $status);
    echo "子进程已经结束n";

    // 读取共享内存中的数据
    $data = shmop_read($shm_id, 0, 1024);
    echo "父进程读取到共享内存中的数据:". $data . "n";

    shmop_close($shm_id); // 关闭共享内存段
    shmop_delete($shm_id); // 删除共享内存段

} else {
    // 子进程
    echo "我是子进程,我的PID是:". getmypid() . "n";
    sleep(1); // 等待父进程写入数据

    // 读取共享内存中的数据
    $data = shmop_read($shm_id, 0, 1024);
    echo "子进程读取到共享内存中的数据:". $data . "n";

    shmop_write($shm_id, "Hello from child!", 0); // 向共享内存写入数据
    shmop_close($shm_id); // 关闭共享内存段
    exit(0);
}

?>

这段代码创建了一个共享内存段,父进程和子进程都可以访问这块内存。父进程向共享内存写入数据,子进程可以读取到这些数据,反之亦然。

3. 消息队列 (Message Queues):msg_queue 扩展

消息队列是一种异步的 IPC 方式,它允许进程之间通过发送和接收消息来交换数据。PHP 提供了 msg_queue 扩展来支持消息队列。

注意: msg_queue 扩展默认没有启用,需要在 php.ini 中启用。

<?php

// 创建消息队列
$queue_key = ftok(__FILE__, 'm'); // 生成一个唯一的键
$queue_id = msg_get_queue($queue_key, 0666 | MSG_IPC_CREAT);

if (!$queue_id) {
    die("无法创建消息队列n");
}

// 父进程
$pid = pcntl_fork();

if ($pid == -1) {
    die('啊哦,fork失败了!');
} else if ($pid) {
    // 父进程
    echo "我是父进程,我的PID是:". getmypid() . "n";
    $message = ["type" => 1, "text" => "Hello from parent!"];
    msg_send($queue_id, 1, $message); // 向消息队列发送消息
    pcntl_waitpid($pid, $status);
    echo "子进程已经结束n";

} else {
    // 子进程
    echo "我是子进程,我的PID是:". getmypid() . "n";
    msg_receive($queue_id, 1, $message_type, 1024, $message); // 从消息队列接收消息
    echo "子进程接收到消息:". $message["text"] . "n";
    exit(0);
}

?>

这段代码创建了一个消息队列,父进程向消息队列发送一条消息,子进程可以从消息队列接收到这条消息。

4. Socket 通信

Socket 是一种通用的网络通信方式,也可以用于进程间通信。你可以使用 socket 扩展创建 Unix Domain Socket,实现本地进程间的通信。 因为比较复杂,这里只给出概念,不提供示例代码。

IPC 方式选择建议:

IPC 方式 优点 缺点 适用场景
管道 简单易用 单向通信,效率较低 简单的进程间数据传递
共享内存 效率高,速度快 需要考虑同步问题,容易出错 大量数据共享,对性能要求高的场景
消息队列 异步通信,可靠性高 效率相对较低,需要序列化和反序列化数据 异步任务处理,进程间解耦
Socket通信 通用性强,可以跨机器通信 复杂度高,需要编写较多的代码 需要跨机器通信,或者需要更灵活的通信方式

三、优雅地谢幕:信号处理

在多进程编程中,信号是一种重要的进程间通信方式。它可以用来通知进程发生了某个事件,例如收到一个请求、发生了错误等等。

pcntl 扩展提供了信号处理的功能。

1. 注册信号处理函数:pcntl_signal()

pcntl_signal() 函数用来注册信号处理函数。当进程收到指定的信号时,系统会自动调用该函数。

<?php

// 信号处理函数
function signal_handler($signal) {
    switch($signal) {
        case SIGTERM:
            echo "收到 SIGTERM 信号,准备退出...n";
            exit(0);
            break;
        case SIGUSR1:
            echo "收到 SIGUSR1 信号,执行自定义操作...n";
            // 执行一些自定义操作
            break;
        default:
            echo "收到未知信号:". $signal . "n";
    }
}

// 注册信号处理函数
pcntl_signal(SIGTERM, "signal_handler");
pcntl_signal(SIGUSR1, "signal_handler");

// 主循环
while (true) {
    echo "程序正在运行...n";
    sleep(1);
    pcntl_signal_dispatch(); // 处理未决信号
}

?>

这段代码注册了 SIGTERMSIGUSR1 信号的处理函数。当进程收到 SIGTERM 信号时,会执行退出操作。当进程收到 SIGUSR1 信号时,会执行一些自定义操作。

重要提示: 在 PHP 中,信号处理函数必须是全局函数,不能是类的成员函数。另外,为了确保信号处理函数能够被及时调用,需要在主循环中调用 pcntl_signal_dispatch() 函数,它会处理所有未决的信号。

2. 发送信号:posix_kill()

posix_kill() 函数用来向指定的进程发送信号。

<?php

$pid = pcntl_fork();

if ($pid == -1) {
    die('啊哦,fork失败了!');
} else if ($pid) {
    // 父进程
    echo "我是父进程,我的PID是:". getmypid() . ", 子进程的PID是:". $pid . "n";
    sleep(2); // 等待子进程启动
    posix_kill($pid, SIGUSR1); // 向子进程发送 SIGUSR1 信号
    pcntl_waitpid($pid, $status);
    echo "子进程已经结束n";

} else {
    // 子进程
    echo "我是子进程,我的PID是:". getmypid() . "n";
    pcntl_signal(SIGUSR1, function($signal) {
        echo "子进程收到 SIGUSR1 信号n";
    });
    while (true) {
        pcntl_signal_dispatch();
        sleep(1);
    }
    exit(0);
}

?>

这段代码中,父进程向子进程发送 SIGUSR1 信号,子进程收到信号后会执行相应的处理函数。

常用的信号:

信号名称 含义
SIGTERM 终止信号 (通常由 kill 命令发送)
SIGKILL 强制终止信号 (无法被捕获)
SIGINT 中断信号 (通常由 Ctrl+C 发送)
SIGUSR1 用户自定义信号 1
SIGUSR2 用户自定义信号 2
SIGHUP 挂起信号 (通常由终端断开连接发送)
SIGCHLD 子进程状态改变信号 (子进程结束、停止或继续)

四、多进程编程的注意事项

  • 资源竞争: 多个进程同时访问共享资源时,可能会发生资源竞争。需要使用锁机制 (例如 flock() 函数) 来保护共享资源。
  • 僵尸进程: 子进程结束后,如果没有被父进程回收,就会变成僵尸进程,占用系统资源。父进程必须调用 pcntl_wait()pcntl_waitpid() 函数来回收子进程。
  • 信号处理: 信号处理函数必须是全局函数,并且需要在主循环中调用 pcntl_signal_dispatch() 函数。
  • 错误处理: 多进程编程更容易出错,需要仔细考虑各种错误情况,并做好错误处理。
  • 调试: 多进程程序的调试比较困难,可以使用 var_dump()error_log() 等函数来输出调试信息。
  • 进程数量: 不要创建过多的进程,否则会占用大量的系统资源,反而降低效率。

五、总结

pcntl 扩展为 PHP 带来了强大的多进程编程能力。通过 pcntl_fork()pcntl_wait()pcntl_exec() 等函数,你可以创建、控制和管理进程。通过管道、共享内存、消息队列等方式,你可以实现进程间通信。通过信号处理,你可以优雅地处理各种事件。

当然,多进程编程也需要注意一些问题,例如资源竞争、僵尸进程、信号处理等等。只有掌握了这些知识,才能编写出高效、稳定、可靠的多进程 PHP 程序。

好了,今天的讲座就到这里。希望大家能够掌握 pcntl 扩展的使用方法,让你的 PHP 代码也能玩转多进程。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注