guanhui07 / blog

把博客迁移到这了
https://guanhui07.github.io/blog/
98 stars 31 forks source link

php多进程 #346

Open guanhui07 opened 5 years ago

guanhui07 commented 5 years ago

实际上PHP是有多线程的,只是很多人不常用。使用PHP的多线程首先需要下载安装一个线程安全版本(ZTS版本)的PHP,然后再安装pecl的pthread扩展参考

实际上PHP是有多进程的,有一些人再用,总体来说php的多进程还算凑合,只需要在安装PHP的时候开启pcntl模块(是不是跟UNIX中的fcntl有点儿.... ....)即可。在*NIX下,在终端命令行下使用php -m就可以看到是否开启了pcntl模块。

所以我们只说php的多进程,至于php多线程就暂时放到一边儿。

注意:不要在apache或者fpm环境下使用php多进程,这将会产生不可预估的后果。

进程是程序执行的实例,举个例子有个程序叫做 “ 病毒.exe ”,这个程序平时是以文件形式存储在硬盘上,当你双击运行后,就会形成一个该程序的进程。系统会给每一个进程分配一个唯一的非负整数用来标记进程,这个数字称作进程ID。当该进程被杀死或终止后,其进程ID就会被系统回收,然后分配给新的其余的进程。

说了这么多,这鬼东西有什么用吗?我平时用CI、YII写个CURD跟这个也没啥关联啊。实际上,如果你了解APACHE PHP MOD或者FPM就知道这些东西就是多进程实现的。以FPM为例,一般都是nginx作为http服务器挡在最前面,静态文件请求则nginx自行处理,遇到php动态请求则转发给php-fpm进程来处理。如果你的php-fpm配置只开了5个进程,如果处理任意一个用户的请求都需要1秒钟,那么5个fpm进程1秒中就最多只能处5个用户的请求。所以结论就是:如果要单位时间内干活更快更多,就需要更多的进程,总之一句话就是多进程可以加快任务处理速度。

在php中我们使用pcntl_fork()来创建多进程(在*NIX系统的C语言编程中,已有进程通过调用fork函数来产生新的进程)。fork出来新进程则成为子进程,原进程则成为父进程,子进程拥有父进程的副本。这里要注意:

这里说子进程拥有父进程数据空间以及堆、栈的副本,实际上,在大多数的实现中也并不是真正的完全副本。更多是采用了COW(Copy On Write)即写时复制的技术来节约存储空间。简单来说,如果父进程和子进程都不修改这些 数据、堆、栈 的话,那么父进程和子进程则是暂时共享同一份 数据、堆、栈。只有当父进程或者子进程试图对 数据、堆、栈 进行修改的时候,才会产生复制操作,这就叫做写时复制。

在调用完pcntl_fork()后,该函数会返回两个值。在父进程中返回子进程的进程ID,在子进程内部本身返回数字0。由于多进程在apache或者fpm环境下无法正常运行,所以大家一定要在php cli环境下执行下面php代码。

第一段代码,我们来说明在程序从pcntl_fork()后父进程和子进程将各自继续往下执行代码:

<?php
        $pid = pcntl_fork();
    if( $pid > 0 ){
            echo "我是父亲".PHP_EOL;
    } else if( 0 == $pid ) {
            echo "我是儿子".PHP_EOL;
    } else {
            echo "fork失败".PHP_EOL;
        }

将文件保存为test.php,然后在使用cli执行,结果如下图所示:

第二段代码,用来说明子进程拥有父进程的数据副本,而并不是共享:

<?php
        // 初始化一个 number变量 数值为1
        $number = 1;
    $pid = pcntl_fork();
    if( $pid > 0 ){
            $number += 1;
            echo "我是父亲,number+1 : { $number }".PHP_EOL;
    } else if( 0 == $pid ) {
        $number += 2;
            echo "我是儿子,number+2 : { $number }".PHP_EOL;
    } else {
            echo "fork失败".PHP_EOL;
        }

第三段代码,比较容易让人思维混乱,pcntl_fork()配合for循环来做些东西,问题来了:会显示几次 “ 儿子 ”?

<?php
        for( $i = 1; $i <= 3 ; $i++ ){
        $pid = pcntl_fork();
        if( $pid > 0 ){
            // do nothing ...
        } else if( 0 == $pid ){
        echo "儿子".PHP_EOL;
        }
    }

上面代码执行结果如下:

仔细数数,竟然是显示了7次 “ 儿子 ”。好奇怪,难道不是3次吗?... ... 下面我修改一下代码,结合下面的代码,再思考一下为什么会产生7次而不是3次。

<?php
        for( $i = 1; $i <= 3 ; $i++ ){
            $pid = pcntl_fork();
        if( $pid > 0 ){
            // do nothing ...
        } else if( 0 == $pid ){
            echo "儿子".PHP_EOL;
        exit;
        }
    }

执行结果如下图所示:

前面强调过:父进程和子进程将继续执行fork之后的程序代码。这里就不解释,实在想不明白的,可以动手自己画画思考一下。


这里应该还是要解释一波的

为了避免写成臭尾理论文儿,这里强行断篇分割一下,下一章说僵尸进程和孤儿进程的一些恩怨情仇。


孤儿进程和僵尸进程

上篇我整篇尬聊的都是pcntl_fork(),只管fork生产,不管产后护理,实际上这样并不符合主流价值观,而且,操作系统本身资源有限,这样无限生产不顾护理,操作系统也会吃不消的。

孤儿进程是指父进程在fork出子进程后,自己先完了。这个问题很尴尬,因为子进程从此变得无依无靠、无家可归,变成了孤儿。用术语来表达就是,父进程在子进程结束之前提前退出,这些子进程将由init(进程ID为1)进程收养并完成对其各种数据状态的收集。init进程是Linux系统下的奇怪进程,这个进程是以普通用户权限运行但却具备超级权限的进程,简单地说,这个进程在Linux系统启动的时候做初始化工作,比如运行getty、比如会根据/etc/inittab中设置的运行等级初始化系统等等,当然了,还有一个作用就是如上所说的:收养孤儿进程。

