1 概述

很多以PHP为编程语言的Web项目,在需要做异步业务、数据的处理时,仍然会选择使用PHP语言来编写任务脚本。这么做的优点不言而喻,通用的底层model、业务service、熟悉的公共类及方法,让熟悉Web业务开发的工程师可以花最快的时间以及最小的学习成本快速编写出特定场景的业务脚本。

然而PHP是为Web网页制作而生的单进程处理语言,当他遇到大数据场景下的业务数据的处理需求时,如果仍然依靠单进程的方式进行,不仅耗费更多时间、浪费服务器资源,而且可能很快就会遇到性能瓶颈。在这样一种场景需求下,PHP语言的多进行/线程处理能力就显得尤为需要了。

PHP对多进程处理的支持虽然已经到来了(https://github.com/krakjoe/pthreads ),然而它却只能在PHP7.2+以上的版本中运行,而且需要Thread Safety特性的支持。在这样一种现状下,使PHP支持多进程处理似乎是唯一(但不是最坏)的选择了。

2 PHP的多进程管理(PCNTL扩展)

PCNTL扩展 实现了类Unix系统中的进程创建、执行、信号处理以及最终的进程终止,更具体的实现方式可以参考类Unix系统提供的fork(2)、waitpid(2)和signal(2)函数接口。

贝贝PHP的开发环境默认配置了PCNTL扩展,如果你的环境需要安装或配置该扩展,还请自行查找方法,此处略过。

对PCNTL扩展有了初步的认识后,我们先来编写一个多进程的demo任务练练手,熟悉一下该扩展中核心函数pcntl_fork的使用场景及方法.

2.1 示例1:简易demo

<?php
print "I'm in the Main process!\n";
 
// 当前进程:主进程
$pid = pcntl_fork();
// pcntl_fork()被执行后,系统会立即创建子进程,而且子进程的执行入口为紧跟在pcntl_fork()之后的代码,与主进程即将执行的代码一致
print "Who am I ?\n";
 
// 此时需要一种机制或约定来让编程人员判断当前代码执行的环境为主进程中or子进程中
if ($pid === -1) {
    print "Fork child process faield, exit!\n";
    exit(1);
} elseif ($pid === 0) {
    print "I'm in the child process!\n";
    print "Child process exit.\n";
    exit(0);
} elseif ($pid > 0) {
    print "I'm still in the main process!\n";
    print "Main process exit.\n";
    exit(0);
}

上面那段代码的输出会是什么样子呢?

执行结果

[root@1d42e4522359 tmp]# php test.php
I'm in the Main process!
Who am I ?
I'm still in the main process!
Main process exit.
Who am I ?
I'm in the child process!
Child process exit.

从上面的示例代码中可以看出:

  • 不同于多线程中只会被新线程执行的特定代码块,多进程代码在被执行时,父进程与子进程几乎共用同一份代码,即pcntl_fork()之后的代码
  • 由于子进程被创建之后,父进程与子进程就相当于两个独立的进程,二者共用pcntl_fork()之后的代码,而且执行顺序没有保证,所以需要通过pcntl_fork()函数的返回值来判断当前进程所属父/子进程,以便通过if-else判断为父/子进程预设置不同的执行代码。pcntl_fork()函数的返回值$pid有三种情况:
    • $pid = -1: 子进程创建失败,通常情况下子进程创建失败,程序都会记录原因后主动退出
    • $pid > 0: 子进程创建成功。当前进程为父进程,其中$pid的值即为刚创建成功的子进程的进程id
    • $pid = 0: 子进程创建成功。当前进程为子进程

2.2 示例2:实际应用场景初探

下面我们来编写一例有实际应用场景的例子:父进程创建2个子进程,每个子进程的工作内容相同:从主进程分配给自己的网址中抓取网页的标题字段并打印出来,然后退出程序。父进程在sleep几秒(其实是在等待子程序执行完并主动退出)后也退出程序。

<?php
 
function printLine($msg) {
    print $msg . "\n";
}
 
function get_url_title($url) {
    $content = file_get_contents($url);
    $match_arr = NULL;
    if ($content) {
        preg_match('/<title>(.+?)<\/title>/', $content, $match_arr);
    }
 
    if ($match_arr && count($match_arr) === 2) {
        return $match_arr[1];
    }
    return NULL;
}
 
printLine('Task begin.');
 
$site_urls = array(
    'https://www.beibei.com',
    'https://www.jd.com',
);
 
 
foreach($site_urls as $_url) {
 
    $pid = pcntl_fork();
 
    if ($pid === -1) {
        printLine("Fork child process faield, exit!");
        exit(1);
    } elseif ($pid === 0) {
        printLine("In the child process!");
        printLine($_url . ' title => ' . get_url_title($_url));
        printLine("Child process exit.");
        exit(0);
    } elseif ($pid > 0) {
        printLine("In the parent process, do nothing.");
    }
}
 
$i = 7;
while ($i--) {
    sleep(1);
}
printLine("Task end.");

运行结果

[root@1d42e4522359 beibei]# php testpcntl.php
Task begin.
In the parent process, do nothing.
In the child process!
In the parent process, do nothing.
In the child process!
https://www.beibei.com title => 贝贝网-买母婴上贝贝!1亿妈妈信赖的母婴正品特卖商城
Child process exit.
https://www.jd.com title => 京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物!
Child process exit.
Task end.

从上面的执行结果中我们又可以总结出PCNTL扩展函数的几个特点:

  • 1)子进程可以使用(继承了)父进程Context中的变量(如$_url)以及函数(如get_url_title()方法)
  • 2)如果移除父进程最后的sleep代码你会发现,子进程在父进程退出后依然可以正常运行,并不会随主进程的退出而退出

