PHP 多进程初探


PHP 多进程初探


正文

来自博客 https://github.com/elarity/advanced-php, 因为文本换行等没处理好,所以这里整理下。

1 php进程daemon化的正确做法

daemon 音标 : [‘di:mən] , 中文含义为守护神或精灵的意思 。 其实它还有个意思 : 守护进程 。

守护进程简单地说就是可以脱离终端而在后台运行的进程。这在Linux中是非常常见的一种进程 , 比如apache或者mysql等服务启动后 , 就会以守护进程的方式进驻在内存中 .

以PHP为例 , 假如我有个耗时间的任务需要跑在后台 : 将所有mysql中user表中的2000万用户全部导入到redis中做缓存预热, 那么这个任务估计一时半会是不会结束的 , 这个时候就需要编写一个php脚本以daemon形式运行在系统中 , 结束后自动退出.

在Linux中 , 大概有三种方式实现脚本后台化 :

1 . 在命令后添加一个&符号 , 比如 php task.php & 。 这个方法的缺点在于 如果terminal终端关闭 , 无论是正常关闭还是非正常关闭 ,这个php进程都会随着终端关闭而关闭 , 其次是代码中如果有echo或者print_r之类的输出文本 , 会被输出到当前的终端窗口中 .

2 . 使用nohup命令 , 比如 nohup php task.php & 。 默认情况下 , 代码中echo或者print_r之类输出的文本会被输出到php代码同级目录的nohup.out文件中。 如果你用exit命令或者关闭按钮等正常手段关闭终端 , 该进程不会被关闭 , 依然会在后台持续运行 . 但是如果终端遇到异常退出或者终止 , 该php进程也会随即退出 。 本质上 , 也并非稳定可靠的daemon方案 。

3 . 使用fork和setsid , 我暂且称之为 : *nix解决方案 . 具体看下代码 :

<?php
// 一次fork  
$pid = pcntl_fork();
if ($pid < 0) {
    exit(' fork error. ');
} else if ($pid > 0) {
    exit(' parent process. ');
}
// 将当前子进程提升会会话组组长 这是至关重要的一步 
if (!posix_setsid()) {
    exit(' setsid error. ');
}
// 二次fork
$pid = pcntl_fork();
if ($pid < 0) {
    exit(' fork error. ');
} else if ($pid > 0) {
    exit(' parent process. ');
}

// 真正的逻辑代码们 下面仅仅写个循环以示例
for ($i = 1; $i <= 100; $i++) {
    sleep(1);
    file_put_contents('daemon.log', $i, FILE_APPEND);
}
?>

另外还有一种运行脚本的方式也值得一提:利用 screen / tmux 等软件,将脚本运行在可以在一个虚拟终端之上。

2 php多进程初探—开篇

实际上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出来新进程则成为子进程,原进程则成为父进程,子进程拥有父进程的副本。这里要注意:

  • 子进程与父进程共享程序正文段
  • 子进程拥有父进程的数据空间和堆、栈的副本,注意是副本,不是共享
  • 父进程和子进程将继续执行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函数)。 这里就不解释,实在想不明白的,可以动手自己画画思考一下。


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

  • i=1的时候父进程的pid不为0 这时候fork了一个pid=0的子进程a, 子进程数量1
  • i=2的时候父进程fork了一个子进程b, 子进程a又fork了一个子进程c, 子进程数量1+2
  • i=3的时候父进程fork了一个子进程d, a子进程fork了e, b子进程fork了f, c子进程fork了g,子进程数量1+2+4=7

  • 至于在fork子进程退出的时候, i=1、=2、=3的时候都只有一个父进程fork一个子进程 所以只有三个儿子

3 php多进程初探—孤儿和僵尸

上面我整篇尬聊的都是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);

    //$wait_result大于0代表子进程已退出,返回的是子进程的pid,非阻塞时0代表没取到退出子进程,为什么会没有取到子进程,
    // 因为当时子进程没有退出,在休眠sleep

    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()如何正确使用啊?这样用,看起来毕竟不太科学。

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