僵尸进程是指父进程在fork出子进程,而后子进程在结束后,父进程并没有调用wait或者waitpid等完成对其清理善后工作,导致该子进程进程ID、文件描述符等依然保留在系统中,极大浪费了系统资源。所以,僵尸进程是对系统有危害的,而孤儿进程则相对来说没那么严重。在Linux系统中,我们可以通过ps -aux来查看进程,如果有[Z+]标记就是僵尸进程。

在PHP中,父进程对子进程的状态收集等是通过pcntl_wait()和pcntl_waitpid()等完成的。依然还是要通过代码还演示说明:

演示并说明孤儿进程的出现,并演示孤儿进程被init进程收养:

<?php
        $pid = pcntl_fork();
        if( $pid > 0 ){
            // 显示父进程的进程ID,这个函数可以是getmypid(),也可以用posix_getpid()
            echo "Father PID:".getmypid().PHP_EOL;
            // 让父进程停止两秒钟,在这两秒内,子进程的父进程ID还是这个父进程
            sleep( 2 );
        } else if( 0 == $pid ) {
            // 让子进程循环10次,每次睡眠1s,然后每秒钟获取一次子进程的父进程进程ID
            for( $i = 1; $i <= 10; $i++ ){
                sleep( 1 );
                // posix_getppid()函数的作用就是获取当前进程的父进程进程ID
                echo posix_getppid().PHP_EOL;
            }
        } else {
            echo "fork error.".PHP_EOL;
        }

运行结果如下图:

可以看到,前两秒内,子进程的父进程进程ID为4129,但是从第三秒开始,由于父进程已经提前退出了,子进程变成孤儿进程,所以init进程收养了子进程,所以子进程的父进程进程ID变成了1。

演示并说明僵尸进程的出现,并演示僵尸进程的危害:

<?php
        $pid = pcntl_fork();
        if( $pid > 0 ){
            // 下面这个函数可以更改php进程的名称
            cli_set_process_title('php father process');
            // 让主进程休息60秒钟
            sleep(60);
        } else if( 0 == $pid ) {
            cli_set_process_title('php child process');
            // 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
            sleep(10);
        } else {
            exit('fork error.'.PHP_EOL);
        }

运行结果如下图:

通过执行ps -aux命令可以看到,当程序在前十秒内运行的时候,php child process的状态列为[S+],然而在十秒钟过后,这个状态变成了[Z+],也就是变成了危害系统的僵尸进程。

那么,问题来了?如何避免僵尸进程呢?PHP通过pcntl_wait()和pcntl_waitpid()两个函数来帮我们解决这个问题。了解Linux系统编程的应该知道,看名字就知道这其实就是PHP把C语言中的wait()和waitpid()包装了一下。

通过代码演示pcntl_wait()来避免僵尸进程,在开始之前先简单普及一下pcntl_wait()的相关内容:这个函数的作用就是 “ 等待或者返回子进程的状态 ”,当父进程执行了该函数后,就会阻塞挂起等待子进程的状态一直等到子进程已经由于某种原因退出或者终止。换句话说就是如果子进程还没结束,那么父进程就会一直等等等,如果子进程已经结束,那么父进程就会立刻得到子进程状态。这个函数返回退出的子进程的进程ID或者失败返回-1。

我们将第二个案例中代码修改一下:

<?php
        $pid = pcntl_fork();
        if( $pid > 0 ){
            // 下面这个函数可以更改php进程的名称
            cli_set_process_title('php father process');

            // 返回$wait_result,就是子进程的进程号,如果子进程已经是僵尸进程则为0
            // 子进程状态则保存在了$status参数中,可以通过pcntl_wexitstatus()等一系列函数来查看$status的状态信息是什么
            $wait_result = pcntl_wait( $status );
            print_r( $wait_result );
            print_r( $status );

            // 让主进程休息60秒钟
            sleep(60);
        } else if( 0 == $pid ) {
            cli_set_process_title('php child process');
            // 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
            sleep(10);
        } else {
            exit('fork error.'.PHP_EOL);
        }

将文件保存为wait.php,然后php wait.php,在另外一个终端中通过ps -aux查看,可以看到在前十秒内,php child process是[S+]状态,然后十秒钟过后进程消失了,也就是被父进程回收了,没有变成僵尸进程。


但是,pcntl_wait()有个很大的问题,就是阻塞。父进程只能挂起等待子进程结束或终止,在此期间父进程什么都不能做,这并不符合多快好省原则,所以pcntl_waitpid()闪亮登场。pcntl_waitpid( $pid, &$status, $option = 0 )的第三个参数如果设置为WNOHANG,那么父进程不会阻塞一直等待到有子进程退出或终止,否则将会和pcntl_wait()的表现类似。

修改第三个案例的代码,但是,我们并不添加WNOHANG,演示说明pcntl_waitpid()功能:

<?php
        $pid = pcntl_fork();
        if( $pid > 0 ){
            // 下面这个函数可以更改php进程的名称
            cli_set_process_title('php father process');

            // 返回值保存在$wait_result中
            // $pid参数表示 子进程的进程ID
            // 子进程状态则保存在了参数$status中
            // 将第三个option参数设置为常量WNOHANG,则可以避免主进程阻塞挂起,此处父进程将立即返回继续往下执行剩下的代码
            $wait_result = pcntl_waitpid( $pid, $status );
            var_dump( $wait_result );
            var_dump( $status );

            // 让主进程休息60秒钟
            sleep(60);

        } else if( 0 == $pid ) {
            cli_set_process_title('php child process');
            // 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
            sleep(10);
        } else {
            exit('fork error.'.PHP_EOL);
        }

下面是运行结果,一个执行php程序的终端窗口,另一个是ps -aux终端窗口。实际上可以看到主进程是被阻塞的,一直到第十秒子进程退出了,父进程不再阻塞:

那么我们修改第四段代码,添加第三个参数WNOHANG,代码如下:

