本文由 简悦 SimpRead 转码, 原文地址 xie.infoq.cn

背景

前两周老大给安排了一个任务,写一个监听信号的包。因为我司的项目是运行在容器里边的,每次上线,需要重新打包镜像,然后启动。在重新打包之前,Dokcer 会先给容器发送一个信号,然后等待一段超时时间 (默认 10s) 后,再发送 SIGKILL 信号来终止容器

现在有一种情况,容器中有一个常驻进程,该常驻进程的任务是不断的消费队列里的消息。假设现在要上线,需要关杀掉容器,Docker 给容器里跑的常驻进程发送一个信号,告诉它我 10s 后会将你关闭,假设现在已经过了 9 秒,常驻进程刚从队列中取出一条消息,1s 内还没将后续逻辑执行完,进程就已经被杀了,此时这条消息就丢失了,且可能会产生脏数据

上边就是这次任务的背景,需要通过监听信号来决定后续如何操作。对于上边这种情况,当常驻进程收到 Docker 发送的关闭信号时,将该进程阻塞即可,一直 sleep,直到杀掉容器。OK,清楚背景之后,下边就介绍一下 PHP 中的信号 (后边会再整理一篇这个包如何写,并将包发布到 https://packagist.org/,供需要的小伙伴使用)

一、在 Linux 操作系统中有哪些信号

1、简单介绍信号

信号是事件发生时对进程的通知机制,有时又称为软件中断。一个进程可以向另一个进程发送信号,比如子进程结束时都会向父进程发送一个 SIGCHLD(17 号信号)来通知父进程,所以有时信号也被当作一种进程间通信的机制。

在 linux 系统下,通常我们使用 kill -9 XXPID 来结束一个进程,其实这个命令的实质就是向某进程发送 SIGKILL(9 号信号),对于在前台进程我们通常用 Ctrl+c 快捷键来结束运行,该快捷键的实质是向当前进程发送 SIGINT(2 号信号),而进程收到该信号的默认行为是结束运行

2、常用信号

下边这些信号,可以使用 kill -l 命令进行查看

PHP 进程信号处理 - 图1

下边介绍几个比较重要且常用的信号:

PHP 进程信号处理 - 图2

PHP 进程信号处理 - 图3

二、PHP 中处理信号相关函数

PHP 的 pcntl 扩展以及 posix 扩展为我们提供了若干操作信号的方法 (若想使用这些函数,需要先安装这几个扩展)

下边具体介绍几个我在本次任务中用到的方法:

declare

declare 结构用来设定一段代码的执行指令。declare 的语法和其它流程控制结构相似

  1. declare (directive)
  2. statement

directive 部分允许设定 declare 代码段的行为。目前只认识两个指令:ticks 和 encoding。declare 代码段中的 statement 部分将被执行——怎样执行以及执行中有什么副作用出现取决于 directive 中设定的指令

Ticks

Tick(时钟周期)是一个在 declare 代码段中解释器每执行 N 条可计时的低级语句就会发生的事件 N 的值是在 declare 中的 directive 部分用 ticks=N 来指定的。不是所有语句都可计时。通常条件表达式 参数表达式 都不可计时。在每个 tick 中出现的事件是由 register_tick_function() 来指定的,注意每个 tick 中可以出现多个事件

更详细的内容,可查看官方文档:https://www.php.net/manual/zh/control-structures.declare.php

<?php
declare(ticks=1);//每执行一条时,触发register_tick_function()注册的函数
$a=1;//在注册之前,不算
function test(){//定义一个函数
    echo "执行\n";
}
register_tick_function('test');//该条注册函数会被当成低级语句被执行
for($i=0;$i<=2;$i++){//for算一条低级语句
    $i=$i;//赋值算一条
}
输出:六个“执行”

pcntl_signal

pcntl_signal, 安装一个信号处理器

pcntl_signal ( int $signo , callback $handler [, bool $restart_syscalls = true ] ) : bool

函数 pcntl_signal() 为 signo 指定的信号安装一个新的信号处理器


declare(ticks = 1);
pcntl_signal(SIGINT,function(){
    echo "你按了Ctrl+C".PHP_EOL;
});
while(1){
    sleep(1);//死循环运行低级语句
}
输出:当按Ctrl+C之后,会输出“你按了Ctrl+C”

posix_kill

posix_kill, 向进程发送一个信号

posix_kill ( int $pid , int $sig ) : bool

第一个参数为进程 ID,第二个参数为你要发送的信号


a.php
<?php
declare(ticks = 1);
echo getmypid();//获取当前进程id
pcntl_signal(SIGINT,function(){
    echo "你给我发了SIGINT信号";
});
while(1){
    sleep(1);
}

b.php
<?php
posix_kill(执行1.php时输出的进程id, SIGINT);

pcntl_signal_dispatch

pcntl_signal_dispatch, 调用等待信号的处理器

pcntl_signal_dispatch ( void ) : bool

函数 pcntl_signal_dispatch() 调用每个等待信号通过 pcntl_signal() 安装的处理器


<?php
echo "安装信号处理器...\n";
pcntl_signal(SIGHUP,  function($signo) {
     echo "信号处理器被调用\n";
});
echo "为自己生成SIGHUP信号...\n";
posix_kill(posix_getpid(), SIGHUP);
echo "分发...\n";
pcntl_signal_dispatch();
echo "完成\n";
?>

输出:
安装信号处理器...
为自己生成SIGHUP信号...
分发...
信号处理器被调用
完成

pcntl_async_signals()

异步信号处理,用于启用无需 ticks (这会带来很多额外的开销)的异步信号处理。(PHP>=7.1)


<?php
pcntl_async_signals(true); // turn on async signals

pcntl_signal(SIGHUP,  function($sig) {
    echo "SIGHUP\n";
});

posix_kill(posix_getpid(), SIGHUP);

输出:
SIGHUP

三、PHP 中处理信号量的方式

前边我们知道我们可以通过 declare(ticks=1) 和 pcntl_signal 组合的方式监听信号,即每一条 PHP 低级语句,就会检查一次当前进程是否有未处理的信号,这其实是十分耗性能的。

pcntl_signal 的实现原理是,触发信号后先将信号加入一个队列中。然后在 PHP 的 ticks 回调函数中不断检查是否有信号,如果有信号就执行 PHP 中指定的回调函数,如果没有则跳出函数。


PHP_MINIT_FUNCTION(pcntl)
{
    php_register_signal_constants(INIT_FUNC_ARGS_PASSTHRU);
    php_pcntl_register_errno_constants(INIT_FUNC_ARGS_PASSTHRU);
    php_add_tick_function(pcntl_signal_dispatch TSRMLS_CC);

    return SUCCESS;
}

在 PHP5.3 之后,有了 pcntl_signal_dispatch 函数。这个时候将不在需要 declare, 只需要在循环中增加该函数, 就可以调用信号通过了:


<?php
echo getmypid();//获取当前进程id
pcntl_signal(SIGUSR1,function(){
    echo "触发信号用户自定义信号1";
});
while(1){
    pcntl_signal_dispatch();
    sleep(1);//死循环运行低级语句
}

大家都知道 PHP 的 ticks=1 表示每执行 1 行 PHP 代码就回调此函数。实际上大部分时间都没有信号产生,但 ticks 的函数一直会执行。如果一个服务器程序 1 秒中接收 1000 次请求,平均每个请求要执行 1000 行 PHP 代码。那么 PHP 的 pcntl_signal,就带来了额外的 1000 * 1000,也就是 100 万次空的函数调用。这样会浪费大量的 CPU 资源。比较好的做法是去掉 ticks,转而使用 pcntl_signal_dispatch,在代码循环中自行处理信号。

pcntl_signal_dispatch 函数的实现:


void pcntl_signal_dispatch()
{
    //.... 这里略去一部分代码,queue即是信号队列
    while (queue) {
        if ((handle = zend_hash_index_find(&PCNTL_G(php_signal_table), queue->signo)) != NULL) {
            ZVAL_NULL(&retval);
            ZVAL_LONG(&param, queue->signo);

            /* Call php signal handler - Note that we do not report errors, and we ignore the return value */
            /* FIXME: this is probably broken when multiple signals are handled in this while loop (retval) */
            call_user_function(EG(function_table), NULL, handle, &retval, 1, &param TSRMLS_CC);
            zval_ptr_dtor(&param);
            zval_ptr_dtor(&retval);
        }
        next = queue->next;
        queue->next = PCNTL_G(spares);
        PCNTL_G(spares) = queue;
        queue = next;
    }
}

但是上边这种,也有个恶心的地方就是,它得放在死循环中。PHP7.1 之后出来了一个完成异步的信号接收并处理的函数:

pcntl_async_signals


<?php
//a.php
echo getmypid();
pcntl_async_signals(true);//开启异步监听信号
pcntl_signal(SIGUSR1,function(){
    echo "触发信号";
    posix_kill(getmypid(),SIGSTOP);
});
posix_kill(getmypid(),SIGSTOP);//给进程发送暂停信号

//b.php
posix_kill(文件1进程, SIGCONT);//给进程发送继续信号
posix_kill(文件1进程, SIGUSR1);//给进程发送user1信号

通过 pcntl_async_signals 方法,就不用再写死循环了。

监听信号的包:

https://github.com/Rain-Life/monitorSignal