看看 PHP 高性能框架 Workerman 源码中信号的使用姿势

开发 前端
信号是一种轻量级的消息传递机制,通常用于软件中断异步通知。比如我们常用的 kill -9 xxx​ 就是对 xxx 程序发送了强制终止其进程的 SIGKILL 信号,还有我们经常会用的 Ctrl+C 或 Ctrl+D 等键盘操作事件,也会产生 SIGINT 终端退出信号。

大家好,我是码农先森。

信号是一种轻量级的消息传递机制,通常用于软件中断异步通知。比如我们常用的 kill -9 xxx 就是对 xxx 程序发送了强制终止其进程的 SIGKILL 信号,还有我们经常会用的 Ctrl+C 或 Ctrl+D 等键盘操作事件,也会产生 SIGINT 终端退出信号。

但在我们平时的业务编程中很少会涉及到信号,这是由于信号通常都是用在进程的管理上,比如父子进程的通信、进程资源的管理等。因此在 PHP-FPM 模式下是不会用多进程编程的,如果那位大胆的朋友用了,那恭喜你会收获到意想不到的惊喜。

不过如果经常使用 Workerman 或 Swoole 编程的朋友,那对信号应该颇有了解了吧。这里不管大家有没有接触过信号,都建议学习其原理和用法,兴许对以后的编程有所帮助,毕竟技术这东西就怕用时方恨少。为了更通俗易懂的描述信号,我用一个大白话的例子来讲解一下。假设你是一个打工仔,这里好像不用假设因为你就是个打工仔哈哈,这时你正戴着耳机沉浸在代码的世界了。

突然公司广播响起了吃「下午茶」的音乐,于是你便快马加鞭赶到下午茶的餐桌面前蓄势待发。才刚吃上鸡腿,公司广播又响起了线上 Bug 的告警声,你不得不叼着鸡腿,又回到座位上排查起问题了。线上问题还没解决,这是公司广播又通知你参加某某答辩会,但这次你完全没有时间顾及这个通知,所以你完全忽略了。

这里的公司你可以理解为操作系统,广播通知理解为信号,你自己理解为一个进程。你在接收到信号之后,可以做对应的事情,同样也可以做与通知不相干的事情,比如通知叫你改线上的 Bug 但是你舍不得下午茶还想继续吃,那你也可以任性不及时改「只不过事后就要挨批了哈哈」。

还有就是也可以把通知不当回事,不予理会完全忽略不处理。这段大白话虽说的好,但中国有句古话「说的好听不如做的好看」,那么接下来我们就以实际的例子来以身入代码。

下面这个是当前进程接收外部信号的例子,使用 pcntl_signal 注册指定信号的回调函数,回调函数 signalHandler 会根据接收到的不同信号进入到相应的 switch 分支中执行对应的处理逻辑。

处理完之后,便可以退出程序。其中的 pcntl_signal_dispatch 函数起着监听待处理信号,并执行信号所注册回调函数的作用。还有 where(true) 会一直循环执行,直到接收到信号。

<?php

// 定义信号处理函数
function signalHandler($signal)
{
    switch ($signal) {
        case SIGINT:
            // 比如公司广播响起了吃 下午茶 的音乐
            echo "捕获到 SIGINT 信号... " . PHP_EOL;
            // 接下来可以自行实现相应的逻辑
            exit;
        case SIGTERM:
            // 比如公司广播又响起了线上 Bug 的告警声
            echo "捕获到 SIGTER 信号... " . PHP_EOL;
            // 接下来可以自行实现相应的逻辑
            exit;
        case SIGALRM:
            // 比如公司广播又通知你参加某某答辩会
            echo "捕获到 SIGALRM 信号... " . PHP_EOL;
            // 接下来可以自行实现相应的逻辑
            // 当然你也可以不处理,任由它去
            exit;
        default:
            echo "捕获到未知信号: $signal " . PHP_EOL;
            exit;
    }
}

// 注册信号回调函数
// 就好比接受到不同的通知,就应该要做对应的事情
// 这里统一回调 signalHandler 这个函数,你也可以定义不同的函数
pcntl_signal(SIGINT, 'signalHandler');
pcntl_signal(SIGTERM, 'signalHandler');

// 输出一下当前的进程号,方便测试
echo "当前进程 PID: " . getmypid() . PHP_EOL;

// 不断监听信号,接收到信号后调用相应的信号回调函数
// 这里就像你自己的耳朵一直在等待接收通知
while (true) {
    pcntl_signal_dispatch();
}

