PHP多进程、信号量及孤儿进程和僵尸进程
实际上PHP是有多进程的,有一些人再用,总体来说php的多进程还算凑合,只需要在安装PHP的时候开启pcntl
模块(是不是跟UNIX中的fcntl有点儿…. ….)即可。在*NIX下,在终端命令行下使用php -m
就可以看到是否开启了pcntl
模块。
所以我们只说php的多进程,至于php多线程就暂时放到一边儿。
注意:不要在apache或者fpm环境下使用php多进程,这将会产生不可预估的后果。
PHP多进程初探
进程是程序执行的实例,举个例子有个程序叫做 “ 病毒.exe ”,这个程序平时是以文件形式存储在硬盘上,当你双击运行后,就会形成一个该程序的进程。系统会给每一个进程分配一个唯一的非负整数用来标记进程,这个数字称作进程ID。当该进程被杀死或终止后,其进程ID就会被系统回收,然后分配给新的其余的进程。
说了这么多,这鬼东西有什么用吗?我平时用CI、YII写个CURD跟这个也没啥关联啊。实际上,如果你了解APACHE PHP MOD或者FPM就知道这些东西就是多进程实现的。以FPM为例,一般都是nginx作为http服务器挡在最前面,静态文件请求则nginx自行处理,遇到php动态请求则转发给php-fpm进程来处理。如果你的php-fpm配置只开了5个进程,如果处理任意一个用户的请求都需要1秒钟,那么5个fpm进程1秒中就最多只能处5个用户的请求。所以结论就是:如果要单位时间内干活更快更多,就需要更多的进程,总之一句话就是多进程可以加快任务处理速度。
在php中我们使用pcntl_fork()
来创建多进程(在*NIX系统的C语言编程中,已有进程通过调用fork函数来产生新的进程)。fork出来新进程则成为子进程,原进程则成为父进程,子进程拥有父进程的副本。这里要注意:
- 子进程与父进程共享程序正文段
- 子进程拥有父进程的数据空间和堆、栈的副本,注意是副本,不是共享
- 父进程和子进程将继续执行fork之后的程序代码
- fork之后,是父进程先执行还是子进程先执行无法确认,取决于系统调度(取决于信仰)
这里说子进程拥有父进程数据空间以及堆、栈的副本,实际上,在大多数的实现中也并不是真正的完全副本。更多是采用了COW(Copy On Write)即写时复制的技术来节约存储空间。简单来说,如果父进程和子进程都不修改这些 数据、堆、栈 的话,那么父进程和子进程则是暂时共享同一份 数据、堆、栈。只有当父进程或者子进程试图对 数据、堆、栈 进行修改的时候,才会产生复制操作,这就叫做写时复制。
在调用完pcntl_fork()
后,该函数会返回两个值。在父进程中返回子进程的进程ID,在子进程内部本身返回数字0。由于多进程在apache
或者fpm
环境下无法正常运行,所以大家一定要在php cli
环境下执行下面php代码。
第一段代码,我们来说明在程序从pcntl_fork()
后父进程和子进程将各自继续往下执行代码:
$pid = pcntl_fork();
if( $pid > 0 ){
echo "我是父亲".PHP_EOL;
} else if( 0 == $pid ) {
echo "我是儿子".PHP_EOL;
} else {
echo "fork失败".PHP_EOL;
}
将文件保存为test.php
,然后在使用cli执行,结果如下图所示:
第二段代码,用来说明子进程拥有父进程的数据副本,而并不是共享:
// 初始化一个 number变量 数值为1
$number = 1;
$pid = pcntl_fork();
if( $pid > 0 ){
$number += 1;
echo "我是父亲,number+1 : { $number }".PHP_EOL;
} else if( 0 == $pid ) {
$number += 2;
echo "我是父亲,number+2 : { $number }".PHP_EOL;
} else {
echo "fork失败".PHP_EOL;
}
第三段代码,比较容易让人思维混乱,pcntl_fork()
配合for
循环来做些东西,问题来了:会显示几次 “ 儿子 ”?
for( $i = 1; $i <= 3 ; $i++ ){
$pid = pcntl_fork();
if( $pid > 0 ){
// do nothing ...
} else if( 0 == $pid ){
echo "儿子".PHP_EOL;
}
}
上面代码执行结果如下:
仔细数数,竟然是显示了7次 “ 儿子 ”。好奇怪,难道不是3次吗?… …
下面我修改一下代码,结合下面的代码,再思考一下为什么会产生7次而不是3次。
for( $i = 1; $i <= 3 ; $i++ ){
$pid = pcntl_fork();
if( $pid > 0 ){
// do nothing ...
} else if( 0 == $pid ){
echo "儿子".PHP_EOL;
exit;
}
}
执行结果如下图所示:
前面强调过:父进程和子进程将继续执行fork之后的程序代码。这里就不解释,实在想不明白的,可以动手自己画画思考一下。
孤儿与僵尸进程
实际上,你们一定要记住:PHP的多进程是非常值得应用于生产环境具备高价值的生产力工具。
但我认为在正式开始吹牛之前还是要说两个基本概念:孤儿进程、僵尸进程。
上文我整篇尬聊的都是pcntl_fork()
,只管fork生产,不管产后护理,实际上这样并不符合主流价值观,而且,操作系统本身资源有限,这样无限生产不顾护理,操作系统也会吃不消的。
孤儿进程是指父进程在fork出子进程后,自己先完了。这个问题很尴尬,因为子进程从此变得无依无靠、无家可归,变成了孤儿。用术语来表达就是,父进程在子进程结束之前提前退出,这些子进程将由init(进程ID为1)进程收养并完成对其各种数据状态的收集。init进程是Linux系统下的奇怪进程,这个进程是以普通用户权限运行但却具备超级权限的进程,简单地说,这个进程在Linux系统启动的时候做初始化工作,比如运行getty、比如会根据/etc/inittab中设置的运行等级初始化系统等等,当然了,还有一个作用就是如上所说的:收养孤儿进程。
僵尸进程是指父进程在fork出子进程,而后子进程在结束后,父进程并没有调用wait或者waitpid等完成对其清理善后工作,导致改子进程进程ID、文件描述符等依然保留在系统中,极大浪费了系统资源。所以,僵尸进程是对系统有危害的,而孤儿进程则相对来说没那么严重。在Linux系统中,我们可以通过ps -aux来查看进程,如果有[Z+]标记就是僵尸进程。
在PHP中,父进程对子进程的状态收集等是通过pcntl_wait()
和pcntl_waitpid()
等完成的。依然还是要通过代码还演示说明:
演示并说明孤儿进程的出现,并演示孤儿进程被init进程收养:
$id = pcntl_fork();
if( $pid > 0 ){
// 显示父进程的进程ID,这个函数可以是getmypid(),也可以用posix_getpid()
echo "Father PID:".getmypid().PHP_EOL;
// 让父进程停止两秒钟,在这两秒内,子进程的父进程ID还是这个父进程
sleep( 2 );
} else if( 0 == $pid ) {
// 让子进程循环10次,每次睡眠1s,然后每秒钟获取一次子进程的父进程进程ID
for( $i = 1; $i <= 10; $i++ ){
sleep( 1 );
// posix_getppid()函数的作用就是获取当前进程的父进程进程ID
echo posix_getppid().PHP_EOL;
}
} else {
echo "fork error.".PHP_EOL;
}
运行结果如下图:
可以看到,前两秒内,子进程的父进程进程ID为4129,但是从第三秒开始,由于父进程已经提前退出了,子进程变成孤儿进程,所以init进程收养了子进程,所以子进程的父进程进程ID变成了1。
演示并说明僵尸进程的出现,并演示僵尸进程的危害:
$pid = pcntl_fork();
if( $pid > 0 ){
// 下面这个函数可以更改php进程的名称
cli_set_process_title('php father process');
// 让主进程休息60秒钟
sleep(60);
} else if( 0 == $pid ) {
cli_set_process_title('php child process');
// 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
sleep(10);
} else {
exit('fork error.'.PHP_EOL);
}
运行结果如下图:
通过执行ps -aux命令可以看到,当程序在前十秒内运行的时候,php child process的状态列为[S+],然而在十秒钟过后,这个状态变成了[Z+],也就是变成了危害系统的僵尸进程。
那么,问题来了?如何避免僵尸进程呢?PHP通过pcntl_wait()和pcntl_waitpid()两个函数来帮我们解决这个问题。了解Linux系统编程的应该知道,看名字就知道这其实就是PHP把C语言中的wait()和waitpid()包装了一下。
通过代码演示pcntl_wait()来避免僵尸进程,在开始之前先简单普及一下pcntl_wait()的相关内容:这个函数的作用就是 “ 等待或者返回子进程的状态 ”,当父进程执行了该函数后,就会阻塞挂起等待子进程的状态一直等到子进程已经由于某种原因退出或者终止。换句话说就是如果子进程还没结束,那么父进程就会一直等等等,如果子进程已经结束,那么父进程就会立刻得到子进程状态。这个函数返回退出的子进程的进程ID或者失败返回-1。
我们将第二个案例中代码修改一下:
$pid = pcntl_fork();
if( $pid > 0 ){
// 下面这个函数可以更改php进程的名称
cli_set_process_title('php father process');
// 返回$wait_result,就是子进程的进程号,如果子进程已经是僵尸进程则为0
// 子进程状态则保存在了$status参数中,可以通过pcntl_wexitstatus()等一系列函数来查看$status的状态信息是什么
$wait_result = pcntl_wait( $status );
print_r( $wait_result );
print_r( $status );
// 让主进程休息60秒钟
sleep(60);
} else if( 0 == $pid ) {
cli_set_process_title('php child process');
// 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
sleep(10);
} else {
exit('fork error.'.PHP_EOL);
}
将文件保存为wait.php,然后php wait.php,在另外一个终端中通过ps -aux查看,可以看到在前十秒内,php child process是[S+]状态,然后十秒钟过后进程消失了,也就是被父进程回收了,没有变成僵尸进程。
但是,pcntl_wait()
有个很大的问题,就是阻塞。父进程只能挂起等待子进程结束或终止,在此期间父进程什么都不能做,这并不符合多快好省原则,所以pcntl_waitpid()
闪亮登场。pcntl_waitpid( $pid, &$status, $option = 0 )
的第三个参数如果设置为WNOHANG
,那么父进程不会阻塞一直等待到有子进程退出或终止,否则将会和pcntl_wait()
的表现类似。
修改第三个案例的代码,但是,我们并不添加WNOHANG
,演示说明pcntl_waitpid()
功能:
$pid = pcntl_fork();
if( $pid > 0 ){
// 下面这个函数可以更改php进程的名称
cli_set_process_title('php father process');
// 返回值保存在$wait_result中
// $pid参数表示 子进程的进程ID
// 子进程状态则保存在了参数$status中
// 将第三个option参数设置为常量WNOHANG,则可以避免主进程阻塞挂起,此处父进程将立即返回继续往下执行剩下的代码
$wait_result = pcntl_waitpid( $pid, $status );
var_dump( $wait_result );
var_dump( $status );
// 让主进程休息60秒钟
sleep(60);
} else if( 0 == $pid ) {
cli_set_process_title('php child process');
// 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
sleep(10);
} else {
exit('fork error.'.PHP_EOL);
}
下面是运行结果,一个执行php程序的终端窗口,另一个是ps -aux终端窗口。实际上可以看到主进程是被阻塞的,一直到第十秒子进程退出了,父进程不再阻塞:
那么我们修改第四段代码,添加第三个参数WNOHANG,代码如下:
$pid = pcntl_fork();
if( $pid > 0 ){
// 下面这个函数可以更改php进程的名称
cli_set_process_title('php father process');
// 返回值保存在$wait_result中
// $pid参数表示 子进程的进程ID
// 子进程状态则保存在了参数$status中
// 将第三个option参数设置为常量WNOHANG,则可以避免主进程阻塞挂起,此处父进程将立即返回继续往下执行剩下的代码
$wait_result = pcntl_waitpid( $pid, $status, WNOHANG );
var_dump( $wait_result );
var_dump( $status );
echo "不阻塞,运行到这里".PHP_EOL;
// 让主进程休息60秒钟
sleep(60);
} else if( 0 == $pid ) {
cli_set_process_title('php child process');
// 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
sleep(10);
} else {
exit('fork error.'.PHP_EOL);
}
下面是运行结果,一个执行php程序的终端窗口,另一个是ps -aux终端窗口。实际上可以看到主进程是被阻塞的,一直到第十秒子进程退出了,父进程不再阻塞:
问题出现了,竟然php child process进程状态竟然变成了[Z+],这是怎么搞得?回头分析一下代码:
我们看到子进程是睡眠了十秒钟,而父进程在执行pcntl_waitpid()之前没有任何睡眠且本身不再阻塞,所以,主进程自己先执行下去了,而子进程在足足十秒钟后才结束,进程状态自然无法得到回收。如果我们将代码修改一下,就是在主进程的pcntl_waitpid()前睡眠15秒钟,这样就可以回收子进程了。但是即便这样修改,细心想的话还是会有个问题,那就是在子进程结束后,在父进程执行pcntl_waitpid()回收前,有五秒钟的时间差,在这个时间差内,php child process也将会是僵尸进程。那么,pcntl_waitpid()如何正确使用啊?这样用,看起来毕竟不太科学。
那么,是时候引入信号量了!
PHP 信号量
信号是一种软件中断,也是一种非常典型的异步事件处理方式。在NIX系统诞生的混沌之初,信号的定义是比较混乱的,而且最关键是不可靠,这是一个很严重的问题。所以在后来的POSIX标准中,对信号做了标准化同时也各个发行版的NIX也都提供大量可靠的信号。每种信号都有自己的名字,大概如SIGTERM、SIGHUP、SIGCHLD等等,在*NIX中,这些信号本质上都是整形数字(游有心情的可以参观一下signal.h系列头文件)。
信号的产生是有多种方式的,下面是常见的几种:
- 键盘上按某些组合键,比如Ctrl+C或者Ctrl+D等,会产生SIGINT信号。
- 使用posix kill调用,可以向某个进程发送指定的信号。
- 远程ssh终端情况下,如果你在服务器上执行了一个阻塞的脚本,正在阻塞过程中你关闭了终端,可能就会产生SIGHUP信号。
- 硬件也会产生信号,比如OOM了或者遇到除0这种情况,硬件也会向进程发送特定信号。
而进程在收到信号后,可以有如下三种响应:
- 直接忽略,不做任何反映。就是俗称的完全不鸟。但是有两种信号,永远不会被忽略,一个是SIGSTOP,另一个是SIGKILL,因为这两个进程提供了向内核最后的可靠的结束进程的办法。
- 捕捉信号并作出相应的一些反应,具体响应什么可以由用户自己通过程序自定义。
- 系统默认响应。大多数进程在遇到信号后,如果用户也没有自定义响应,那么就会采取系统默认响应,大多数的系统默认响应就是终止进程。
用人话来表达,就是说假如你是一个进程,你正在干活,突然施工队的喇叭里冲你嚷了一句:“吃饭了!”,于是你就放下手里的活儿去吃饭。你正在干活,突然施工队的喇叭里冲你嚷了一句:“发工资了!”,于是你就放下手里的活儿去领工资。你正在干活,突然施工队的喇叭里冲你嚷了一句:“有人找你!”,于是你就放下手里的活儿去看看是谁找你什么事情。当然了,你很任性,那是完全可以不鸟喇叭里喊什么内容,也就是忽略信号。也可以更任性,当喇叭里冲你嚷“吃饭”的时候,你去就不去吃饭,你去睡觉,这些都可以由你来。而你在干活过程中,从来不会因为要等某个信号就不干活了一直等信号,而是信号随时随地都可能会来,而你只需要在这个时候作出相应的回应即可,所以说,信号是一种软件中断,也是一种异步的处理事件的方式。
回到上文所说的问题,就是子进程在结束前,父进程就已经先调用了pcntl_waitpid()
,导致子进程在结束后依然变成了僵尸进程。实际上在父进程不断while循环调用pcntl_waitpid()
是个解决办法,大概代码如下:
$pid = pcntl_fork();
if (0 > $pid) {
exit('fork error.' . PHP_EOL);
} else {
if (0 < $pid) {
// 在父进程中
cli_set_process_title('php father process');
// 父进程不断while循环,去反复执行pcntl_waitpid(),从而试图解决已经退出的子进程
while (true) {
sleep(1);
pcntl_waitpid($pid, &$status, WNOHANG);
}
} else {
if (0 == $pid) {
// 在子进程中
// 子进程休眠3秒钟后直接退出
cli_set_process_title('php child process');
sleep(20);
exit;
}
}
}
下图是运行结果:
解析一下这个结果,我先后三次执行了ps -aux | grep php去查看这两个php进程。
- 第一次:子进程正在休眠中,父进程依旧在循环中。
- 第二次:子进程已经退出了,父进程依旧在循环中,但是代码还没有执行到pcntl_waitpid(),所以在子进程退出后到父进程执行回收前这段空隙内子进程变成了僵尸进程。
- 第三次:此时父进程已经执行了pcntl_waitpid(),将已经退出的子进程回收,释放了pid等资源。
但是这样的代码有一个缺陷,实际上就是子进程已经退出的情况下,主进程还在不断while pcntl_waitpid()
去回收子进程,这是一件很奇怪的事情,并不符合社会主义主流价值观,不低碳不节能,代码也不优雅,不好看。所以,应该考虑用更好的方式来实现。那么,我们篇头提了许久的信号终于概要出场了。
现在让我们考虑一下,为何信号可以解决“不低碳不节能,代码也不优雅,不好看”的问题。子进程在退出的时候,会向父进程发送一个信号,叫做SIGCHLD
,那么父进程一旦收到了这个信号,就可以作出相应的回收动作,也就是执行pcntl_waitpid()
,从而解决掉僵尸进程,而且还显得我们代码优雅好看节能环保。
梳理一下流程,子进程向父进程发送SIGCHLD信号是对人们来说是透明的,也就是说我们无须关心。但是,我们需要给父进程安装一个响应SIGCHLD信号的处理器,除此之外,还需要让这些信号处理器运行起来,安装上了不运行是一件尴尬的事情。那么,在php里给进程安装信号处理器使用的函数是pcntl_signal(),让信号处理器跑起来的函数是pcntl_signal_dispatch()
。
pcntl_signal()
,安装一个信号处理器,具体说明是pcntl_signal ( int $signo , callback $handler [, bool $restart_syscalls = true ] )
,参数signo就是信号,callback则是响应该信号的代码段,返回bool值。pcntl_signal_dispatch()
,调用每个等待信号通过pcntl_signal()
安装的处理器,参数为void,返回bool值。
下面结合新引入的两个函数来解决一下楼上的丑陋代码:
$pid = pcntl_fork();
if( 0 > $pid ){
exit('fork error.'.PHP_EOL);
} else if( 0 < $pid ) {
// 在父进程中
// 给父进程安装一个SIGCHLD信号处理器
pcntl_signal( SIGCHLD, function() use( $pid ) {
echo "收到子进程退出".PHP_EOL;
pcntl_waitpid( $pid, $status, WNOHANG );
} );
cli_set_process_title('php father process');
// 父进程不断while循环,去反复执行pcntl_waitpid(),从而试图解决已经退出的子进程
while( true ){
sleep( 1 );
// 注释掉原来老掉牙的代码,转而使用pcntl_signal_dispatch()
//pcntl_waitpid( $pid, &$status, WNOHANG );
pcntl_signal_dispatch();
}
} else if( 0 == $pid ) {
// 在子进程中
// 子进程休眠3秒钟后直接退出
cli_set_process_title('php child process');
sleep( 20 );
exit;
}
运行结果如下:
PHP 预定义了一些信号量,可参看 http://php.net/manual/zh/pcntl.constants.php。
原文地址:
https://blog.ti-node.com/blog/6363989547574886401
https://blog.ti-node.com/blog/6375675957193211905
https://blog.ti-node.com/blog/6375380006637404161