1、前言
很多PHPer在实际开发中并不会用到多进程处理业务,因为并不是任何业务都需要用到多进程并行处理。
多进程一般用于处理多次相同的业务逻辑,将循环逻辑,优化成一起执行,消耗CPU性能达到加速业务执行的作用。
2、什么是多进程
用生活中来举例,有30包大米,加上你自己有4个工人(4个进程),但通常PHP是单进程,30包大米是分配给你的,那你要来回搬30次,才能搬完,可能耗时需要30秒。
如果用多进程,你用权限把其他3个工人喊过来帮忙,然后你自己负责记录结果,监督工作,3个人只需要来回10次,就能搬完,可能只需要耗时10秒。
效率翻了3倍。
PHP代码实际上是一个单进程在运行,如果我们将所有的工作都局限在一个进程中,它只能一次做一件事,这意味着我们需要将我们的单进程任务变成一个多进程任务,以便我们可以利用操作系统的多任务处理能力(俗称利用多核CPU)。
3、多进程跟多线程的区别
进程:是资源分配的最小单位
线程:是CPU调度的最小单位
上面两句话是对进程、线程的最浅解释。
经常在网络上可以看到有人问“多进程好还是多线程好?”、“Linux下用多进程还是多线程?”之类,期望一劳永逸的问题。
但实际开发中,只有结合业务场景来选择是使用,多进程还是多线程,才是最合理的:没有最好,只有更好。
按照不同的应用维度,可以看下多线程和多进程的对比
(注:因为是感性的比较,因此都是相对的,不是说一个好得不得了,另外一个差的无法忍受)
对比维度 | 多进程 | 多线程 | 总结 |
---|---|---|---|
数据共享、同步 | 数据共享复杂,需要用IPC;数据是分开的,同步简单 | 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 | 各有优势 |
内存、CPU | 占用内存多,切换复杂,CPU利用率低 | 占用内存少,切换简单,CPU利用率高 | 线程占优 |
创建销毁、切换 | 创建销毁、切换复杂,速度慢 | 创建销毁、切换简单,速度很快 | 线程占优 |
编程、调试 | 编程简单,调试简单 | 编程复杂,调试复杂 | 进程占优 |
可靠性 | 进程间不会互相影响 | 一个线程挂掉将导致整个进程挂掉 | 进程占优 |
分布式 | 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 | 适应于多核分布式 | 进程占优 |
4、多进程跟多线程的选择
A、需要频繁创建销毁的优先用线程
这种原则最常见的应用就是Web服务器了,例如PHP-FPM,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的。
B、需要进行大量计算的优先使用线程
所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。
这种原则最常见的是图像处理、算法处理。
C、强相关的处理用线程,弱相关的处理用进程
什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。
一般的Server需要完成如下任务:消息收发、消息处理。
“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。
因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。
当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。
D、可能要扩展到多机分布的用进程,多核分布的用线程
E、都满足需求的情况下,用你最熟悉、最拿手的方式
至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。
但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。
需要提醒的是:虽然给了这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。
5、多进程跟多线程的优缺点
从内核的观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。
线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
一个进程由几个线程组成,线程与同属一个进程的其他的线程共享进程所拥有的全部资源。
所以线程间是不安全的,资源共享,而进程间资源独立,不会互相干扰。
进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。
线程有自己的堆栈和局部变量,但线程没有单独的地址空间(可以理解为没有进程ID),一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。
6、PHP中的伪多进程
由于PHP-FPM的进程模型原因,单个用户请求,即为一个进程,而在以前很多PHPer喜欢用ignore_user_abort(true)
、set_time_limit(0)
和while(1){}
的结合方式实现多进程队列处理。
用网页访问这个php文件,就相当于开启了一个进程处理。
再开第二个网页访问这个文件,相当于又开启了一个进程。
如此重复,我们就可以得到N个处理队列的进程。
针对于业务逻辑层面,实际上是已经开启了多进程。
只不过这种伪多进程的方式不可控、不安全、内存开销大(进程挂后台永久执行),同时无法监听进程状态,所以一般称之为伪多进程。
7、PHP多进程的依赖
原生PHP中并没有多进程的支持,依赖pcntl
扩展可以实现多进程,但该扩展是依赖Linux系统的原生进程管理实现,所以只能再Linux系统下安装,Windows系统中PHP不支持多进程,只能用伪多进程实现。
同时,pcntl
扩展只支持Cli模式,也就是只能在命令行模式下运行PHP代码,才能使用,用浏览器去访问PHP文件的方式,也是不能运行pcntl
扩展的。
8、PHP多进程的相关函数
PHP多进程的相关函数,又分为进程创建,和进程管理两类。
进程创建:pcntl_
系列函数。
pcntl_fork() - 创建一个子进程
pcntl_signal() - 自定义进程信号处理器
pcntl_wait() - 当前进程,等待或返回 fork 的子进程状态
pcntl_waitpid() - 指定进程,等待或返回fork的子进程状态
更多函数:
通过 pcntl_wait
和 pcntl_waitpid
传指的 $status
参数,我们可以获取子进程的状态信息,我们可以将状态信息传入以下方法获取更多信息:
pcntl_wifexited() - 检查状态代码是否代表一个正常的退出。
pcntl_wifstopped() - 检查子进程当前是否已经停止
pcntl_wifsignaled() - 检查子进程状态码是否代表由于某个信号而中断
pcntl_wexitstatus() - 返回一个中断的子进程的返回代码
pcntl_wtermsig() - 返回导致子进程中断的信号
pcntl_wstopsig() - 返回导致子进程停止的信号
pcntl_alarm() - 创建一个计时器,在指定的秒数后向进程发送一个SIGALRM信号
pcntl_signal() - 安装一个信号监听器
pcntl_signal_dispatch() - 触发一次信号发送,配合posix_kill()使用
进程管理:posix_
系列函数,(需要安装posix
扩展)。
posix_getpid() 获取当前进程 ID
posix_kill() 发送进程信号,可以配合pcntl_signal()进行触发。
文件脚本管理:
getmypid() - 获取当前运行PHP文件的进程ID
getmygid() - 获取当前运行PHP文件拥有者的GID
getmyuid() - 获取当前运行PHP文件所有者的UID
9、进程信号
进程信号,是相当于posix_kill()
和pcntl_signal()
使用的,同时也是Linux系统的进程信号,通用。
全部进程信号,可以在Linux中,使用kill -l
命令进行查看。
下面是一部分进程信号的介绍:
信号名 | 信号值 | 信号类型 | 信号说明 |
---|---|---|---|
SIGHUP | 1 | 终止进程(终端线路挂断) | 本信号在用户终端连接(正常或非正常、结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联. |
SIGINT | 2 | 终止进程(中断进程) | 程序终止(interrupt、信号, 在用户键入INTR字符(通常是Ctrl-C、时发出 |
SIGQUIT | 3 | 建立CORE文件终止进程,并且生成CORE文件 | SIGQUIT 和SIGINT类似, 但由QUIT字符(通常是Ctrl-、来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信 号. |
SIGILL | 4 | 建立CORE文件(非法指令) | SIGILL 执行了非法指令. 通常是因为可执行文件本身出现错误, 或者试图执行数据段. 堆栈溢出时也有可能产生这个信号. |
SIGTRAP | 5 | 建立CORE文件(跟踪自陷) | SIGTRAP 由断点指令或其它trap指令产生. 由debugger使用. |
SIGABRT | 6 | SIGABRT 程序自己发现错误并调用abort时产生. | |
SIGIOT | 6 | 建立CORE文件(执行I/O自陷) | SIGIOT 在PDP-11上由iot指令产生, 在其它机器上和SIGABRT一样. |
SIGBUS | 7 | 建立CORE文件(总线错误) | SIGBUS 非法地址, 包括内存地址对齐(alignment、出错. eg: 访问一个四个字长的整数, 但其地址不是4的倍数. |
SIGFPE | 8 | 建立CORE文件(浮点异常) | SIGFPE 在发生致命的算术运算错误时发出. 不仅包括浮点运算错误, 还包括溢 出及除数为0等其它所有的算术的错误. |
SIGKILL | 9 | 终止进程(杀死进程) | SIGKILL 用来立即结束程序的运行. 本信号不能被阻塞, 处理和忽略. |
SIGUSR1 | 10 | 终止进程(用户自定义信号1) | SIGUSR1 留给用户使用 |
SIGSEGV | 11 | SIGSEGV 试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据. | |
SIGUSR2 | 12 | 终止进程(用户自定义信号2) | SIGUSR2 留给用户使用 |
SIGPIPE | 13 | 终止进程(向一个没有读进程的管道写数据) | Broken pipe |
SIGALRM | 14 | 终止进程(计时器到时) | SIGALRM 时钟定时信号, 计算的是实际的时间或时钟时间. alarm函数使用该信号. |
SIGTERM | 15 | 终止进程(软件终止信号) | SIGTERM 程序结束(terminate、信号, 与SIGKILL不同的是该信号可以被阻塞和处理. 通常用来要求程序自己正常退出. shell命令kill缺省产生这个信号. |
SIGCHLD | 17 | 忽略信号(当子进程停止或退出时通知父进程) | SIGCHLD 子进程结束时, 父进程会收到这个信号. |
SIGCONT | 18 | 忽略信号(继续执行一个停止的进程) | SIGCONT 让一个停止(stopped、的进程继续执行. 本信号不能被阻塞. 可以用一个handler来让程序在由stopped状态变为继续执行时完成特定的工作. 例如, 重新显示提示符 |
SIGSTOP | 19 | 停止进程(非终端信号) | SIGSTOP 停止(stopped、进程的执行. 注意它和terminate以及interrupt的区别: 该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略. |
SIGTSTP | 20 | 停止进程(终端信号) | SIGTSTP 停止进程的运行, 但该信号可以被处理和忽略. 用户键入SUSP字符时 (通常是Ctrl-Z、发出这个信号 |
SIGTTIN | 21 | 停止进程(后端进程读终端) | SIGTTIN 当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN 信号. 缺省时这些进程会停止执行. |
SIGTTOU | 22 | 停止进程(后端进程写终端) | SIGTTOU 类似于SIGTTIN, 但在写终端(或修改终端模式、时收到. |
SIGURG | 23 | 忽略信号(I/O紧急信号) | SIGURG 有”紧急”数据或out-of-band数据到达socket时产生. |
SIGXCPU | 24 | 终止进程(CPU实现超时) | SIGXCPU 超过CPU时间资源限制. 这个限制可以由getrlimit/setrlimit来读取/ 改变 |
SIGXFSZ | 25 | 终止进程(文件长度过长) | SIGXFSZ 超过文件大小资源限制. |
SIGVTALRM | 26 | 终止进程(虚拟计时器到时) | SIGVTALRM 虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间. |
SIGPROF | 27 | 终止进程(统计分布图用计时器到时) | SIGPROF 类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的 时间. |
SIGWINCH | 28 | 忽略信号(窗口大小发生变化) | SIGWINCH 窗口大小改变时发出. |
SIGIO | 29 | 忽略信号(描述符上可以进行I/O) | SIGIO 文件描述符准备就绪, 可以开始进行输入/输出操作. |
SIGPWR | 30 | SIGPWR Power failure |
所有PHP进程信号内置常量,参考官网文档:https://www.php.net/manual/zh/pcntl.constants.php
10、什么是僵尸进程?
一个进程使用 fork
创建子进程,如果子进程退出,而父进程并没有调用 wait()
或 waitpid()
获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这类子进程被称为僵尸进程。
我们详细理解下,在 UNIX/Linux 中,正常情况下,子进程是通过 fork
父进程创建的。
子进程和父进程的运行是一个异步过程,理论上父进程无法知道子进程的运行状态。
但知道子进程运行状态是一个很合理的需求,所以 UNIX 提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到。
这种机制就是: 在每个进程退出的时候,内核释放该进程的一部分资源,包括打开的文件、占用的内存等,同时仍然为其保留一定的信息(包括进程号 the process ID,退出状态 the termination status of the process,运行时间 the amount of CPU time taken by the process 等)。父进程可以通过 wait()
/waitpid()
来获取这些信息,然后操作系统才释放。
这样就产生了一个问题,如果父进程不调用 wait()
/waitpid()
的话,那么保留的信息就不会释放,其进程号就会一直被占用,就像僵尸一样,所以把这些进程称为僵尸进程。
A、僵尸进程的坏处
上面说到僵尸进程由于父进程不回收系统保留的信息而一直占用着系统资源,其中有一项叫做进程描述符。系统通过分配它来启动一个进程。
但是系统所能使用的进程号是有限的,如果存在大量的僵尸进程,系统将因为没有可用的进程号而导致系统不能产生新的进程。
B、如何查看僵尸进程
僵尸进程在系统中用 defunct
或 z
表示,通过 ps -ef
指令查看进程,如果发现某个进程的状态为 defunct
/z
,说明该进程是一个僵尸进程。
ps -ef|grep defunct
C、如何避免僵尸进程?
在 pcntl
中,父进程都应该通过 pcntl_wait()
或 pcntl_waitpid()
函数来等待子进程结束,并回收。
11、什么是孤儿进程?
当一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将会成为孤儿进程。
也就是说孤儿进程是没有父进程的进程,不会产生什么危害,因为孤儿进程会被init进程(进程号为1)所管理,并由init进程对它们完成状态收集工作。
Linux内核会把孤儿进程的父进程设置为init,而init进程会循环地wait()
它的已经退出的子进程。
这样,当一个孤儿进程执行结束后,init进程就会回收它,。因此孤儿进程并不会有什么危害 。
12、什么是守护进程?
说白点,就是脚本退到后台持续运行。
通过php test.php
方式执行程序,关闭终端后程序会退出。
要让程序能长期执行,需要额外的手段。
总结起来主要有三种:
nohup;
screen/tmux等工具;
fork子进程后,父进程退出,子进程升为会话/进程组长,脱离终端继续运行(孤儿进程)。
screen/tmux
方式程序实际上仍停留在终端,只是运行在一个长期存在的终端中。
nohup
命令和fork
方式才是让程序脱离(detach)终端,达到肉体飞升的正道(成为daemon)。
13、pcntl_fork()函数的注意事项
父进程和子进程都从调用pcntl_fork
函数位置开始,分别向下继续执行。不同的是父进程在执行过程中,得到的pcntl_fork
返回值为子进程号(PID),而子进程得到的是0
,如果调用pcntl_fork
创建进程失败,则会返回-1
。
从调用pcntl_fork
函数创建进程后,父进程和子进程无论是数据空间还是指令指针都完全一致,两者再也没有任何继承关系,可以看成是两个独立的进程,通过pcntl_fork
返回值,来对他们进行区分。
下面,我们来创建一个进程,看看效果。
echo '下面开始创建进程了'.PHP_EOL;
// 创建一个进程
$pid = pcntl_fork();
// 从这一行开始,父进程和子进程同时执行,下面的代码在多个进程中是同时执行的
$child = '';
if ($pid==-1) {
die('fork失败');
} else if ($pid==0) {
//子进程执行
$pName = '子进程';
} else if ($pid>0) {
//父进程执行
$pName = '父进程';
$child = ';子进程(' . $pid . ')';
}
// 获取当前进程ID
$curPid = posix_getpid();
echo date('H:i:s') . $pName .':(PID:' . $curPid . ')' . $child . PHP_EOL;
echo date('H:i:s') . $pName .'(PID:' . $curPid .')结束' . PHP_EOL;
exit(0);
最终输出:
下面开始创建进程了
19:00:21父进程:(PID:49393);子进程(49394)
19:00:21父进程(PID:49393)结束
19:00:21子进程:(PID:49394)
19:00:21子进程(PID:49394)结束
14、防止僵尸进程
$pids = [];
// 创建3个进程
for ($batch = 1; $batch <= 3; $batch++) {
$pid = pcntl_fork();
// 子进程处理逻辑
if ($pid == 0) {
echo '我是子进程'.PHP_EOL;
sleep(5);
exit;
}
// 主进程处理逻辑
elseif ($pid > 0) {
echo '我是父进程'.PHP_EOL;
$pids[] = $pid;
}
}
// 监听子进程状态,当子进程退出时,回收子进程信息,防止僵尸进程
while (count($pids)) {
foreach ($pids as $key => $pid) {
// 非阻塞获取子进程状态,如果要堵塞把WNOHANG去掉就行
// $res -1:进程非正常退出,例如信号 0:子进程未退出 大于0:子进程正常退出,res为子进程进程号
$res = pcntl_waitpid($pid, $status, WNOHANG);
if ($res == -1 || $res > 0) {
unset($pids[$key]);
}
}
}
15、进程信号监听
// declare的性能很差,可以用pcntl_singal_dispath优化
declare(ticks=1);
// 定义一个信号处理函数
// $signo是信号常量值
function signHandler($signo) {
// 获取当前进程ID
$pid = posix_getpid();
// exit是为了配合SIGUSR1停止当前进程
echo '我要停止拉:'.$pid.PHP_EOL;
exit;
}
// 创建一个进程
$pid = pcntl_fork();
// 子进程执行程序
if ($pid == 0) {
// 注册信号处理函数
pcntl_signal(SIGUSR1, "signHandler");
// 堵塞子进程,让它理论上是不会停止运行
$i = 1;
while(true){
echo time().':我是子进程~~'.PHP_EOL;
sleep(1);
$i++;
if ($i > 15) exit;
}
exit;
}
// 父进程执行程序
elseif ($pid > 0) {
$childList[$pid] = 1;
// 5秒后,父进程向子进程发送 SIGUSR1 信号.
sleep(5);
// 给子进程发送SIGUSR1信号
posix_kill($pid, SIGUSR1);
// 再5秒后,父进程才关闭
sleep(5);
}
16、进程间通信
管道是比较常用的多进程通信手段,管道分为无名管道与有名管道,无名管道只能用于具有亲缘关系的进程间通信,而有名管道可以用于同一主机上任意进程。
这里只介绍有名管道。下面的例子,子进程写入数据,父进程读取数据。
// 定义管道路径,与创建管道
$pipePath = 'test.pipe';
if (!file_exists($pipePath)){
if(!posix_mkfifo($pipePath, 0664)){
exit("create pipe error!");
}
}
$pid = pcntl_fork();
// 子进程,向管道写数据
if ($pid == 0){
$file = fopen($pipePath, 'w');
while (true){
fwrite($file, 'hello world');
sleep(5);
}
exit;
}
// 父进程,从管道读数据
elseif ($pid > 0) {
$file = fopen($pipePath,'r');
while (true){
$rel = fread($file,20);
echo "{$rel}n".PHP_EOL;
sleep(5);
}
}
17、多进程优化CURL爬虫
正常情况下,单进程的爬虫抓取,都是用循环的方式一个一个执行,其流程是有序的,但耗时极其严重:
$t1=microtime(true);
for ($i=1; $i<=10; $i++) {
// 爬虫抓包
$res = file_get_contents('http://www.baidu.com');
echo '第'.$i.'次,抓取完成'.PHP_EOL;
}
$t2=microtime(true);
echo '总耗时'.round($t2-$t1, 7).'s'.PHP_EOL;
得到结果:
第1次,抓取完成
第2次,抓取完成
第3次,抓取完成
第4次,抓取完成
第5次,抓取完成
第6次,抓取完成
第7次,抓取完成
第8次,抓取完成
第9次,抓取完成
第10次,抓取完成
总耗时2.301796s
用多进程优化后,其执行流程是无序的,但耗时优化了很多:
<?php
$t1=microtime(true);
$pids = [];
// 每个爬虫都分别交给1个子进程处理
for ($i=1; $i<=10; $i++) {
$pid = pcntl_fork();
// 子进程处理逻辑
if ($pid == 0) {
// 爬虫抓包
$res = file_get_contents('http://www.baidu.com');
echo '第'.$i.'次,抓取完成'.PHP_EOL;
exit;
}
// 主进程处理逻辑
elseif ($pid > 0) {
$pids[] = $pid;
}
}
// 监听子进程状态,当子进程退出时,回收子进程信息,防止僵尸进程
while (count($pids)) {
foreach ($pids as $key => $pid) {
// 非阻塞获取子进程状态,如果要堵塞把WNOHANG去掉就行
// $res -1:进程非正常退出,例如信号 0:子进程未退出 大于0:子进程正常退出,res为子进程进程号
$res = pcntl_waitpid($pid, $status, WNOHANG);
if ($res == -1 || $res > 0) {
unset($pids[$key]);
}
}
}
$t2=microtime(true);
echo '总耗时'.round($t2-$t1, 7).'s'.PHP_EOL;
得到结果:
第1次,抓取完成
第4次,抓取完成
第2次,抓取完成
第7次,抓取完成
第6次,抓取完成
第10次,抓取完成
第9次,抓取完成
第8次,抓取完成
第5次,抓取完成
第3次,抓取完成
总耗时0.4595609s
进程的执行顺序是无需的,但其爬虫业务却可以是有序的,我们只需要用消息队列(实际项目中是推荐使用Redis做为消息队列的,便于多机扩展),爬虫任务从队列中获取,就可以实现任务有序处理了。
18、总结
Windows设计处理,用于面对的是单机,同时远古时代CPU的资源昂贵,所以当时编程都是偏向于线程开发。在Windows开发中,线程的切换消耗要比进程切换快很多,这就是为什么一些老游戏,老软件,都是沿用单进程,多线程的方式,这个可以在Windows任务管理中看到。
而Linux的诞生了为了解决互联网项目在高并发下,Windows的资源分配不合理,造成机器资源浪费的情况(例如CPU没有合理运用、不需要图形界面等额外消耗)。
所以Linux在多机分布下,极大提升了CPU瓶颈的难题,为此Linux系统更注重多进程开发,同时在Linux系统中进程间切换的开销要比线程切换要低很多。
通常情况下,在Linux系统中,都是首选多进程开发(但并不绝对,实际要根据业务场景判断选择)。