<?php
        $pid = pcntl_fork();
        if( $pid > 0 ){
            // 下面这个函数可以更改php进程的名称
            cli_set_process_title('php father process');

            // 返回值保存在$wait_result中
            // $pid参数表示 子进程的进程ID
            // 子进程状态则保存在了参数$status中
            // 将第三个option参数设置为常量WNOHANG,则可以避免主进程阻塞挂起,此处父进程将立即返回继续往下执行剩下的代码
            $wait_result = pcntl_waitpid( $pid, $status, WNOHANG );
            var_dump( $wait_result );
            var_dump( $status );
            echo "不阻塞,运行到这里".PHP_EOL;

            // 让主进程休息60秒钟
            sleep(60);

        } else if( 0 == $pid ) {
            cli_set_process_title('php child process');
            // 让子进程休息10秒钟,但是进程结束后,父进程不对子进程做任何处理工作,这样这个子进程就会变成僵尸进程
            sleep(10);
        } else {
            exit('fork error.'.PHP_EOL);
        }

下面是运行结果,一个执行php程序的终端窗口,另一个是ps -aux终端窗口。可以看到主进程不再阻塞:

问题出现了,竟然php child process进程状态竟然变成了[Z+],这是怎么搞得?回头分析一下代码:

我们看到子进程是睡眠了十秒钟,而父进程在执行pcntl_waitpid()之前没有任何睡眠且本身不再阻塞,所以,主进程自己先执行下去了,而子进程在足足十秒钟后才结束,进程状态自然无法得到回收。如果我们将代码修改一下,就是在主进程的pcntl_waitpid()前睡眠15秒钟,这样就可以回收子进程了。但是即便这样修改,细心想的话还是会有个问题,那就是在子进程结束后,在父进程执行pcntl_waitpid()回收前,有五秒钟的时间差,在这个时间差内,php child process也将会是僵尸进程。那么,pcntl_waitpid()如何正确使用啊?这样用,看起来毕竟不太科学。

那么,是时候引入信号学了!


上一篇尬聊了通篇的pcntl_wait()和pcntl_waitpid(),就是为了解决僵尸进程的问题,但最后看起来还是有一些遗留问题,而且因为嘴欠在上篇文章的结尾出也给了解决方案:信号。

信号是一种软件中断,也是一种非常典型的异步事件处理方式。在 nix 系统诞生的混沌之初,信号的定义是比较混乱的,而且最关键是不可靠,这是一个很严重的问题。所以在后来的POSIX标准中,对信号做了标准化同时也各个发行版的 nix 也都提供大量可靠的信号。每种信号都有自己的名字,大概如SIGTERM、SIGHUP、SIGCHLD等等,在 *nix 中,这些信号本质上都是整形数字(游有心情的可以参观一下signal.h系列头文件)。

信号的产生是有多种方式的,下面是常见的几种:

而进程在收到信号后,可以有如下三种响应:

用人话来表达,就是说假如你是一个进程,你正在干活,突然施工队的喇叭里冲你嚷了一句:“吃饭了!”,于是你就放下手里的活儿去吃饭。你正在干活,突然施工队的喇叭里冲你嚷了一句:“发工资了!”,于是你就放下手里的活儿去领工资。你正在干活,突然施工队的喇叭里冲你嚷了一句:“有人找你!”,于是你就放下手里的活儿去看看是谁找你什么事情。当然了,你很任性,那是完全可以不鸟喇叭里喊什么内容,也就是忽略信号。也可以更任性,当喇叭里冲你嚷“吃饭”的时候,你去就不去吃饭,你去睡觉,这些都可以由你来。而你在干活过程中,从来不会因为要等某个信号就不干活了一直等信号,而是信号随时随地都可能会来,而你只需要在这个时候作出相应的回应即可,所以说,信号是一种软件中断,也是一种异步的处理事件的方式。

回到上文所说的问题,就是子进程在结束前,父进程就已经先调用了pcntl_waitpid(),导致子进程在结束后依然变成了僵尸进程。实际上在父进程不断while循环调用pcntl_waitpid()是个解决办法,大概代码如下:

$pid = pcntl_fork();
if( 0 > $pid ){
  exit('fork error.'.PHP_EOL);
} else if( 0 < $pid ) {
  // 在父进程中
  cli_set_process_title('php father process');
  // 父进程不断while循环,去反复执行pcntl_waitpid(),从而试图解决已经退出的子进程
  while( true ){
    sleep( 1 );
    pcntl_waitpid( $pid, $status, WNOHANG );
  }
} else if( 0 == $pid ) {
  // 在子进程中
  // 子进程休眠20秒钟后直接退出
  cli_set_process_title('php child process');
  sleep( 20 );
  exit;
}

下图是运行结果:

解析一下这个结果,我先后三次执行了ps -aux | grep php去查看这两个php进程。

但是这样的代码有一个缺陷,实际上就是子进程已经退出的情况下,主进程还在不断while pcntl_waitpid()去回收子进程,这是一件很奇怪的事情,并不符合社会主义主流价值观,不低碳不节能,代码也不优雅,不好看。所以,应该考虑用更好的方式来实现。那么,我们篇头提了许久的信号终于概要出场了。

现在让我们考虑一下,为何信号可以解决“不低碳不节能,代码也不优雅,不好看”的问题。子进程在退出的时候,会向父进程发送一个信号,叫做SIGCHLD,那么父进程一旦收到了这个信号,就可以作出相应的回收动作,也就是执行pcntl_waitpid(),从而解决掉僵尸进程,而且还显得我们代码优雅好看节能环保。

梳理一下流程,子进程向父进程发送SIGCHLD信号是对人们来说是透明的,也就是说我们无须关心。但是,我们需要给父进程安装一个响应SIGCHLD信号的处理器,除此之外,还需要让这些信号处理器运行起来,安装上了不运行是一件尴尬的事情。那么,在php里给进程安装信号处理器使用的函数是pcntl_signal(),让信号处理器跑起来的函数是pcntl_signal_dispatch()。

下面结合新引入的两个函数来解决一下楼上的丑陋代码:

