转载请注明文章出处: tlanyan.me/php-review-…
PHP回顾系列目录
为了更好的利用多核CPU,我们需要多进程或多线程。但在常规web开发中,我们极少用到这两种并发技术(
curl_multi
等特殊函数除外)。如果脚本运行在CLI模式下,多进程和多线程技术是提高效率的有力武器。
相对于多线程,多进程的程序具有健壮、无锁、对分布式支持更好等特点。本文来学习一下PHP多的多进程编程。
多进程
PHP中与(多)进程相关的两个重要拓展是
PCNTL
和
POSIX
。
PCNTL
主要用来创建、执行子进程和处理信号,
POSIX
拓展则实现了POSIX标准中定义的接口。由于Windows不是POSIX兼容的,所以
POSIX
拓展在Windows平台上不可用。
先上简单的代码看多进程编程:
// fork.php
$parentId = posix_getpid();
fwrite(STDOUT, "my pid: $parentId\n");
$childNum = 10;
foreach (range(1, $childNum) as $index) {
$pid = pcntl_fork();
if ($pid < 0) {
fwrite(STDERR, "failt to fork!\n");
exit;
}
// parent code
if ($pid > 0) {
fwrite(STDOUT, "fork the {$index}th child, pid: $pid\n");
} else {
$mypid = posix_getpid();
$parentId = posix_getppid();
fwrite(STDOUT, "I'm the {$index}th child and my pid: $mypid, parentId: $parentId\n");
sleep(5);
exit; // 注意这一行
}
}
关键的代码是
pcntl_fork
函数,返回一个整数,小于0表示克隆失败。克隆成功的情况下返回两个值:父进程拿到子进程的进程号,而子进程则得到0。可以根据函数的返回值判断接下来的执行环境在父进程中还是子进程中。
fork调用复制一个与当前进程几乎完全一样的进程,除了进程号等少数信息不一样,执行的代码段、堆栈、数据的值都一致。父进程打开了一个文件,子进程同样享有这个句柄,这是过去多进程能监听同一个端口的原理;如果不通过返回的pid在接下来的代码中条件执行,子进程将基于父进程的当前环境(fork时的环境)继续执行(代码段共享)。
将上述代码中else语句块的
exit
去掉将帮助你理解上面一段话的意思。程序的本意是生成10个子进程,去掉子进程执行代码的
exit
后,子进程执行完else块中代码后继续执行
foreach
循环,最终生成55个子进程(为什么是55个?)!鉴于此,一个良好的实践是在子进程的执行代码后总是加上exit终止语句,除非你对父进程当前的状态和流程把控十分到位。
除了fork,另外一种多进程技术是exec。
system
、
exec
、
proc_open
等函数会生成一个新的进程执行外部命令。这些函数的本质是fork一个进程,然后调用shell执行命令,主进程调等待其执行结束。函数执行期间,主进程除了等待无法处理其他任务,所以一般不认为是多进程编程。实践中可以结合fork和exec/system并发执行外部命令。
孤儿进程与僵尸进程
多进程编程需要考虑到的一个问题是孤儿进程和僵尸进程。进程结束前父进程已经退出,进程变成孤儿进程;进程退出后父进程在执行且未回收子进程,那么进程变成僵尸进程。孤儿进程是仍在执行的进程,僵尸进程则已经停止执行,只剩下进程号一缕孤魂仍能被外界感知。
孤儿进程会被系统的根进程(init进程,进程号为1)接管,运行结束后由根进程回收。用代码说一下孤儿进程的演化过程:
// orphan.php
$pid = pcntl_fork();
if ($pid === 0) {
$myid = posix_getpid();
$parentId = posix_getppid();
fwrite(STDOUT, "my pid: $myid, parentId: $parentId\n");
sleep(5);
$myid = posix_getpid();
$parentId = posix_getppid();
fwrite(STDOUT, "my pid: $myid, parentId: $parentId\n");
} else {
fwrite(STDOUT, "parent exit\n");
}
执行脚本:
php orphan.php
,可以看到类似如下输出:
parent exit
my pid: 14384, parentId: 14383
my pid: 14384, parentId: 1
父进程退出后子进程过继给1号根进程,并由其负责回收子进程。
接着看僵尸进程。主进程长时间运行且不回收子进程,僵尸进程会一直存在,直到主进程退出后变成孤儿进程过继给根进程;如果主进程一直运行,僵尸进程将一直存在。
下面代码演示生成10个僵尸进程:
// zombie.php
foreach (range(1, 10) as $i) {
$pid = pcntl_fork();
if ($pid === 0) {
fwrite(STDOUT, "child exit\n");
exit;
}
}
sleep(200);
exit;
打开终端执行
php zombie.php
,然后新打开一个终端执行
ps aux | grep php | grep -v grep
,一个可能的输出如下:
vagrant 14336 0.3 0.8 344600 15144 pts/1 S+ 05:09 0:00 php zombie.php
vagrant 14337 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14338 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14339 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14340 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14341 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14342 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14343 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14344 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14345 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
vagrant 14346 0.0 0.0 0 0 pts/1 Z+ 05:09 0:00 [php] <defunct>
最后一列为
<defunct>
的进程便是僵尸进程,这些进程的第八列的标志是“Z+”,即Zombie。虽然除了进程号无法回收,僵尸进程并不像僵尸那么恐怖,但我们应该在子进程执行结束后让其安息,避免出现僵尸进程。
回收子进程有两种方式,一种是主进程调用
pcntl_wait/pcntl_waitpid
函数等待回收子进程;另外一种是处理SIGCLD信号。我们先说使用wait函数回收子进程,信号处理放在下面的章节。
PCNT
拓展中用于回收子进程的两个函数是
pcntl_wait
和
pcntl_waitpid
,
pcntl_waitpid
可以指定等待的进程。来看如何用这两个函数回收子进程:
// wait.php
$pid = pcntl_fork();
if ($pid === 0) {
$myid = posix_getpid();
fwrite(STDOUT, "child $myid exited\n");
} else {
sleep(5);
$status = 0;
$pid = pcntl_wait($status, WUNTRACED);
if ($pid > 0) {
fwrite(STDOUT, "child: $pid exited\n");
}
sleep(5);
fwrite(STDOUT, "parent exit\n");
}
执行脚本:
php wait.php
,然后打开另外一个终端执行:
watch -n2 'ps aux | grep php | grep -v grep'
。从
watch
输出可以看到子进程退出后的5秒是僵尸进程,父进程回收后僵尸进程消失,最后父进程退出。
如果有多个子进程,父进程需要循环调用wait函数,否则某些子进程执行完毕后也会变成僵尸进程。
信号处理
PCNTL
拓展中的
pcntl_signal
函数用于安装信号函数,进程收到信号时会执行回调函数中的代码。我们知道
Ctrl + C
可以中断程序的执行,实际上是系统向程序发出
SIGINT
信号,而这个信号的默认操作是退出程序。
SIGINT
信号是可以捕捉的,我们可以设置信号回调让系统执行我们的函数而非执行默认的退出程序:
// signal.php
pcntl_signal(SIGINT, function () {
fwrite(STDOUT, "receive signal: SIGINT, do nothing...\n");
});
while (true) {
pcntl_signal_dispatch();
sleep(1);
}
执行脚本:
php signal.php
,然后按
Ctrl + C
,输出如下:
[vagrant@localhost ~]$ php signal.php
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
^Creceive signal: SIGINT, do nothing...
安装了信号函数后,
Ctrl + C
不再好使,程序依旧在调皮的执行。要结束程序,可以向进程发送无法捕捉的信号,例如
SIGKILL
。
ps aux | grep php
找到程序的进程号,然后用
kill
命令发送
SIGKILL
信号:
kill -SIGKILL 进程号
。程序收到信号后被操作系统强制中断执行。
如果在代码中捕捉
SIGKILL
信号会怎么样?由于这个信号无法捕捉,执行脚本会提示:
PHP Fatal error: Error installing signal handler for 9 in /home/vagrant/signal.php on line 7
。SIGKILL的值是9,即代码中不能捕捉这个信号。
支持哪些信号,默认操作是什么,和系统相关。一般来说
SIGINT
、
SIGKILL
等31个常见异步信号都是支持的,有些系统支持更多的信号。内核收到进程信号后,会查看进程是否注册了处理函数,如果未注册则执行默认操作;否则当进程运行在用户态时,回调注册的函数并清除信号。PHP中收到信号后触发信号回调函数的方式有三种:
-
tick触发,例如每执行100条低级指令检查信号:
declare(ticks=100)
; -
使用
pcntl_signal_dispatch
触发,用法见上文signal.php
; -
PHP7.1起可以使用
pcntl_async_signals
。
tick的方式十分低效,不建议使用;
pcntl_signal_dispatch
需要手动触发,可能存在较大延迟。如果PHP的版本不低于7.1,建议使用
pcnt_async_signals
。这个函数会智能的触发信号回调,效率上比tick要高,实时性上比手动触发要强。其原理是当程序从内核态切出、函数返回等时机检查是否有信号,有则执行回调。