4 php多进程初探—信号

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

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

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

  • 键盘上按某些组合键,比如Ctrl+C或者Ctrl+D等,会产生SIGINT信号。
  • 使用posix kill调用,可以向某个进程发送指定的信号。
  • 远程ssh终端情况下,如果你在服务器上执行了一个阻塞的脚本,正在阻塞过程中你关闭了终端,可能就会产生SIGHUP信号。
  • 硬件也会产生信号,比如OOM了或者遇到除0这种情况,硬件也会向进程发送特定信号。

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

  • 直接忽略,不做任何反映。就是俗称的完全不鸟。但是有两种信号,永远不会被忽略,一个是SIGSTOP,另一个是SIGKILL, 因为这两个进程提供了向内核最后的可靠的结束进程的办法。
  • 捕捉信号并作出相应的一些反应,具体响应什么可以由用户自己通过程序自定义。
  • 系统默认响应。大多数进程在遇到信号后,如果用户也没有自定义响应,那么就会采取系统默认响应,大多数的系统默认响应就是终止进程。

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

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

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

        // 会输出20个0,第21个是子进程退出后返回的子进程号,第22个开始输出-1,那为何第22个开始一直是-1,因为当找不到子进程时,
        // 或者错误时是返回-1的,而0只代表当前子进程没有退出
        echo $wait_result . PHP_EOL;
    }
} else if (0 == $pid) {
    // 在子进程中
    // 子进程休眠20秒钟后直接退出
    cli_set_process_title('php child process');
    sleep(20);
    exit;
}

下图是运行结果:

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

  • 第一次:子进程正在休眠中,父进程依旧在循环中。
  • 第二次:子进程已经退出了,父进程依旧在循环中,但是代码还没有执行到pcntl_waitpid(),所以在子进程退出后到父进程执行回收前这段空隙内子进程变成了僵尸进程。
  • 第三次:此时父进程已经执行了pcntl_waitpid(),将已经退出的子进程回收,释放了pid等资源。

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

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

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

  • pcntl_signal(),安装一个信号处理器,具体说明是pcntl_signal ( int $signo , callback $handler [, bool $restart_syscalls = true ] ), 参数signo就是信号,callback则是响应该信号的代码段,返回bool值。
  • pcntl_signal_dispatch(),调用每个等待信号通过pcntl_signal() 安装的处理器,参数为void,返回bool值。

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

<?php
$pid = pcntl_fork();
if ($pid < 0) {
    exit('fork error.' . PHP_EOL);
} else if ($pid > 0) {
    // 在父进程中
    // 给父进程安装一个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;
}

运行结果如下:

5 swoole的进程模型

很多phper一直停留在php web开发的mvc CURD中,也听到很多人对自己陷入这种困境中多有不满,但又不知道如何提高自己,摆脱困境。 活脱脱就像一直趴在玻璃上的苍蝇,前途一片光明,就是飞不出去,可悲可叹。

话说回来,实际上做到一名合格的CURDer也并不是一件容易的事情,万万不可眼高手低。

如果想提高自己,也不一定非要通过工作,我认为一个人的提高更多是在非工作环境中。 php的开发人员开始通过尝试接触并使用swoole或者workerman(0_0 或者等我的php socket框架,啦啦啦 0_0)来提高自己的认知水准, 这就像2017年那部挺火的电视剧中那样,鼓励大家学英语,我认为挺好的。

学习swoole前,我建议大家有最好有如下知识储备或者有准备学习如下知识的准备:

  • 多进程多线程知识储备,多进程进程间通信,多进程多线程锁相关,进程管理等
  • 服务器IO模型相关知识储备
  • 熟练的*NIX系统操作以及良好的使用习惯
  • TCP协议以及socket相关知识储备

考虑到本PO主原来叨叨了一坨与进程相关的知识,所以这篇就来一坨与swoole进程相关的内容,这也是要学习并好好利用swoole的第一课, 打好基础,会对你使用有着更大的帮助。

