转载请注明文章出处: https://tlanyan.me/php-review...
为了更好的利用多核CPU,咱们须要多进程或多线程。但在常规web开发中,咱们极少用到这两种并发技术(curl_multi
等特殊函数除外)。若是脚本运行在CLI模式下,多进程和多线程技术是提升多核CPU的有力工具。php
相对于多线程,多进程的程序具备健壮、无锁、对分布式支持更好等特色。本文来学习一下PHP的多进程编程。html
PHP中与(多)进程相关的两个重要拓展是PCNTL
和POSIX
。PCNTL
主要用来建立、执行子进程和处理信号,POSIX
拓展则实现了POSIX标准中定义的接口。因为Windows不是POSIX兼容的,因此POSIX
拓展在Windows平台上不可用。linux
先上简单的代码看多进程编程:git
// fork.php $parentId = posix_getpid(); fwrite(STDOUT, "my pid: $parentId\n"); $childNum = 10; foreach (range(1, $childNum) as $index) { $pid = pcntl_fork(); if ($pid === -1) { 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。能够根据函数的返回值判断接下来的执行环境在父进程中仍是子进程中。web
fork调用让系统建立一个与当前进程几乎彻底同样的进程,除了进程号等少数信息不同,进程的代码段、堆栈、数据段的值都一致。父进程打开了一个文件,复制的子进程一样享有这个句柄,这是过去多进程能监听同一个端口的原理;子进程基于父进程fork时的环境继续执行(代码段共享)直到退出。shell
去掉上述代码中else语句块的exit
能将帮助你更好地理解上面这段话。程序的本意是生成10个子进程,去掉子进程执行代码的exit
后,子进程执行完else块中代码后继续执行foreach
循环,最终生成55个子进程(为何是55个?)!鉴于此,一个良好的实践是在子进程的执行代码后老是加上exit终止语句,除非你真的有把握子进程会按照预期执行。数据库
除了fork,另一种多进程技术是exec。system
、exec
、proc_open
等函数会生成一个新的进程执行外部命令(并返回结果)。这些函数的本质是fork一个进程,而后调用shell执行命令,主进程等待其执行结束。函数执行期间,主进程除了等待没法处理其余任务,因此通常不认为这是多进程编程。实践中能够结合fork来并发执行外部命令。编程
多进程编程须要考虑到的一个问题是孤儿进程和僵尸进程。进程结束前父进程已经退出,进程变成孤儿进程;进程退出后父进程在执行且未回收子进程,那么进程变成僵尸进程。孤儿进程是仍在执行的进程,僵尸进程则已经中止执行,只剩下进程号一缕孤魂仍能被外界感知。json
孤儿进程会被系统的根进程(init进程,进程号为1)接管,运行结束后由根进程回收。下面代码演示孤儿进程的父进程的变化:segmentfault
// 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...
安装了信号函数后,Ctrl + C
再也不好使,程序依旧调皮的执行。要结束程序,能够向进程发送没法捕捉的信号,例如SIGKILL
。ps aux | grep php
找到程序的进程号,而后用kill
命令发送SIGKILL
信号:kill -SIGKILL 进程号
。程序收到信号后被操做系统强制中断执行。
若是在代码中捕捉SIGKILL
信号会怎么样?将上面代码中的SIGINT
改为SIGKILL
,执行脚本会提示:PHP Fatal error: Error installing signal handler for 9 in /home/vagrant/signal.php on line 2
。9是SIGKILL
的值,错误表示代码中不能捕捉这个信号。
支持哪些信号,默认操做是什么,和系统相关。绝大部分*nix系统支持SIGINT
、SIGKILL
等31个常见异步信号,某些系统支持更多的信号。
内核收到进程信号后,会查看进程是否注册了处理函数,若是未注册则执行默认操做;不然当进程运行在用户态时,内核回调信号处理函数并移除信号。PHP中收到信号后触发信号回调函数的方式有三种:
declare(ticks=100)
;pcntl_signal_dispatch
手动触发,用法见上文signal.php
;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
函数能够向指定进程发送信号,达到通讯的目的。
进程间通讯技术主要有:
这些通讯技术的详细内容请参考文末的连接,或者其余文献,本文再也不详述。
经过php test.php
方式执行程序,关闭终端后程序会退出。要让程序能长期执行,须要额外的手段。总结起来主要有三种:
nohup
;screen/tmux
等工具;fork
子进程后,父进程退出,子进程升为会话/进程组长,脱离终端继续运行。screen/tmux
方式程序实际上仍停留在终端,只是运行在一个长期存在的终端中。nohup和fork方式才是让程序脱离(detach)终端,达到肉体飞升的正道(成为daemon)。
下面的代码经过fork的方式让程序成为守护进程:
// daemon.php $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的进程无心间打开终端(非会话组长没法打开终端)。
执行程序:php daemon.php
,而后关闭终端,或者从新登陆,经过ps aux | grep daemon.php
查看程序均在执行。检测/tmp/daemon.out
,不断有内容输出,说明程序已经成为在后台持续运行的守护进程。
注意后台的多进程应当在进程脱离终端后再fork,即最终在后台干活的进程不能直接从脚本启动的进程fork,而应该至少是脚本启动进程的孙子进程。
下面来讲一个多进程的简单应用。在上一篇博文“PHP回顾之Socket编程”,咱们的服务端已经能作到几乎实时响应客户端的请求,可是客户端不是实时收到服务端下发的消息。利用多进程,咱们用一个进程专门负责读取服务端的消息,另外一个进程则负责收集用户在终端的输入,而后发送到服务端。下面是多进程的客户端代码:
// client.php <?php $host = "127.0.0.1"; $port = 8000; $socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errMsg); if ($socket === false) { throw new \RuntimeException("unable to create socket: " . $errMsg); } stream_set_blocking($socket, false); fwrite(STDOUT, "success connect to server: [{$host}:{$port}]...\n"); $pid = pcntl_fork(); switch ($pid) { case -1: fwrite(STDOUT, "fail to fork!\n"); exit(1); break; // child case 0: while (true) { $read = [$socket]; $write = null; $except = null; @stream_select($read, $write, $except, null); if (count($read)) { while (true) { $msg = fread($socket, 4096); if ($msg) { fwrite(STDOUT, "receive server: $msg\n"); } else { if (feof($socket)) { fwrite(STDOUT, "server closed.\n"); posix_kill(posix_getppid(), SIGINT); exit; } break; } } } } exit; // parent default: while (true) { fwrite(STDOUT, "please enter the input:\n"); $msg = trim(fgets(STDOUT)); if ($msg) { $args = [$msg]; $message = json_encode([ "method" => "echo", "args" => $args, ]); fwrite($socket, $message); } } }
执行客户端:php client.php
,会发现终端输入和服务端消息都能及时响应。同时,链接断开的信号也被正确的广播。
本文简要介绍了多进程编程的几个方面,最后给出一个应用的例子,但愿对学习多进程的同行有帮助。
感谢阅读!