PHP Pcntl 详解,实现多进程处理

Posted by Cann on May 21, 2019

前言

初次使用 PHP 的 Pcntl实现多进程,做个记录

环境

php中的进程是以扩展的形式来完成。通过这些扩展,我们能够很轻松的完成进程的一系列动作。

  • pcntl扩展:主要的进程扩展,完成进程创建于等待操作。
  • posix扩展:完成posix兼容机通用api,如获取进程id,杀死进程等。
  • sysvmsg扩展:实现system v方式的进程间通信之消息队列。
  • sysvsem扩展:实现system v方式的信号量。
  • sysvshm扩展:实现system v方式的共享内存。
  • sockets扩展:实现socket通信。

Pcntl 无法在 Windows 环境下运行,且只能在 Cli中运行

使用 Pcntl

创建子进程

通过 pcntl_fock 函数创建子进程:

echo 'Started!';

$pid = pcntl_fock();

if ($pid == -1) {
  echo 'Fork Children Failed';
}

elseif ($pid == 0) {
  echo 'In The Children Process';
}

elseif ($pid > 0) {
  echo 'In The Mail Process';
}

echo 'Ended';

进程从 pcntl_fock 开始,会产生分叉,主进程和子进程都会执行 pcntl_fock 所有的代码,可以通过 $pid 判断代码是执行在哪个进程:

  • pid = 0 : 当前在子进程
  • pid > 0:当前在主进程,pid为创建出来的子进程的进程号

例如以上代码在 Fork 成功的情况下,会产生如下输出:

Started! // 主进程输出
In The Main Process // 主进程输出
In The Children Process // 子进程输出
Ended // 主进程输出
Ended // 子进程输出

需要注意的是,如果是用循环的方式创建子进程,子进程也会继续执行循环,递归创建子程序,例如:

    for ($batch = 1; $batch <= 3; $batch++) {

        $pid = pcntl_fork();

        // 子进程处理逻辑
        if ($pid == 0) {
            echo 'In The Children Process';
        }

        // 主进程处理逻辑
        elseif ($pid > 0) {
            echo 'In The Main Process';
        }
    }

这段代码会创建 7 个子进程,而不是 3 个。当 $batch = 1 时,子进程会继续执行剩下的两次循环。

获取当前进程ID

我们可以通过 posix_getpid 获取当前进程ID

防止僵尸进程

什么是僵尸进程?

一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait() 或 waitpid() 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这类子进程被称为僵尸进程。

我们详细理解下,在 UNIX/Linux 中,正常情况下,子进程是通过 fork 父进程创建的。子进程和父进程的运行是一个异步过程,理论上父进程无法知道子进程的运行状态。

但知道子进程运行状态是一个很合理的需求,所以 UNIX 提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到。

这种机制就是: 在每个进程退出的时候,内核释放该进程的一部分资源,包括打开的文件、占用的内存等,同时仍然为其保留一定的信息(包括进程号 the process ID,退出状态 the termination status of the process,运行时间 the amount of CPU time taken by the process等)。父进程可以通过 wait()/waitpid() 来获取这些信息,然后操作系统才释放。

这样就产生了一个问题,如果父进程不调用 wait()/waitpid() 的话,那么保留的信息就不会释放,其进程号就会一直被占用,就像僵尸一样,所以把这些进程称为僵尸进程。

僵尸进程的坏处

上面说到僵尸进程由于父进程不回收系统保留的信息而一直占用着系统资源,其中有一项叫做进程描述符。系统通过分配它来启动一个进程。

但是系统所能使用的进程号是有限的,如果存在大量的僵尸进程,系统将因为没有可用的进程号而导致系统不能产生新的进程。

如何查看僵尸进程

僵尸进程在系统中用 <defunct><z> 表示,通过 ps -ef 指令查看进程,如果发现某个进程的状态为 <defunct>/<z>,说明该进程是一个僵尸进程。

ps -ef|grep defunc
如何避免僵尸进程?

在 pcntl 中,父进程可以通过 pcntl_wait()pcntl_waitpid() 函数来等待子进程结束,并回收

  • int pcntl_wait ( int &$status [, int $options ] ):阻塞当前进程,直到当前进程的一个子进程退出或者收到一个结束当前进程的信号。
  • int pcntl_waitpid ( int $pid , int &$status [, int $options ] ):功能同 pcntl_wait,区别为 waitpid 为等待指定 pid 的子进程。当 pid 为 -1 时 pcntl_waitpid 与 pcntl_wait 一样。