执行 php index.php 命令启动程序,然后通过 kill 向进程发送 SIGTERM 信号,最终会在 signalHandler 回调函数中捕获到该信号。

[manongsen@root php_signal]$ php index.php 
当前进程 PID: 43195

[manongsen@root php_signal]$ kill -SIGTERM 43195

[manongsen@root php_signal]$ php index.php 
当前进程 PID: 43195
捕获到 SIGTERM 信号...

再来看一个进程间信号传递的例子,假设你在当前这家公司被内耗死了,然后你开始找一个替补的人,好不容易找到一个卷王,但是干了一段时间就跑路了,最后你也无奈了索性也摆烂跑路了。

使用 pcntl_fork 创建了一个子进程,父进程中调用 pcntl_signal 函数给 SIGCHLD 信号注册回调函数 signalHandler 然后父进程一直监听信号。直到子进程 exit 退出了,最后父进程触发了回调函数 signalHandler 开始回收子进程资源,之后父进程也 exit 退出了。

<?php

// 定义信号处理函数
function signalHandler($signal)
{
    $pid = getmypid();
    // 回收子进程资源
    // 你接收到了他跑路的信号,开始回收他的代码权限、账号等资源
    $status = 0;
    $cid = pcntl_wait($status, \WUNTRACED);
    echo "父进程: {$pid}, 收到子进程: {$cid}, 退出信号: {$signal}" . PHP_EOL;
    
    // 最后你抵不住也跑路了
    echo "父进程: {$pid}, 最后也退出了" . PHP_EOL;
    exit;
}

// 在当前进程 Fork 一个子进程
// 比如终于招聘了一个替补你的人
$pid = pcntl_fork();
if ( $pid < 0 ) {
 // 这里被你放鸽子了
 // 你也摆烂直接跑路算了
    exit('fork error');
} else if( $pid > 0 ) {
   // 父进程执行空间 ...
   echo "父进程: " . getmypid() . PHP_EOL;

   // 注册信号回调函数
   // SIGCHLD 表示一个子进程已经终止或停止
   // 你相当于给他触发跑路的信号,准备了一个处理方案
   pcntl_signal(SIGCHLD, "signalHandler");

   // 监听信号
   // 你相当于一直监听他的动向
   while(true){
      pcntl_signal_dispatch();
   }
} 

// 子进程执行空间 ...
$cid = getmypid();
echo "子进程: {$cid}" . PHP_EOL;

// 休眠 5 秒钟
sleep(5);

// 退出
// 假设他干了5秒钟就跑路了
exit;

执行 php index.php 可以看到相应的执行结果。

[manongsen@root php_signal]$ php index.php 
父进程: 47008
子进程: 47009
子进程: 47009 退出了
父进程: 47008, 收到子进程: 47009, 退出信号: 20
父进程: 47008, 最后也退出了

有了上面这两个例子的基础,我们再来看 Workerman 源码中对信号的使用就会更得心应手,翻开 Worker.php 文件中的第 548 行,这个 runAll 函数中会调用 installSignal 函数注册信号回调函数,还会调用 monitorWorkers 函数时刻监听子进程的信号。

<?php

// workerman/Worker.php:548
public static function runAll()
{
    // ...

    // 注册信号回调函数
    static::installSignal();
    
    // ...
    
    // 监听 Worker 子进程信号
    static::monitorWorkers();
}

看到 installSignal 这个函数的实现,是不是有种似曾相似的感觉,和上面例子的注册方式基本一样。

<?php

// workerman/Worker.php:1140
protected static function installSignal()
{
    // ...
    // 信号回调函数
    $signalHandler = '\Workerman\Worker::signalHandler';
    // 退出信号
    \pcntl_signal(\SIGINT, $signalHandler, false);
    \pcntl_signal(\SIGTERM, $signalHandler, false);
    \pcntl_signal(\SIGHUP, $signalHandler, false);
    \pcntl_signal(\SIGTSTP, $signalHandler, false);
    // 优雅退出信号
    \pcntl_signal(\SIGQUIT, $signalHandler, false);
    // 重载信号
    \pcntl_signal(\SIGUSR1, $signalHandler, false);
    // 优雅重载信号
    \pcntl_signal(\SIGUSR2, $signalHandler, false);
    // 状态信号
    \pcntl_signal(\SIGIOT, $signalHandler, false);
    // 连接状态信号
    \pcntl_signal(\SIGIO, $signalHandler, false);
    // SIGPIPE 管道信号,忽略该信号
    \pcntl_signal(\SIGPIPE, \SIG_IGN, false);
}