先贴一张图,是从官方wiki上偷过来的,不得不说,如果你去swoole官方wiki上找,都不一定能找到这个图, 我的言下之意就是老韩写的文档组织方式以及内容确实比较混乱,没有对比就没有伤害,比如人家workerman的文档的,我贴出来,你们感受一下:

啦啦啦,盗图狗要贴图了:


结合上图开始简单描述一下swoole中进程角色们:

  • master进程

这个进程比较复杂,也是我认为最核心的进程,这是一个包含多线程的进程,分别是一个主线程和n个reactor线程(数量可以配置)。 其中,主线程用于accept新的连接,然后评估一下每个reactor线程负责维护的连接数,然后分配给数量最少的那个reactor线程, 最大程度保证每个reactor线程的负载量是均衡的。本质上讲,一旦一个socket可读或者可写了,就由reactor线程发送给worker进程或者发送回客户端。 除此之外,主线程还负责对所有信号的接管,避免reactor线程收到信号的打扰中断。 说的洋气点儿就是:master进程负责了连接的accept、托管、socket的可读可写(数据的发送和接受),本质上讲,master进程负责了IO。 还需要注意一点儿的是reactor线程是彻底的全异步非阻塞工作方式。

  • manager进程

manager进程是worker进程和taskworker进程的妈,说的洋气点儿就是manager进程fork出来了worker进程和taskworker进程,生出来了就得管, 所以,manager进程得负责对worker进程和taskworker进程的抚养义务, 具体包括监控它们的状态、当它们意外挂了后重新拉起一个新的进程(避免了僵尸进程)、平滑重启(就是传说中的reload)。

  • worker进程

worker进程是manager进程fork出来的,这个进程说白了就是搬砖干活(官方文档中屡次提到的业务代码),其实就是平时码的那些curd业务逻辑代码,懂了吧? 只不过worker进程比较diao的是,这个进程可以用异步方式去工作,也可以用同步方式去工作。如果听不懂什么意思,那就先背过,先混个脸熟再说。

  • taskworker进程

taskworker进程(后文中称tasker进程)实际本质上也是worker进程,只不过是一种特殊的worker进程。如果你的worker进程中存在一些耗时耗力的操作, 那么可以先抛给tasker进程,自己先去干别的,等tasker干完了,再由worker进程取回,非常diao。但是tasker进程只能工作在同步方式下,并不能使用异步。 这就是为什么tasker进程不可以使用定时器,而worker进程可以使用定时器的原因。

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

6 php多进程初探—利用多进程开发点儿东西

干巴巴地叨逼叨了这么久,是时候表演真正的技术了!

做个高端点儿的玩意吧,假如我们要做一个任务系统,这个系统可以在后台帮我们完成一大波(注意是一大波)数据的处理,那么我们自然想到, 多开几个进程分开处理这些数据,同时我们不能执行了php task.php后,就将终端挂起,万一一不小心关闭了终端都会导致任务失败, 所以我们还要实现程序的daemon化。好啦,开始了!

首先,我们第一步就得将程序daemon化了!

<?php
// 设置umask为0,这样,当前进程创建的文件权限则为777
umask(0);
$pid = pcntl_fork();
if ($pid < 0) {
    exit('fork error.');
} else if ($pid > 0) {
    // 主进程退出
    exit();
}
// 子进程继续执行

// 最关键的一步来了,执行setsid函数!
/*
 http://linux.die.net/man/2/setsid
 [setsid详解][1] 主要目的脱离终端控制,自立门户。
创建一个新的会话,而且让这个pid统治这个会话,他既是会话组长,也是进程组长。
而且谁也没法控制这个会话,除了这个pid。当然关机除外。。
这时可以看做pid为这个无终端的会话组长。
注意这个函数需要当前进程不是父进程,或者说不是会话组长。
在这里当然不是,因为父进程已经被kill

换句话来说就是 : 调用进程必须是非当前进程组组长,调用后,产生一个新的会话期,且该会话期中只有一个进程组,且该进程组组长为调用进程,
没有控制终端,新产生的group ID 和 session ID 被设置成调用进程的PID
*/
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个子进程的状态等。