echo 'Started!';

$pid = pcntl_fock();

if ($pid == -1) {
  echo 'Fork Children Failed';
}

elseif ($pid == 0) {
  echo 'In The Children Process';
}

elseif ($pid > 0) {
  echo 'In The Mail Process';
}

pcntl_waitpid($status);

echo 'Ended';

需要注意的是 pcntl_waitpcntl_waitpid 默认是阻塞的:

for ($batch = 1; $batch <= 3; $batch++) {

    $pid = pcntl_fork();

    // 子进程处理逻辑
    if ($pid == 0) {
        echo 'In The Children Process';
      	sleep(5);
      	exit;
    }

    // 主进程处理逻辑
    elseif ($pid > 0) {
        echo 'In The Main Process';
      	pcntl_waitpid($pid, $status);
    }
}

上面这段代买,创建第一个子进程后,主进程会等到第一个子进程结束后,再创建子进程。官方推荐的写法为:

$pids = [];

for ($batch = 1; $batch <= 3; $batch++) {

    $pid = pcntl_fork();

    // 子进程处理逻辑
    if ($pid == 0) {
        echo 'In The Children Process';
        sleep(5);
        exit;
    }

    // 主进程处理逻辑
    elseif ($pid > 0) {
        echo 'In The Main Process';
        $pids[] = $pid;
    }
}

// 监听子进程状态,当子进程退出时,回收子进程信息,防止僵尸进程
while (count($pids)) {
    foreach ($pids as $key => $pid) {
        // 非阻塞获取子进程状态
        // $res -1:进程非正常退出,例如信号 0:子进程未退出 大于0:子进程正常退出,res为子进程进程号
        $res = pcntl_waitpid($pid, $status, WNOHANG);
        if ($res == -1 || $res > 0) {
            unset($pids[$key]);
        }
    }
}

子进程的状态信息

通过 pcntl_waitpcntl_waitpid 传指的 $status 参数,我们可以获取子进程的状态信息,我们可以将状态信息传入以下方法获取更多信息:

进程间的通信

信号

信号是一种系统调用。通常我们用的kill命令就是发送某个信号给某个进程的。具体有哪些信号可以在liunx/mac中运行kill -l查看。下面这个例子中,父进程等待5秒钟,向子进程发送sigint信号。子进程捕获信号,掉信号处理函数处理。

我们可以通过 posix_kill发送信号,通过 pcntl_signal 处理信号:

// 定义一个信号处理函数
function signHandler($signo) {
    $pid = posix_getpid();
    exit;
}

$pid = pcntl_fork();

// 子进程执行程序
if ($pid == 0) {

    // 注册信号处理函数
    declare(ticks=10);
    pcntl_signal(SIGINT, "signHandler");

    while(true){
        sleep(1);
    }
    exit;
} 

// 父进程执行程序
elseif ($pid > 0) {
    $childList[$pid] = 1;
    // 5秒后,父进程向子进程发送 sigint 信号.
    sleep(5);
    posix_kill($pid,SIGINT);
    sleep(5);
}

我们可以看到,在代码中我们加了一句declare(ticks=10);,它在这里的作用是每执行10条语句,就去检测是否接收到信号。如果PHP版本大等于5.3,就使用pcntl_singal_dispath 来进行信号派送。

管道

管道是比较常用的多进程通信手段,管道分为无名管道与有名管道,无名管道只能用于具有亲缘关系的进程间通信,而有名管道可以用于同一主机上任意进程。这里只介绍有名管道。下面的例子,子进程写入数据,父进程读取数据。

// 定义管道路径,与创建管道
$pipePath = '/data/test.pipe';

if (! file_exists($pipePath)){
    if(! posix_mkfifo($pipePath, 0664)){
        exit("create pipe error!");
    }
}

$pid = pcntl_fork();

// 子进程,向管道写数据
if ($pid == 0){

    $file = fopen($pipePath, 'w');

    while (true){
        fwrite($file, 'hello world');
        sleep(5);
    }

    exit;
}

// 父进程,从管道读数据
elseif ($pid > 0) {

    $file = fopen($pipePath,'r');

    while (true){
        $rel = fread($file,20);
        echo "{$rel}\n";
        sleep(5);
    }
}