$pid = pcntl_fork();
if( 0 > $pid ){
  exit('fork error.'.PHP_EOL);
} else if( 0 < $pid ) {
  // 在父进程中
  // 给父进程安装一个SIGCHLD信号处理器
  pcntl_signal( SIGCHLD, function() use( $pid ) {
    echo "收到子进程退出".PHP_EOL;
    pcntl_waitpid( $pid, $status, WNOHANG );
  } );
  cli_set_process_title('php father process');
  // 父进程不断while循环,去反复执行pcntl_waitpid(),从而试图解决已经退出的子进程
  while( true ){
    sleep( 1 );
    // 注释掉原来老掉牙的代码,转而使用pcntl_signal_dispatch()
    //pcntl_waitpid( $pid, $status, WNOHANG );
    pcntl_signal_dispatch();
  }
} else if( 0 == $pid ) {
  // 在子进程中
  // 子进程休眠20秒钟后直接退出
  cli_set_process_title('php child process');
  sleep( 20 );
  exit;
}

运行结果如下:




很多phper一直停留在php web开发的mvc CURD中,也听到很多人对自己陷入这种困境中多有不满,但又不知道如何提高自己,摆脱困境。活脱脱就像一直趴在玻璃上的苍蝇,前途一片光明,就是飞不出去,可悲可叹。
话说回来,实际上做到一名合格的CURDer也并不是一件容易的事情,万万不可眼高手低。
如果想提高自己,也不一定非要通过工作,我认为一个人的提高更多是在非工作环境中。php的开发人员开始通过尝试接触并使用swoole或者workerman(0_0 或者等我的php socket框架,啦啦啦 0_0)来提高自己的认知水准,这就像2017年那部挺火的电视剧中那样,鼓励大家学英语,我认为挺好的。
学习swoole前,我建议大家有最好有如下知识储备或者有准备学习如下知识的准备:
考虑到本PO主原来叨叨了一坨与进程相关的知识,所以这篇就来一坨与swoole进程相关的内容,这也是要学习并好好利用swoole的第一课,打好基础,会对你使用有着更大的帮助。
先贴一张图,是从官方wiki上偷过来的,不得不说,如果你去swoole官方wiki上找,都不一定能找到这个图,我的言下之意就是老韩写的文档组织方式以及内容确实比较混乱,没有对比就没有伤害,比如人家workerman的文档的,我贴出来,你们感受一下:
啦啦啦,盗图狗要贴图了:


结合上图开始简单描述一下swoole中进程角色们:
简单总结混在一起说下这几种进程之间是怎么搭配起来干活的。见说来说,就是master进程就是接活儿的销售,但是具体干活则由worker进程来做,如果worker进程感觉到某些流程太繁忙复杂就可以让tasker进程来做。而manager进程就是后勤worker进程和takser进程的人力资源保障部,负责他们的生死存亡和吃喝拉撒。

做个高端点儿的玩意吧,假如我们要做一个任务系统,这个系统可以在后台帮我们完成一大波(注意是一大波)数据的处理,那么我们自然想到,多开几个进程分开处理这些数据,同时我们不能执行了php task.php后终端挂起,万一一不小心关闭了终端都会导致任务失败,所以我们还要实现程序的daemon化。好啦,开始了!
首先,我们第一步就得将程序daemon化了!
    // 设置umask为0,这样,当前进程创建的文件权限则为777
    umask( 0 );
    $pid = pcntl_fork();
    if( $pid < 0 ){
      exit('fork error.');
    } else if( $pid > 0 ) {
      // 主进程退出
      exit();
    }
    // 子进程继续执行

    // 最关键的一步来了,执行setsid函数!
    if( !posix_setsid() ){
      exit('setsid error.');
    }

    // 理论上一次fork就可以了
    // 但是,二次fork,这里的历史渊源是这样的:在基于system V的系统中,通过再次fork,父进程退出,子进程继续
    // 保证形成的daemon进程绝对不会成为会话首进程,不会拥有控制终端。
    $pid = pcntl_fork();
    if( $pid  < 0 ){
      exit('fork error');
    } else if( $pid > 0 ) {
      // 主进程退出
      exit;
    }
    // 子进程继续执行
    // 给进程重新起个名字
    cli_set_process_title('php master process');
加入我们fork出5个子进程就可以搞定这些任务,那么fork出5个子进程,同时父进程要负责这5个子进程的状态等。
// 由于*NIX好像并没有(如果有,请告知)可以获取父进程fork出所有的子进程的ID们的功能,所以这个需要我们自己来保存
$child_pid = [];

// 父进程安装SIGCHLD信号处理器并分发
pcntl_signal( SIGCHLD, function(){
  // 这里注意要使用global将child_pid全局化,不然读到去数组将为空,具体原因可以自己思考下
  global $child_pid;
  // 如果子进程的数量大于0,也就说如果还有子进程存活未 退出,那么执行回收
  $child_pid_num = count( $child_pid );
  if( $child_pid_num > 0 ){
    // 循环子进程数组
    foreach( $child_pid as $pid_key => $pid_item ){
      $wait_result = pcntl_waitpid( $pid_item, $status, WNOHANG );
      // 如果子进程被成功回收了,那么一定要将其进程ID从child_pid中移除掉
      if( $wait_result == $pid_item || -1 == $wait_result ){
        unset( $child_pid[ $pid_key ] );
      }
    }
  }
} );

// fork出5个子进程出来,并给每个子进程重命名
for( $i = 1; $i <= 5; $i++ ){
  $_pid = pcntl_fork();
  if( $_pid < 0 ){
    exit();
  } else if( 0 == $_pid ) {
    // 重命名子进程
    cli_set_process_title('php worker process');

    // 啦啦啦啦啦啦啦啦啦啦,请在此处编写你的业务代码
    // do something ...
    // 啦啦啦啦啦啦啦啦啦啦,请在此处编写你的业务代码

    // 子进程退出执行,一定要exit,不然就不会fork出5个而是多于5个任务进程了
    exit();

  } else if( $_pid > 0 ) {
    // 将fork出的任务进程的进程ID保存到数组中
    $child_pid[] = $_pid;
  }
}