<?php
// 由于*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中移除掉
            /*
            可能有朋友疑惑为什么要判断$wait_result == $pid_item,也不知道这时候程序运行到哪里了,
            大家是否还记得第四章php多进程初探---信号中提到循环while等待子进程被回收,出现20个0,第21个输出子进程号,
            所以这里foreach判断是否等于子进程号,-1 == $wait_result就不用多讲,也提到,子进程找不到了
            */
            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);
}

7 php多进程初探—再次谈daemon进程

其实前面是谈过一次daemon进程的,涉及过多原理,但是并不影响使用。今天打算说说关于daemon进程更多的二三事, 本质上说,如果你仅仅是简单实现利用一下daemon进程,这个不看也是可以的。

讲真,*NIX真是波大精深,越是深入看越是发现它的厉害。原理往往都是枯燥的,大家都不爱看,但这并不影响我坚持写自己对这些东西的理解。

三个概念,理(bei)解(song)一下:

  • 进程组。一坨相关的进程可以组成一个进程组,每个进程组都会有一个组ID(正整数),每个进程组都会有一个组长进程,组长进程的ID等于进程组ID。 组长进程可以创建新的进程组以及该进程组中的其他进程。一个进程组的是有生命周期的,即便是组长进程挂了,只有组里还有其他的活口, 那就应该算该进程组依然存活,只有到组里最后一个活口也挂了,那真的就是彻底没了。
  • 会话。一坨相关的进程组组成了一个会话。在*NIX下,是通过setsid()创建一个新的会话。但是值得注意的是,组长进程不能创建会话, 简单理解就是在组长进程中,执行setsid函数会报错,这点很重要。所以一般都是组长进程执行fork,然后主进程退出, 因为子进程的进程ID是新分配的,而子进程的进程组ID是继承父进程的,所以子进程就注定不可能是组长进程, 从而可以确保子进程中一定可以执行setsid函数。在执行setsid函数时候,一般会发生下面三个比较重要的事情:
    • 该进程会创建一个新的进程组,该进程为进程组组长(或者你可以认为这是一种提升)
    • 该进程会创建一个会话组并成为该会话的会话首进程(会话首进程就是创建该会话的进程)
    • 该进程会失去控制终端。如果该进程本来就没有控制终端,则罢了(liao)。如果有,那么该进程也将脱离该控制终端,与之失去联系。
  • 控制终端。每个会话可能会拥有一个控制终端(看着比较玄学,你可以暂时理解为就一个那种黑乎乎的命令行窗口),建立与控制终端连接的会话首进程叫做控制进程。

结合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。

简单总结一下:

  • 上述三个进程一共形成了两个进程组,bash自己为一组,组ID为15793,组长进程为bash自己 ; ps和less为一组,组ID为15816,组长进程为ps进程
  • 上述三个进程属于同一个会话,会话ID为15793,会话首进程为bash进程
  • 控制终端则为打开的terminal窗口,与之关联的控制进程则为bash进程

通过这么一顿分析,是不是感觉可以接受点儿了?然后是,叨逼叨了半天这个,跟daemon进程有啥子关系?

啦啦啦,下面通过引入代码直接分析:

<?php
$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%需要我们进一步完善。那么,需要完善什么呢?我们修改一下上述代码, 让程序在最终的代码段中执行一些文本输出:

<?php
$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),避免权限上的一些问题。所以较为完整的代码如下:

<?php
// 设置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);

8 php多进程初探—进程间通信二三事