3 PCNTL扩展进阶使用

3.1 监控子进程的运行

在实际的任务框架编写中,开发者通常会首先创建一个master进程,然后再由master进程创建N个子进程。其中每个子进程作为工作进程(worker process)进行实际的业务处理,master进程作为管理者,主要负责工作进程的调度、监管、维护、回收等内容。到了某个具体业务处理的场景下,对应业务的开发者仅需要完成自己业务逻辑部分,并将其作为工作进程的一部分,即可快速嵌入到整体的任务框架中去。

在这种任务框架模式下,master进程对子进程的监控及控制能力就显得尤为重要了。那么如何借用PCNTL扩展的函数接口对子进程进行监管与控制呢?

对子进程的监控,主要分为以下两个方面:

  • 1) 需要记录每个子进程的进程ID,这个过程在进程创建时即可获取到;
  • 2) 可以通过子进程的进程ID获取子进程的运行状态(是否已结束、是否为僵尸进程)

对子进程的控制,主要是指:可以通过子进程的进程ID获取其运行状态,并可以停止或中断其运行。

获取子进程运行状态,需要用到函数pcntl_waitpid(int $pid, int &$status, int $options = 0)。本函数依赖于Linux系统的waitpid(2)函数的实现,作用为:阻断当前进程直到给定$pid的子进程退出(当遇到系统信号需要终止运行或调用信号处理函数时会转去进行信号处理)。注意该函数默认是阻塞型函数(除非$options设置为WNOHANG),其会根据入参的不同,做出多种可能的处理和返回(不过由于当前进程通常都为管理子进程的父进程,并非worker进程,所以即便阻塞也没关系)。参数解释:

  • $pid:根据$pid值的范围进行不同的处理:
    • $pid < -1: 等待指定进程组ID下所有子进程的退出。其中进程组ID = abs($pid),即$pid的绝对值
    • $pid = -1: 等待任意子进程(这里不太清楚,任意是指:用户空间的所有fork出的子进程?)的退出
    • $pid = 0: 等待指定进程组ID下所有子进程的退出。其中进程组ID = 当前进程的进程组ID
    • $pid > 0: 等待进程ID为$pid的子进程退出
  • $status会在调用结束后被赋予子进程的状态信息,对$status结果的判断可以使用以下函数:
    • pcntl_wifexited($status): 判断子进程是否正常退出
    • pcntl_wifstopped($status): 判断子进程是否已停止
    • pcntl_wstopsig($status): 获取导致子进程停止(stop)的信号(signal),当pcntl_wifstopped返回TRUE时使用
    • pcntl_wifsignaled($status): 判断子进程是否由于某信号而退出
    • pcntl_wtermsig($status): 获取导致子进程终止运行(terminate)的信号(signal),当pcntl_wifsignaled返回TRUE时使用
    • pcntl_wexitstatus($status): 获取已终止子进程的退出值(exit_code)
  • $options: 默认无需设置。但想要使用下面两种值需要系统底层支持。参数使用背景:子进程退出时,内核会保存子进程的退出状态信息,直到父进程来消费这个状态信息,内核保存的状态信息即可释放
    • WNOHANG 作用:如果父进程不想阻塞式获取子进程状态可传入该值,则如果没有子进程退出,本次函数调用会立即return 0
    • WUNTRACED 作用:返回:进程已经stop了,但状态信息未被消费(取走)的子进程$pid
  • @return: 本次函数调用有3中返回结果,他们分别是:
    • result > 0: 返回值为:已退出的子进程的进程ID
    • result = -1: 函数调用出错
    • result = 0: 当使用WNOHANG选项,并且无子进程退出时会返回0