// 主进程继续循环不断派遣信号
while( true ){
  pcntl_signal_dispatch();
  // 每派遣一次休眠一秒钟
  sleep( 1 );
}

其实前面是谈过一次daemon进程的,但是并涉及过多原理,但是并不影响使用。今天打算说说关于daemon进程更多的二三事,本质上说,如果你仅仅是简单实现利用一下daemon进程,这个不看也是可以的。
杠真,*NIX真是波大精深,越是深入看越是发现它的diao。原理往往都是枯燥的,大家都不爱看,但这并不影响我坚持写自己对这些东西的理解。
三个概念,理(bei)解(song)一下:
结合Linux命令ps来查看一下上述几个概念的恩怨情仇,我们看下我们常用的 ps -o pid,ppid,pgid,sid,comm | less 执行结果:

第一行分别是PID,PPID,PGID,SID,COMMAND,依次分别是进程ID,该进程父进程ID,进程组ID,会话ID,命令。
通过最后一列,我们知道第二行就是bash也就是bash shell进程,其进程ID为15793,其父进程为13291,进程组ID为15793,会话ID也会15793,结合前面的概念,我们可以知道bash shell就是该进程组组长。
第三行则是ps命令的进程,其进程ID为15816,他是由于bash进程fork出来的,所以他的父进程ID为15793,然后是他所属的组ID为15816,所属的会话ID依然是15793。
最后一行是less命令的进程,其进程ID为15817,他也是由bash进程fork出来的,所以他的父进程ID也为15793,然后是他所属的组ID为15816,所属的会话ID依然是15793。
简单总结一下:
通过这么一顿分析,是不是感觉可以接受点儿了?然后是,叨逼叨了半天这个,跟daemon进程有啥子关系?
啦啦啦,下面通过引入代码直接分析:
$pid = pcntl_fork();
if( $pid < 0 ){
  exit('fork error.');
} else if( $pid > 0 ) {
  // 主进程退出
  exit();
}
// 子进程继续执行

// 最关键的一步来了,执行setsid函数!
if( !posix_setsid() ){
  exit('setsid error.');
}

// 理论上一次fork就可以了
// 但是,二次fork,这里的历史渊源是这样的:在基于system V的系统中,通过再次fork,父进程退出,子进程继续,保证形成的daemon进程绝对不会成为会话首进程,不会拥有控制终端。

$pid = pcntl_fork();
if( $pid  < 0 ){
  exit('fork error');
} else if( $pid > 0 ) {
  // 主进程退出
  exit;
}

// 子进程继续执行

// 啦啦啦,啦啦啦,啦啦啦,已经变成daemon啦,开心
cli_set_process_title('testtesttest');
// 睡眠1000000,防止进程执行完毕挂了
sleep( 1000000 );
将上述文件保存为daemon.php,然后php daemon.php执行,使用 ps -aux | grep testte ,如果没有什么大问题你应该就可以看到这个进程在后台跑了。
所以为什么第一步要先fork呢?因为调用setsid的进程不可以是组长进程(篇头的枯燥知识需要了吧?),所以必须fork一次,然后将主进程直接退出,保留子进程。因为子进程一定不会是组长进程,所以子进程可以调用setsid。调用setsid则会产生三个现象:创建一个新会话并成为会话首进程,创建一个进程组并成为组长进程,脱离控制终端。
啦啦啦,明白为啥篇头那一坨枯燥的知识是为了什么吧?
然而,实际上,上述代码仅仅完成了一个标准daemon的80%,还有20%需要我们进一步完善。那么,需要完善什么呢?我们修改一下上述代码,让程序在最终的代码段中执行一些文本输出:
$pid = pcntl_fork();
if( $pid < 0 ){
  exit('fork error.');
} else if( $pid > 0 ) {
  // 主进程退出
  exit();
}
// 子进程继续执行

// 最关键的一步来了,执行setsid函数!
if( !posix_setsid() ){
  exit('setsid error.');
}

// 理论上一次fork就可以了
// 但是,二次fork,这里的历史渊源是这样的:在基于system V的系统中,通过再次fork,父进程退出,子进程继续,保证形成的daemon进程绝对不会成为会话首进程,不会拥有控制终端。

$pid = pcntl_fork();
if( $pid  < 0 ){
  exit('fork error');
} else if( $pid > 0 ) {
  // 主进程退出
  exit;
}

// 子进程继续执行

// 啦啦啦,啦啦啦,啦啦啦,已经变成daemon啦,开心
cli_set_process_title('testtesttest');
// 循环1000次,每次睡眠1s,输出一个字符test
for( $i = 1; $i <= 1000; $i++ ){
  sleep( 1 );
  echo "test".PHP_EOL;
}
将文件保存为daemon.php,然后php daemon.php执行文件,嗯,是不是有怪怪的现象,大概类似于下图:

即便你按Ctrl+C都没用,终端在不断输出test,唯一办法就是关闭当前终端窗口然后重新开一个,然而,这并不符合社会主义主流价值观。所以,我们还要解决标准输出和错误输出,我们的daemon程序不可以再将终端窗口当作默认的标准输出了。
其次是将当前工作目录修改更改为根目录。不然可能就会出现下面这样一个问题,就是如果父进程是的工作目录是一个挂载的目录,那么子进程会继承父进程的工作目录,当子进程已经daemon化后就会出现一个悲剧:那就是虽然原来挂载的目录已经不用了,但是却无法用umount卸载,非常悲剧。
最后一个问题是,要在第一次fork后设置umask(0),避免权限上的一些问题。所以较为完整的代码如下:
// 设置umask为0,这样,当前进程创建的文件权限则为777
umask( 0 );

$pid = pcntl_fork();
if( $pid < 0 ){
  exit('fork error.');
} else if( $pid > 0 ) {
  // 主进程退出
  exit();
}
// 子进程继续执行

// 最关键的一步来了,执行setsid函数!
if( !posix_setsid() ){
  exit('setsid error.');
}

// 理论上一次fork就可以了
// 但是,二次fork,这里的历史渊源是这样的:在基于system V的系统中,通过再次fork,父进程退出,子进程继续,保证形成的daemon进程绝对不会成为会话首进程,不会拥有控制终端。

