PHP回顾之多进程编程

作者 | 2018年6月24日

转载请注明文章出处: https://tlanyan.me/php-review-multi-process/

PHP回顾系列目录

为了更好的利用多核CPU,我们需要多进程或多线程。但在常规web开发中,我们极少用到这两种并发技术(curl_multi等特殊函数除外)。如果脚本运行在CLI模式下,多进程和多线程技术是提高多核CPU的有力工具。

相对于多线程,多进程的程序具有健壮、无锁、对分布式支持更好等特点。本文来学习一下PHP的多进程编程。

多进程

PHP中与(多)进程相关的两个重要拓展是PCNTLPOSIXPCNTL主要用来创建、执行子进程和处理信号,POSIX拓展则实现了POSIX标准中定义的接口。由于Windows不是POSIX兼容的,所以POSIX拓展在Windows平台上不可用。

先上简单的代码看多进程编程:

// 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。可以根据函数的返回值判断接下来的执行环境在父进程中还是子进程中。

fork调用让系统创建一个与当前进程几乎完全一样的进程,除了进程号等少数信息不一样,进程的代码段、堆栈、数据段的值都一致。父进程打开了一个文件,复制的子进程同样享有这个句柄,这是过去多进程能监听同一个端口的原理;子进程基于父进程fork时的环境继续执行(代码段共享)直到退出。

去掉上述代码中else语句块的exit能将帮助你更好地理解上面这段话。程序的本意是生成10个子进程,去掉子进程执行代码的exit后,子进程执行完else块中代码后继续执行foreach循环,最终生成55个子进程(为什么是55个?)!鉴于此,一个良好的实践是在子进程的执行代码后总是加上exit终止语句,除非你真的有把握子进程会按照预期执行。

除了fork,另外一种多进程技术是exec。systemexecproc_open等函数会生成一个新的进程执行外部命令(并返回结果)。这些函数的本质是fork一个进程,然后调用shell执行命令,主进程等待其执行结束。函数执行期间,主进程除了等待无法处理其他任务,所以一般不认为这是多进程编程。实践中可以结合fork来并发执行外部命令。

孤儿进程与僵尸进程

多进程编程需要考虑到的一个问题是孤儿进程和僵尸进程。进程结束前父进程已经退出,进程变成孤儿进程;进程退出后父进程在执行且未回收子进程,那么进程变成僵尸进程。孤儿进程是仍在执行的进程,僵尸进程则已经停止执行,只剩下进程号一缕孤魂仍能被外界感知。

孤儿进程会被系统的根进程(init进程,进程号为1)接管,运行结束后由根进程回收。下面代码演示孤儿进程的父进程的变化:

// 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_waitpcntl_waitpidpcntl_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不再好使,程序依旧调皮的执行。要结束程序,可以向进程发送无法捕捉的信号,例如SIGKILLps 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系统支持SIGINTSIGKILL等31个常见异步信号,某些系统支持更多的信号。

内核收到进程信号后,会查看进程是否注册了处理函数,如果未注册则执行默认操作;否则当进程运行在用户态时,内核回调信号处理函数并移除信号。PHP中收到信号后触发信号回调函数的方式有三种:

  1. tick触发,例如每执行100条低级指令检查信号:declare(ticks=100)
  2. 使用pcntl_signal_dispatch手动触发,用法见上文signal.php
  3. PHP7.1起可以使用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函数可以向指定进程发送信号,达到通信的目的。

进程间通信技术主要有:

  1. 管道(pipe),流管道(s_pipe)和有名管道(FIFO);
  2. 信号(signal);
  3. 消息队列(message queue);
  4. 共享内存(share memory);
  5. 信号量(semaphore);
  6. 套接字(socket);

这些通信技术的详细内容请参考文末的链接,或者其他文献,本文不再详述。

守护进程

通过php test.php方式执行程序,关闭终端后程序会退出。要让程序能长期执行,需要额外的手段。总结起来主要有三种:

  1. nohup
  2. screen/tmux等工具;
  3. 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,会发现终端输入和服务端消息都能及时响应。同时,连接断开的信号也被正确的广播。

总结

本文简要介绍了多进程编程的几个方面,最后给出一个应用的例子,希望对学习多进程的同行有帮助。

感谢阅读!

参考

  1. http://php.net/manual/en/book.pcntl.php
  2. http://php.net/manual/en/book.posix.php
  3. https://www.cnblogs.com/hicjiajia/archive/2011/01/20/1940154.html
  4. http://gityuan.com/2015/12/20/signal/
  5. https://www.cnblogs.com/hoys/archive/2012/08/19/2646377.html
  6. http://www.cnblogs.com/taobataoma/archive/2007/08/30/875743.html
  7. https://www.jianshu.com/p/c1015f5ffa74
  8. https://blog.csdn.net/column/details/linuxkernel-detailed.html
  9. https://segmentfault.com/a/1190000008556669
打赏 赞(1)
微信
支付宝
微信二维码图片

微信扫描二维码打赏

支付宝二维码图片

支付宝扫描二维码打赏

PHP回顾之多进程编程” 有一条评论

  1. Pingback: PHP回顾之执行流程及相关概念 – Masterton的博客

发表评论

电子邮件地址不会被公开。 必填项已用*标注