往往开启多进程的目的是为了一起干活加速效率,前面说了不同进程之间的内存空间都是相互隔离的,也就说进程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上汇聚的方法大概有如下三种:

  • 使用指定键IPC_PRIVATE来创建一个IPC结构,然后将返回的标志符保存到一个文件中,然后进程之间通过读取这个文件中的标志符进行通信。 使用公共的头文件。这么做的缺点是多了IO操作。
  • 将共同认同的键写入到公共头文件中。这么做的缺点这个键可能已经与一个IPC结构关联,这样在使用这个键创建结构的时候就可能会出错, 然后必须删除已有的IPC结构再重新创建。
  • 认同一个文件路径名和项目ID,然后使用ftok将这两个参数转换成一个键。这将是我们使用的方式。

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创建一个键名,注意这个函数的第二个参数“需要一个字符的字符串”
/*
共享内存,消息队列,信号量它们三个都是找一个中间介质,来进行通信的,这种介质多的是。
就是怎么区分出来,就像唯一一个身份证来区分人一样。你随便来一个就行,就是因为这。
只要唯一就行,就想起来了文件的设备编号和节点,它是唯一的,但是直接用它来作识别好像不太好,不过可以用它来产生一个号。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()两个函数,这两个的每一个参数都是非常值得深入研究和尝试的。 篇幅问题,这里就不再详细描述。

这里还需要提示一下ftok函数,第一个参数的目录被删除后,重新再建立一个同名的目录, 这时候生成的key是不同了,所以确保ftok()的文件不被删除。

多进程通信之三:信号量与共享内存。共享内存是进程间通信最快的方式,因为n个进程之间并不需要数据复制, 而是直接操控同一份数据。实际上信号量和共享内存是分不开的,要用也是搭配着用。 *NIX的一些书籍中甚至不建议新手轻易使用这种进程间通信的方式,因为这是一种极易产生死锁的解决方案。 共享内存顾名思义,就是一坨内存中的区域,可以让多个进程进行读写。这里最大的问题就在于数据同步的问题, 比如一个在更改数据的时候,另一个进程不可以读,不然就会产生问题。所以为了解决这个问题才引入了信号量, 信号量是一个计数器,是配合共享内存使用的,一般情况下流程如下:

  • 当前进程获取将使用的共享内存的信号量
  • 如果信号量大于0,那么就表示这块儿共享资源可以使用,然后进程将信号量减1
  • 如果信号量为0,则进程进入休眠状态,一直到信号量大于0,进程唤醒开始执行

一个进程不再使用当前共享资源情况下,就会将信号量加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();
    // 其实在fork后,子进程也会继承父进程的变量与资源,在子进程echo SHM_VAR就知道了
    if ($pid < 0) {
        exit();
    } else if (0 == $pid) {
        // 获取锁
        sem_acquire($sem_id);
        if (shm_has_var($shm_id, SHM_VAR)) {
            //shm_get_var第二参数必须是int型
            $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);
        }
        /*
        有人可能不明白:既然某个子进程获取到锁了,为什么要在if里面都设置shm_put_var?
        其实程序是这样运行:fork后,假如A子进程先到达(A、B子进程到达顺序由底层某些算法决定的),
        A子进程去共享内存找一个SHM_VAR值,发现没有,就进入else{}里面shm_put_var,设置SHM_VAR为 $counter = 1,释放锁后,进程退出,
        B子进程发现现在没有锁住了,我自已先加锁,查找有无SHM_VAR值,刚好发现有值,就+1,并更新SHM_VAR值
        */
        // 释放锁,一定要记得释放,不然就一直会被阻锁死
        sem_release($sem_id);
        exit;
    } else if ($pid > 0) {
        $child_pid[] = $pid;
    }
}

while (!empty($child_pid)) {
    foreach ($child_pid as $pid_key => $pid_item) {
        $wait_result = pcntl_waitpid($pid_item, $status, WNOHANG);
        //必须判断子进程回收的状态,如果不加判断,第一次两个子进程返回都是0,直接unset后会无法进入while,导致僵尸进程
        if ($wait_result == -1 || $wait_result > 0) {
            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,绝对不会出现其他数值。

接下来关于socket编程相关的内容看这里






参考资料

https://github.com/elarity/advanced-php

swoole的进程模型 http://t.ti-node.com/thread/6445811931285553153


返回