$pid = pcntl_fork();
if( $pid  < 0 ){
  exit('fork error');
} else if( $pid > 0 ) {
  // 主进程退出
  exit;
}

// 子进程继续执行

// 啦啦啦,啦啦啦,啦啦啦,已经变成daemon啦,开心
cli_set_process_title('testtesttest');
// 一般服务器软件都有写配置项,比如以debug模式运行还是以daemon模式运行。如果以debug模式运行,那么标准输出和错误输出大多数都是直接输出到当前终端上,如果是daemon形式运行,那么错误输出和标准输出可能会被分别输出到两个不同的配置文件中去
// 连工作目录都是一个配置项目,通过php函数chdir可以修改当前工作目录
chdir( $dir );

往往开启多进程的目的是为了一起干活加速效率,前面说了不同进程之间的内存空间都是相互隔离的,也就说进程A是无法读或写进程B中的任何数据内容的,反之亦然。但是,有些时候,多个进程之间必须要有相互通知的机制,用职场上的话来说就叫“及时沟通”。大家都在一起做同一件事情的不同部分,彼此之间“及时沟通”是很重要的。
于是进程间通信就诞生了,英文缩写IPC,全称InterProcess Communication。
常见的进程间通信方式有:管道(分无名和有名两种)、消息队列、信号量、共享内存和socket,最后一种方式今天不提,放到后面的php socket编程中去说,重点说前四种方式。
管道是*NIX上常见的一个东西,大家平时使用linux的时候也都在用,简单理解就是|,比如ps -aux|grep php这就是管道,大概意思类似于ps进程和grep进程两个进程之间用|完成了通信。管道是一种半双工(现在也有系统已经支持全双工的管道)的工作方式,也就是说数据只能沿着管道的一个方向进行传递,不可以在同一个管道上反向传数据。管道分为两种,一种叫做未命名的管道,另一种叫做命名管道,未命名管道只能在拥有公共祖先的两个进程之间使用,简单理解就是只能用于父进程和和其子进程之间的通信,但是命名管道则可以用于任何两个毫无关连的进程之间的通信(一会儿将要演示的将是这种命名管道)。
需要特殊指出的是消息队列、信号量和共享内存这三种IPC同属于XSI IPC(XSI可以认为是POSIX标准的超集,简单粗暴理解为C++之于C)。这三种IPC在*NIX中一般都有两个“名字”来为其命名,一个叫做标志符,一个叫做键(key)。标志符是一个非负整数,每当一个IPC结构被创建然后又被销毁后,标志符便会+1一直加到整数的最大整数数值,而后又从0开始重新计算。既然是为了多进程通信使用,那么多进程在使用XSI IPC的时候就需要使用一个名字来找到相应的IPC,然后才能对其进行读写(术语叫做多个进程在同一个IPC结构上汇聚),所以POSIX建议是无论何时创建一个IPC结构,都应指定一个键(key)与之关联。一句话总结就是:标志符是XSI IPC的内部名称,键(key)是XSI IPC的外部名称。
使多个进程在XSI IPC上汇聚的方法大概有如下三种:
XSI IPC结构有一个与之对应的权限结构,叫做ipc_perm,这个结构中定义了IPC结构的创建者、拥有者等。
多进程通信之一:命名管道。 在php中,创建一个管道的函数叫做posix_mkfifo(),管道创建完成后其实就是一个文件,然后就可以用任何与读写文件相关的函数对其进行操作了,代码大概演示一下:
<?php
// 管道文件绝对路径
$pipe_file = __DIR__.DIRECTORY_SEPARATOR.'test.pipe';
// 如果这个文件存在,那么使用posix_mkfifo()的时候是返回false,否则,成功返回true
if( !file_exists( $pipe_file ) ){
  if( !posix_mkfifo( $pipe_file, 0666 ) ){
    exit( 'create pipe error.'.PHP_EOL );
  }
}
// fork出一个子进程
$pid = pcntl_fork();
if( $pid < 0 ){
  exit( 'fork error'.PHP_EOL );
} else if( 0 == $pid ) {
  // 在子进程中
  // 打开命名管道,并写入一段文本
  $file = fopen( $pipe_file, "w" );
  fwrite( $file, "helo world." );
  exit;
} else if( $pid > 0 ) {
  // 在父进程中
  // 打开命名管道,然后读取文本
  $file = fopen( $pipe_file, "r" );
  // 注意此处fread会被阻塞
  $content = fread( $file, 1024 );
  echo $content.PHP_EOL;
  // 注意此处再次阻塞,等待回收子进程,避免僵尸进程
  pcntl_wait( $status );
}
运行结果如下:

多进程通信之二:消息队列。这个怕是很多人都听过,不过印象往往停留在kafka、rabbitmq之类的用于服务器解耦网络消息队列软件上。消息队列是消息的链接表(一种常见的数据结构),但是这种消息队列存储于系统内核中(不是用户态),一般我们外部程序使用一个key来对消息队列进行读写操作。在PHP中,是通过msg_*系列函数完成消息队列操作的。
<?php
// 使用ftok创建一个键名,注意这个函数的第二个参数“需要一个字符的字符串”
$key = ftok( __DIR__, 'a' );
// 然后使用msg_get_queue创建一个消息队列
$queue = msg_get_queue( $key, 0666 );
// 使用msg_stat_queue函数可以查看这个消息队列的信息,而使用msg_set_queue函数则可以修改这些信息
//var_dump( msg_stat_queue( $queue ) );  
// fork进程
$pid = pcntl_fork();
if( $pid < 0 ){
  exit( 'fork error'.PHP_EOL );
} else if( $pid > 0 ) {
  // 在父进程中
  // 使用msg_receive()函数获取消息
  msg_receive( $queue, 0, $msgtype, 1024, $message );
  echo $message.PHP_EOL;
  // 用完了记得清理删除消息队列
  msg_remove_queue( $queue );
  pcntl_wait( $status );
} else if( 0 == $pid ) {
  // 在子进程中
  // 向消息队列中写入消息
  // 使用msg_send()向消息队列中写入消息,具体可以参考文档内容
  msg_send( $queue, 1, "helloword" );
  exit;
}
运行结果如下:

