为了更好的利用多核 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 要高, 实时性上比手动触发要强. 其原理是当程序从内核态切出, 函数返回等时机检查是否有信号, 有则执行回调.
理解了信号, 再看看如何使用信号解决僵尸进程问题. 子进程退出后, 操作系统会发送 SIGCLD 信号到父进程, 在信号回调函数中回收子进程即可, 详情见下面代码:
- // fork-signal.php
- pcntl_async_signals(true);
- pcntl_signal(SIGCLD, function () {
- $pid = pcntl_wait($status, WUNTRACED);
- fwrite(STDOUT, "child: $pid exited\n");
- });
- $pid = pcntl_fork();
- if ($pid === 0) {
- fwrite(STDOUT, "child exit\n");
- } else {
- // mock busy work
- sleep(1);
- }
相对于手动
pcntl_wait/pcntl_waitpid
方式, 信号处理无疑更为简洁高效.
信号也是进程中通信的一种方式. 接下来简要说一下进程间通信.
进程间通信
fork 出子进程后, 两个进程的数据段和堆栈 (理论上) 均分开. 与多线程不同, 全局变量在不同进程中无法共享. 进程间进行数据交换需要进程间通信 (Inter-Process Communication) 技术. 上文提到的信号是进程中通信技术的一种, posix_kill 函数可以向指定进程发送信号, 达到通信的目的.
简要归结, 进程间通信技术主要有:
管道 (pipe), 流管道(s_pipe) 和有名管道(FIFO);
信号(signal);
消息队列(message queue);
共享内存(share memory);
信号量(semaphore);
套接字(socket);
关于这些通信技术请参考文末的链接, 或者其他文献.
守护进程
通过 php test.php 方式执行程序, 关闭终端后程序也会退出. 要让程序能长期执行, 需要额外的手段. 总结起来主要有三种:
使用 nohup;
使用 screen/tmux 等工具;
fork 子进程后, 父进程退出, 子进程升为会话 / 进程组长, 脱离终端继续运行.
screen/tmux 方式实际上未脱离终端, 只是帮用户模拟了一个可长期执行的终端环境. nohup 和 fork 方式才是让程序脱离 (detach) 终端, 达到肉体飞升的正道(成为 daemon).
下面的代码通过 fork 的方式让程序成为守护进程:
- $pid = pcntl_fork();
- switch ($pid) {
- case -1:
- fwrite(STDOUT, "fork failed!\n");
- exit(1);
- break;
- case 0:
- if (posix_setsid() === -1) {
- fwrite(STDERR, "fail to set child as the session leader!\n");
- exit;
- }
- file_put_contents("/tmp/daemon.out", "php daemon example\n", FILE_APPEND);
- while (true) {
- sleep(5);
- file_put_contents("/tmp/daemon.out", "now:" . date("Y-m-d H:i:s") . "\n", FILE_APPEND);
- }
- break;
- default:
- // parent exit
- exit;
- }
fork 之后最重要的一个操作是 posix_setsid, 该函数把当前进程设置为会话组长(被设置的进程当前不能是组长). 某些开源库中会 fork 两次, 防止第一次 fork 的进程无意间打开终端(非会话组长无法打开终端).
注意后台的多进程应当在进程脱离终端后再 fork.
总结
本文简要介绍了多进程编程的几个方面, 希望对学习多进程的同行有帮助.
- http://php.net/manual/en/book.pcntl.php
- http://php.net/manual/en/book.posix.php
- https://www.cnblogs.com/hicjiajia/archive/2011/01/20/1940154.html
- http://gityuan.com/2015/12/20/signal/
- https://www.cnblogs.com/hoys/archive/2012/08/19/2646377.html
- http://www.cnblogs.com/taobataoma/archive/2007/08/30/875743.html
- https://www.jianshu.com/p/c1015f5ffa74
- https://blog.csdn.net/column/details/linuxkernel-detailed.html
- https://segmentfault.com/a/1190000008556669
来源: https://juejin.im/post/5b2f855e6fb9a00e5e427715