控制子进程的运行,主要是通过向子进程发送信息完成的。向子进程发送信号需要用到函数posix_kill ($pid, $signal)

3.2 父/子进程的信号处理

对进程的信号处理,主要是通过注册信号回调函数完成的,信号注册函数需要用到:pcntl_signal ($signo, $handler, $restart_syscalls = true)函数。 我们的任务要想优雅退出的话,就必须让父进程监听常规的SIGTERMSIGINTSIGHUP等信号,除此之外为了能即时知晓子进程退出、中止、恢复等情况,我们也可以让父进程监听SIGCHLD`信号,并即时更新该子进程的对应状态。

3.3 任务(进程)的后台执行

为了让任务进程能在我们退出登录后,仍然持久运行,我们需要将进程设置为daemon状态。在此之前我们需要了解一下进程所包含的基础信息:SessionId,GroupId, ParentId(暂时忽略进程自己的ID PID)

何为SessionID?当我们登入Linux系统时,是藉由某个Terminal提供的CLI连接到Linux上的。Linux为会这个Terminal分配一个SessionId,用来标志用户的本次登录。用户在本次登录后启动的普通进程的SessionId,便是Linux为Terminal分配的SessionId,这些普通进程在用户登出后会被回收。

何为GroupId?当我们通过管道命令执行ls | grep "java" | more这组命令时,其实是三个进程被启动,为了将他们作为整体统一管理(比如Ctrl+C终止本地管道命令),Linux会为本次的三个进程分配同一个GroupId,方便统一向该Group发送信号以控制其中的进程的运行。

何为ParentId?当父进程fork出子进程时,子进程的ParentId便是父进程的PID

言归正传,为了让我们的任务进程在退出登录后仍然持久运行,我们需要做以下两步:

  • 第一步:在程序初始化的地方fork子进程,然后当前进程退出,由新建子的进程作为后续master进程,并负责之后fork、管理工作进程的任务;
  • 第二部:以新建子进程为基础,重新生成独立于本次登录的SessionID,防止用户登出后新建子进程退出。这一步是通过执行posix_setsid()系统调用实现的

完整的Demo如下:

$pid = pcntl_fork();
if ($pid == -1) {
    echo "fork failed";
    exit(1);
} else if ($pid > 0) {
    exit(0); // 父进程退出
}

// ↓↓↓新fork的子进程的执行部分

// 重置SessionID
if (posix_setsid() == -1) {
    echo 'posix_setsid() failed';
    exit(1);
}

// 后续逻辑
...

附录:通用PHP任务框架流程图

通用PHP任务框架流程图:PHP多进程任务流程图.svg