这个函数 signalHandler 接收到子进程的信号,并且根据不同的信号 switch 到不同的 case 分支。

<?php

// workerman/Worker.php:1220
public static function signalHandler($signal)
{
    switch ($signal) {
        // 退出
        case \SIGINT:
        case \SIGTERM:
        case \SIGHUP:
        case \SIGTSTP:
            static::$_gracefulStop = false;
            // 这里会把所有的 Woker 子进程 Kill 掉,并且销毁相应子进程的 Event-Loop 事件循环
            static::stopAll();
            break;
        // 优雅的退出
        case \SIGQUIT:
            // 优雅退出在调用 posix_kill 函数时传递的是 SIGTERM 信号
            static::$_gracefulStop = true;
            static::stopAll();
            break;
        // 重载
        case \SIGUSR2:
        case \SIGUSR1:
            if (static::$_status === static::STATUS_SHUTDOWN || static::$_status === static::STATUS_RELOADING) {
                return;
            }
            static::$_gracefulStop = $signal === \SIGUSR2;
            static::$_pidsToRestart = static::getAllWorkerPids();
            // 对 Worker 进程实现重载
            static::reload();
            break;
        // 展现状态
        case \SIGIOT:
            // 这里会输出一些 全局状态信息、进程状态信息
            // 比如 load-average、event-loop、workers-count 等
            static::writeStatisticsToStatusFile();
            break;
        // 展现连接状态
        case \SIGIO:
            // 这里会输出一些进程连接相关的统计信息
            // 比如 Recv-Q、Send-Q、Bytes-R、Bytes-W、Status 等
            static::writeConnectionsStatisticsToStatusFile();
            break;
    }
}

最后启动监听函数 monitorWorkersForLinux 除了监听信号之后,还做了些资源的回收工作,此外还会回调用户自定义的 Stop 函数,便于做一些业务上的处理。

<?php

// workerman/Worker.php:1735
protected static function monitorWorkersForLinux()
{
    // 默认运行状态
    static::$_status = static::STATUS_RUNNING;
    // 一直监听直到所有的进程退出
    while (1) {
        \pcntl_signal_dispatch();
        $status = 0;
        // 这些做一些子进程资源的回收工作
        $pid    = \pcntl_wait($status, \WUNTRACED);
        \pcntl_signal_dispatch();

        // ...

        // 做一些清理工作
        if (static::$_status === static::STATUS_SHUTDOWN && !static::getAllWorkerPids()) {
            // 这里还会回调用户设定的 Stop 回调函数,便于做一些业务上的处理
            // 然后直接 exit 当前进程
            static::exitAndClearAll();
        }
    }
}

在涉及到多进程编程的场景中信号的使用最为频繁,比如 Workerman 在 Master 主进程中通过信号监听了 Worker 子进程的状态,可以及时获取到子进程的运行信息,一旦子进程挂了也可以迅速的拉取,在整个服务进程都退出后也可以及时回收内存资源。

还有在 Linux 系统下我们也经常使用 Kill 命令来向进程传递信号,不过大多数情况都是用在了强制结束进程上,这是最简单粗暴杀死进程的方法。不过我之前遇到过某些病毒进程,无论如何都 Kill 不掉,不知大家有没有见识过?

这次我主要分享了信号的一些基础知识,以及在 Workerman 源码中的使用姿势,大家最好是可以实践一下文中的这些例子,实践过后相信对信号会有更深一步的理解,希望对大家能有所帮助。

责任编辑:武晓燕 来源: 码农先森
相关推荐

2024-08-12 08:43:09

2012-08-08 10:10:31

PHP

2015-03-27 11:42:44

日志管理PHPSeasLog

2024-02-01 09:21:08

RevoltPHP高性能

2024-10-18 10:27:50

PHP框架webma

2022-08-15 08:01:35

微服务框架RPC

2024-11-04 08:16:08

Go语言Web 框架

2020-06-17 16:43:40

网络IO框架

2023-10-31 18:52:29

网络框架XDP技术

2024-02-26 07:43:10

大语言模型LLM推理框架

2024-04-28 10:17:30

gnetGo语言

2011-06-20 15:40:19

QT 信号

2018-02-28 10:11:50

腾讯框架开源

2019-07-31 14:36:46

Linux服务器框架

2024-11-06 11:25:06

2024-11-01 11:46:59

2017-03-16 11:39:33

Openstack源码姿势

2017-05-25 10:50:16

Python高性能服务

2023-11-01 11:13:58

Linux信号处理定时器

2021-05-28 05:18:08

PHP语言roadrunnner
点赞
收藏

51CTO技术栈公众号