但是,值得大家继续深入研究的是msg_send()和msg_receive()两个函数,这两个的每一个参数都是非常值得深入研究和尝试的。篇幅问题,这里就不再详细描述。
多进程通信之三:信号量与共享内存。共享内存是最快是进程间通信方式,因为n个进程之间并不需要数据复制,而是直接操控同一份数据。实际上信号量和共享内存是分不开的,要用也是搭配着用。*NIX的一些书籍中甚至不建议新手轻易使用这种进程间通信的方式,因为这是一种极易产生死锁的解决方案。共享内存顾名思义,就是一坨内存中的区域,可以让多个进程进行读写。这里最大的问题就在于数据同步的问题,比如一个在更改数据的时候,另一个进程不可以读,不然就会产生问题。所以为了解决这个问题才引入了信号量,信号量是一个计数器,是配合共享内存使用的,一般情况下流程如下:
一个进程不再使用当前共享资源情况下,就会将信号量减1。这个地方,信号量的检测并且减1是原子性的,也就说两个操作必须一起成功,这是由系统内核来实现的。
在php中,信号量和共享内存先后一共也就这几个函数:

其中,sem*是信号量相关函数,shm*是共享内存相关函数。
<?php
// sem key
$sem_key = ftok( __FILE__, 'b' );
$sem_id = sem_get( $sem_key );
// shm key
$shm_key = ftok( __FILE__, 'm' );
$shm_id = shm_attach( $shm_key, 1024, 0666 );
const SHM_VAR = 1;
$child_pid = [];
// fork 2 child process
for( $i = 1; $i <= 2; $i++ ){
  $pid = pcntl_fork();
  if( $pid < 0 ){
    exit();
  } else if( 0 == $pid ) {
    // 获取锁
    sem_acquire( $sem_id );
    if( shm_has_var( $shm_id, SHM_VAR ) ){
      $counter = shm_get_var( $shm_id, SHM_VAR );
      $counter += 1;
      shm_put_var( $shm_id, SHM_VAR, $counter );
    } else {
      $counter = 1;
      shm_put_var( $shm_id, SHM_VAR, $counter );
    }
    // 释放锁,一定要记得释放,不然就一直会被阻锁死
    sem_release( $sem_id );
    exit;
  } else if( $pid > 0 ) {
    $child_pid[] = $pid;
  }
}
while( !empty( $child_pid ) ){
  foreach( $child_pid as $pid_key => $pid_item ){
    pcntl_waitpid( $pid_item, $status, WNOHANG );
    unset( $child_pid[ $pid_key ] );
  }
}
// 休眠2秒钟,2个子进程都执行完毕了
sleep( 2 );
echo '最终结果'.shm_get_var( $shm_id, SHM_VAR ).PHP_EOL;
// 记得删除共享内存数据,删除共享内存是有顺序的,先remove后detach,顺序反过来php可能会报错
shm_remove( $shm_id );
shm_detach( $shm_id );
运行结果如下:

确切说,如果不用sem的话,上述的运行结果在一定概率下就会产生1而不是2。但是只要加入sem,那就一定保证100%是2,绝对不会出现其他数值。

转自 https://github.com/elarity

guanhui07 commented 5 years ago

https://github.com/guanhui07/php7-internal/blob/master/1/fpm.md php-fpm多进程源码分析

guanhui07 commented 5 years ago

https://juejin.im/entry/59f4385bf265da431c6f8fdd PHP实现daemon

http://rango.swoole.com/archives/59

https://akaedu.github.io/book/ch30s01.html

https://akaedu.github.io/book/ch30s03.html c fork函数

guanhui07 commented 5 years ago

https://lihaoquan.me/2019/1/20/linux-kernel-learning-process.html

https://segmentfault.com/a/1190000014735390

https://www.cnblogs.com/zhenbianshu/p/7978835.html 多线程编程Php实现

guanhui07 commented 5 years ago

https://github.com/tobegit3hub/understand_linux_process/blob/master/process_advanced/file_lock.md

guanhui07 commented 5 years ago

https://github.com/guanhui07/workerman_note/blob/master/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/Signal.md 信号使用 源码分析

guanhui07 commented 5 years ago

https://www.ibm.com/developerworks/cn/aix/library/au-libev/index.html libevent

https://zhuanlan.zhihu.com/p/20315482

https://blog.csdn.net/luotuo44/article/details/39547391

https://juejin.im/entry/5b39661be51d4558ae19fd03

event_base_free() 释放资源,这不能销毁绑定事件 event_base_loop() 处理事件,根据指定的base来处理事件循环 event_base_loopbreak() 立即取消事件循环,行为各break语句相同 event_base_loopexit() 在指定的时间后退出循环 event_base_new() 创建并且初始事件 event_base_priority_init() 设定事件的优先级 event_base_set() 关联事件到事件base event_buffer_base_set() 关联缓存的事件到event_base event_buffer_disable() 禁用一个缓存的事件 event_buffer_enable() 启用一个指定的缓存的事件 event_buffer_fd_set() 改变一个缓存的文件系统描述 event_buffer_free() 释放缓存事件 event_buffer_new() 建立一个新的缓存事件 event_buffer_priority_set() 缓存事件的优先级设定 event_buffer_read() 读取缓存事件中的数据 event_buffer_set_callback() 给缓存的事件设置或重置回调函数 event_buffer_timeout_set() 给一个缓存的事件设定超时的读写时间 event_buffer_watermark_set 设置读写事件的水印标记 event_buffer_write() 向缓存事件中写入数据 event_add() 向指定的设置中添加一个执行事件 event_del() 从设置的事件中移除事件 event_free() 清空事件句柄 event_new() 创建一个新的事件 event_set() 准备想要在event_add中添加事件

https://www.cnblogs.com/jkko123/p/6294591.html

https://www.cnblogs.com/nickbai/articles/6762449.html libevent

