引用的资料:https://juejin.cn/post/7405474110299570212
前言
在 PHP 语言中,可以使用 pcntl 系列函数来实现多进程并发。在编译安装php时,一定要使用 --enable-pcntl 配置选项,否则无法使用pcntl系列函数。
在正式学习多进程并发前,需要了解一些基础知识。
1.密集型任务分类
在编程和计算机科学中,任务通常可以分为两类:CPU 密集型任务和 I/O 密集型任务。理解这两种类型的任务有助于优化程序性能和资源利用。
1.1 CPU 密集型任务
定义:
CPU 密集型任务是指主要消耗 CPU 处理能力的任务。这类任务通常需要进行大量的计算操作,如数学运算、数据处理、加密解密、图像或视频处理、复杂的算法运算(如机器学习模型的训练)等。
特征:
高计算量: 任务的主要工作是进行计算操作,CPU 需要不断执行指令来处理数据。
低 I/O 操作: 任务很少与外部设备(如磁盘、网络)进行交互,CPU 使用率通常很高。
多线程/多进程优化: 对于 CPU 密集型任务,可以通过多线程或多进程来利用多核 CPU 提高并行处理能力。但需要注意的是,增加线程或进程数量可能会增加上下文切换的开销,反而可能降低效率。
示例:
大规模数值计算(如矩阵运算、傅里叶变换)
图像处理(如滤镜应用、特效生成)
加密算法执行(如 AES、RSA 加密解密)
复杂的科学计算和模拟(如气象预测、天体物理计算)
1.2 I/O 密集型任务
定义:
I/O 密集型任务是指主要消耗 I/O 操作资源的任务。I/O 指的是输入/输出操作,包括读取或写入磁盘、网络通信、文件操作、数据库访问等。这类任务的性能瓶颈通常在于等待数据传输,而不是计算操作。
特征:
高 I/O 操作: 任务需要频繁地与外部设备或网络进行交互,CPU 在这些过程中大部分时间处于空闲状态,等待 I/O 操作完成。
低计算量: 任务中 CPU 的工作量相对较少,计算操作简单或不频繁。
异步和并发优化: I/O 密集型任务通常可以通过异步操作和并发模型(如 Goroutine、异步 I/O)来优化性能,减少等待时间,提高资源利用率。
示例:
文件读取/写入操作(如读取大文件、日志记录)
网络请求(如 HTTP 请求、API 调用)
数据库查询和操作(如从数据库中读取数据)
爬虫程序(频繁发起网络请求,下载网页内容)
提问:我有一台 32核 CPU 128G内存的服务器,如果我开启了100个子进程,会不会占满这32个CPU?。
答:
CPU 密集型任务:如果你的 100 个子进程都在执行 CPU 密集型任务(即每个进程都需要持续大量使用 CPU),那么在大部分时间里,32 个核心将会被占满,并且系统会处于高负载状态。此时,你的系统负载会接近 100,CPU 资源将被充分利用。
I/O 密集型任务或混合型任务:如果你的任务中有一部分是 I/O 密集型任务(例如读取文件、网络请求等),即使你创建了 100 个子进程,也不一定会占满所有的 CPU。操作系统会将等待 I/O 的进程暂时挂起,而调度其他可以运行的进程上 CPU。
因此,是否会将 32 个 CPU 核心占满,取决于这些子进程的具体任务类型和操作系统的进程调度策略。如果你担心过多的子进程影响系统性能,建议根据任务性质和系统的实时负载来动态调整子进程的数量。
2.多进程之间的资源竞争问题
使用pcntl_fork()
函数创建子进程,当子进程被创建时,它复制了父进程的代码和内存空间,这意味着如果你在父进程里面定义了一些变量,在子进程里面也是可以操作访问的,这同时也意味着如果多个子进程操作同一个变量必然会出现覆盖和争用问题
比如说同时修改一个变量、同时往一个文件写入内容,需要通过锁机制保证同一时刻只能有一个进程操作。
还有一些坑,假如你在父进程去实例化一个mysql连接,在多个子进程里面同时使用,也会出现争用问题,所以涉及到这类资源类的变量,务必在各个子进程内部单独创建。
3. 孤儿进程和僵尸进程的定义,僵尸进程对系统的危害
3.1 孤儿进程
孤儿进程一个父进程在其子进程结束之前提前终止,导致子进程成为“孤儿”。操作系统中的init进程(在大多数 Linux 系统中是 PID 为 1 的进程)会自动收养这些孤儿进程,并成为它们的父进程。
init进程会定期调用 wait() 或 waitpid() 函数来回收孤儿进程的资源,这样这些孤儿进程就不会成为僵尸进程。
使用getmypid()
可以查看当前进程ID,posix_getppid()
查看当前父进程的ID,如果父进程ID 变为1,说明这个进程已经变成孤儿进程了。
3.2 僵尸进程
僵尸进程是指一个子进程已经完成了执行并退出了,但是它的父进程还没有调用 wait() 或 waitpid() 函数来获取子进程的终止状态信息。由于父进程没有及时回收子进程的资源,这些已经退出的子进程会进入一种特殊的状态,称为“僵尸状态”。僵尸进程仍然在系统的进程表中占用一个条目,主要是为了记录进程的终止状态,以便父进程稍后可以读取。
3.3 僵尸进程的危害
僵尸进程本身并不会占用大量系统资源(如内存和 CPU),这时的僵尸进程只保留在进程表中。但是,僵尸进程会带来以下危害:
占用 PID 资源:
每个僵尸进程都会占用一个 PID(进程标识符),而操作系统中的 PID 是有限的。
例如,在 Linux 系统中,默认的最大 PID 数量是 32768(可以通过 /proc/sys/kernel/pid_max 查看和修改)。
如果系统中存在大量僵尸进程,占用了大量的 PID,那么新进程就无法获得新的 PID,从而导致系统无法创建新的进程。
系统稳定性和性能影响:虽然单个僵尸进程的资源占用很少,但如果大量僵尸进程存在,系统的进程表会变得非常庞大,增加进程管理的开销,影响系统的性能和稳定性。
3.4 孤儿进程和僵尸进程的实现代码
3.1孤儿进程
$arr = [1,2,3,4,5,6,7,8,9,10];
$pid = pcntl_fork();
if($pid == -1){
$this->error('创建进程失败');
}elseif($pid){
echo "父进程ID: " . getmypid() . PHP_EOL;
sleep(2);
exit();
}else{
$cid = getmypid();
echo "当前子进程: {$cid}" . PHP_EOL;
for($i = 1; $i <= 10; $i++){
// posix_getppid 函数获取当前子进程的父进程ID
sleep(1);
echo "当前子进程ID: " . $cid. ", 父进程ID: " . posix_getppid() . PHP_EOL;
}
}
3.2 僵尸进程代码
$pid = pcntl_fork();
if($pid == -1){
$this->error('创建进程失败');
}elseif($pid){
echo "父进程ID: " . getmypid() . PHP_EOL;
sleep(30);
exit();
}else{
$cid = getmypid();
echo "当前子进程: {$cid}" . PHP_EOL;
sleep(4);
exit(0);
}
1. 创建子进程
使用pcntl_fork()
函数可以在当前进程当前位置产生子进程,在脚本里从 pcntl_fork()
函数调用的位置开始,父进程和子进程都会继续向下执行,从而进入多进程环境。创建的子进程会继承父进程的内存空间和相关变量。
函数 | 说明 |
---|---|
pcntl_fork | 在当前进程当前位置产生子进程 |
子进程创建完成后,当完成相关的业务逻辑后,从子进程退出后,主进程需要回收子进程资源。回收资源使用下面两种函数。
函数 | 说明 |
---|---|
pcntl_wait | 阻塞模式回收子进程 |
pcntl_waitpid | 非阻塞模式回收子进程 |
代码1:
public function handle()
{
$arr = [1,2,3,4,5,6,7,8,9,10];
foreach($arr as $id){
$pid = pcntl_fork();
if($pid == -1){
Log::channel('wechat')->debug('子进程创建失败');
}elseif($pid){
Log::channel('wechat')->debug('子进程创建成功',['pid'=>$pid]);
}else{
//子进程逻辑
Log::channel('wechat')->debug('进入子进程里世界PID='.$pid,['pid'=>$pid]);
sleep(4);
exit(0);
}
}
while(($pid = pcntl_wait($status)) > 0){
Log::channel('wechat')->debug('子进程回收成功',['receive_pid'=>$pid]);
}
$this->info('完成');
}
分析代码:
这个程序里,创建了10个子进程,每个子进程打印一条日志,然后睡眠4秒,就退出返回到主进程。
在遍历数组的时候,使用 pcntl_fork()
函数创建子进程。从 pcntl_fork()
函数调用的位置开始,父进程和子进程都会继续向下执行,从而进入多进程环境。创建的子进程会继承父进程的内存空间和相关变量。
分析代码:
$pid = pcntl_fork();
if($pid == -1){
Log::channel('wechat')->debug('子进程创建失败');
}elseif($pid){
Log::channel('wechat')->debug('子进程创建成功',['pid'=>$pid]);
}else{
//子进程逻辑
Log::channel('wechat')->debug('进入子进程里世界PID='.$pid,['pid'=>$pid]);
sleep(4);
exit(0);
}
pcntl_fork()
执行后,会返回三种值
函数 | 说明 |
---|---|
大于0 | 创建成功,当前父进程执行线程内返回产生的子进程的PID |
0 | 在子进程执行线程内返回0 |
-1 | 创建子进程失败 |
当创建子进程成功后,它会返回子进程的id号,与此同时它会直接进入子进程空间,继续运行pcntl_fork
函数,然后返回0.
这时候就需要用 if语句判断三种状态, 在else部分里写的代码,就是子进程的业务逻辑了。当子进程完成任务后,一定要使用exit(0) 或 die()
进行退出,否则子进程会一直循环下去。
从打印的日志报告里也可以看到,当子进程创建成功后,返回大于0的子进程PID,然后又执行一次pcntl_fork函数,它此时已进入子进程中,从pcntl_fork()处开始执行返回0
production.DEBUG: 子进程创建成功 {"pid":29860}
production.DEBUG: 进入子进程里世界PID=0 {"pid":29860}
production.DEBUG: 子进程创建成功 {"pid":29861}
production.DEBUG: 进入子进程里世界PID=0 {"pid":29861}
production.DEBUG: 子进程创建成功 {"pid":29862}
production.DEBUG: 进入子进程里世界PID=0 {"pid":29862}
production.DEBUG: 子进程创建成功 {"pid":29863}
production.DEBUG: 进入子进程里世界PID=0 {"pid":29863}
当子进程内的业务逻辑完成时,一定要使中exit(0) 或 die() 函数退出,如果不退出,会产生以下几种情况。
在子进程内,如果在执行完业务逻辑后不调用 exit(0)
或类似的退出函数,子进程会继续执行父进程中 pcntl_fork()
之后的代码。这会导致以下几种情况:
继续执行父进程的逻辑:子进程会继续执行父进程中的代码,直到脚本结束。这可能会导致子进程意外地执行父进程不希望其执行的逻辑,甚至可能再次进入创建子进程的循环中,导致意外的多重子进程生成(即“进程爆炸”现象)。
资源浪费:子进程不退出会占用系统资源(如内存和 CPU)。如果有多个子进程不退出,系统资源会被大量消耗,可能会导致服务器负载增加,性能下降,甚至崩溃。
父进程的
pcntl_wait
阻塞:父进程使用pcntl_wait()
或类似的函数来等待子进程退出。如果子进程不调用exit()
或die()
,它不会正常退出,这样父进程可能会一直阻塞在pcntl_wait()
处,导致父进程不能继续执行其后续逻辑。僵尸进程:如果子进程完成了它的任务但没有调用
exit()
或die()
,并且父进程没有正确处理子进程的结束状态,子进程可能会变成僵尸进程(Zombie Process)。这意味着进程已经完成执行但其退出状态尚未被父进程获取。僵尸进程占用进程表中的条目,过多的僵尸进程会导致系统的进程表被填满,阻碍新进程的创建。
为了避免这些问题,子进程在完成任务后应该始终调用 exit(0)
或 die()
来确保其正常退出,从而释放资源并防止继续执行不必要的代码。
2 子进程资源回收
子进程退出返回后,在主进程里,需要通过pcntl_wait
或 pcntl_waitpid
函数进行资源回收。
函数 | 说明 |
---|---|
pcntl_wait | 阻塞模式回收子进程 |
pcntl_waitpid | 非阻塞模式回收子进程 |
while(($pid = pcntl_wait($status)) > 0){
Log::channel('wechat')->debug('子进程回收成功',['receive_pid'=>$pid]);
}
我在脚本里创建了 10 个子进程,想回收所有的子进程,就通过while循环多次调用 pcntl_wait(),它会依次阻塞,直到所有子进程都被回收完毕。回收完毕后,才会执行while()后面的代码。
pctl_wait
函数里的参数 $status,不需要定义,子进程返回后会直接发送一个状态,这个函数会返回子进程的ID
最后总结一下 pcntl_wait() 的工作机制:
1.阻塞式等待:当你调用 pcntl_wait() 时,当前进程会阻塞并等待任意一个子进程的状态发生变化(例如,子进程退出)。
2.回收子进程:一旦有一个子进程退出,pcntl_wait() 就会返回,允许当前进程继续执行接下来的代码。
3.循环回收:通常,为了确保所有子进程都被回收完毕,你会在一个循环中使用 pcntl_wait(),直到它返回 -1,表示没有更多的子进程可以被回收。
因此,如果脚本在一个循环中使用 pcntl_wait() 来回收子进程,它将会阻塞并等待,直到所有的子进程都已经退出并被回收。如果子进程执行的时间较长,那么主进程会一直处于等待状态。
在另一篇文章里,会讲解如何使用信号模式回收子进程。