guanhui07 commented 5 years ago

多进程和多线程其实是作用是相同的。区别是

线程是在同一个进程内的,可以共享内存变量实现线程间通信 线程比进程更轻量级,开很大量进程会比线程消耗更多系统资源 多线程也存在一些问题:

线程读写变量存在同步问题,需要加锁 锁的粒度过大会有性能问题,可能会导致只有1个线程在运行,其他线程都在等待锁。这样就不是并行了 同时使用多个锁,逻辑复杂,一旦某个锁没被正确释放,可能会发生线程死锁 某个线程发生致命错误会导致整个进程崩溃 多进程方式更加稳定,另外利用进程间通信(IPC)也可以实现数据共享。

共享内存,这种方式和线程间读写变量是一样的,需要加锁,会有同步、死锁问题。 消息队列,可以采用多个子进程抢队列模式,性能很好 PIPE,UnixSock,TCP,UDP。可以使用read/write来传递数据,TCP/UDP方式使用socket来通信,子进程可以分布运行

利用fork可以实现一个最简单的并发TCP Server。主进程accept连接,有新的连接到来就Fork一个子进程。子进程中循环recv/send,处理数据。这种模式在请求量不多情况下很实用,像FTP服务器。过去有很多Linux程序都是这种模式的,简单高效,几十行代码就可以实现。当然这种模型在几百个并发的情况下还算不错,大量并发的情况下就有点消耗过大了。


if(($sock = socket_create(AF_INET, SOCK_STREAM, 0)) < 0)
{
echo "failed to create socket: ".socket_strerror($sock)."\n";
exit();
}

if(($ret = socket_bind($sock, $address, $port)) < 0)
{
echo "failed to bind socket: ".socket_strerror($ret)."\n";
exit();
}

if( ( $ret = socket_listen( $sock, 0 ) ) < 0 )
{
echo "failed to listen to socket: ".socket_strerror($ret)."\n";
exit();
}

while (true)
{
$conn = @socket_accept($sock);

//子进程
if(pcntl_fork() == 0)
{
$recv = socket_read($conn, 8192);
//处理数据
$send_data = "server: ".$recv;
socket_write($conn, $send_data);
socket_close($conn);
exit(0);
}
else
{
socket_close($conn);
}
}

http://rango.swoole.com/archives/48

函数fork

由 fork 创建的新进程被称为子进程(child process) fork 函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0, 而父进程的返回值则是新建子进程的进程 ID。将子进程 D 返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程 D。fork 使子进程得到返回值 0 的理由是:一个进程只会有一个父进程,所以子进程总是可以调用 getppid 以获得其父进程的进程 ID(进程 ID0 总是由内核交换进程使用,所以一个子进程的进程 D 不可能为 0)

子进程和父进程继续执行 fork 调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段(见 7.6 节)。

由于在 fork 之后经常跟随着 exec,所以现在的很多实现并不执行一个父进程数据段栈和堆的完全副本。作为替代,使用了写时复制(Copy-On- Write, COW)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。Bach [1986] 的 9.2 节和 Mckusick 等【1996] 的 5.6 节和 5.7 节对这种特征做了更详细的说明。

在说明 fork 函数时,显而易见,子进程是在父进程调用 fork 后生成的。上面又说明了子进程将其终止状态返回给父进程。但是如果父进程在子进程之前终止,又将如何呢?其回答是:对于父进程已经终止的所有进程,它们的父进程都改变为 init 进程。我们称这些进程由 init 进程收养。其操作过程大致是:在一个进程终止时,内核逐个检査所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程 D 就更改为 1 (init 进程的 D)。这种处理方法保证了每个进程有一个父进程。

另一个我们关心的情况是,如果子进程在父进程之前终止,那么父进程又如何能在做相应检査时得到子进程的终止状态呢?如果子进程完全消失了,父进程在最终准备好检查子进程是否终止时是无法获取它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用 wait 或 waitpid 时,可以得到这些信息。这些信息至少包括进程 ID、该进程的终止状态以及该进程使用的 CPU 时间总量。内核可以释放终止进程所使用的所有存储区,关闭其所有打开文件。在 UNIX 术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为優死进程(zombie)。ps (1) 命令将僵死进程的状态打印为 Z。如果编写一个长期运行的程序,它 fork 了很多子进程,那么除非父进程等待取得子进程的终止状态,不然这些子进程终止后就会变成僵死进程。

最后一个要考虑的问题是:一个由 init 进程收养的进程终止时会发生什么?它会不会变成个僵死进程?对此问题的回答是“否”,因为 init 被编写成无论何时只要有一个子进程终止,init 就会调用一个 wait 函数取得其终止状态。这样也就防止了在系统中塞满僵死进程。当提及“一个 init 的子进程”时,这指的可能是 init 直接产生的进程(如将在 9.2 节说明的 getty 进程),也可能是其父进程已终止,由 init 收养的进程。

当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD 信号。因为子进程终止是个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。对于这种信号的系统默认动作是忽略它。第 10 章将说明这些选项。现在需要知道的是调用 wait 或 waitpid 的进程可能会发生什么。

● 如果其所有子进程都还在运行,则阻塞。

●如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。

如果它没有任何子进程,则立即出错返回。

如果进程由于接收到 SIGCHLD 信号而调用 wait,我们期望 wait 会立即返回。但是如果在随机时间点调用 wait,则进程可能会阻塞。

这两个函数的区别如下。

●在一个子进程终止前,wait 使其调用者阻塞,而 waitpid 有一 -选项,可使调用者不阻塞。 ●waitpid 并不等待在其调用之后的第一个终止子进程,它有若千个选项,可以控制它所

等待的进程。

如果子进程已经终止,并且是一个僵死进程,则 wait 立即返回并取得该子进程的状态;否则 wait 使其调用者阻塞,直到一个子进程终止。如调用者阻塞而且它有多个子进程,则在其某一子进程终止时,wait 就立即返回。因为 wait 返回终止子进程的进程 ID,所以它总能了解是哪一个子进程终止了。

guanhui07 commented 5 years ago