swoole 第7章 守护进程、信号和平滑重启

2021-06-09 鲁鲁槟 收藏

一、守护进程

之前我们介绍过进程和线程,今天我们再来谈一谈守护进程。

无论是server初识还是task邂逅,不管我们程序写的多么精彩,都没有办法把项目应用到实际业务中,因为我们知道,把运行server的终端关闭之后,server也就不复存在了。

那有没有一种办法说仅且当电脑关机的时候才终止server的运行,不管终端怎么玩,server也能够在后台持续运行呢?

守护进程(daemon)就是一种长期生存的进程,它不受终端的控制,可以在后台运行。其实我们之前也有了解,比如说nginx,fpm等一般都是作为守护进程在后台提供服务。

熟悉linux的同学可能知道,我们可以利用nohup命令让程序在后台跑。swoole官方也为我们提供了配置选项daemonize,默认不启用守护进程,若要开启守护进程,daemonize设置为true即可。

守护进程有优点,必然也存在缺点。我们启用守护进程后,server内所有的标准输出都会被丢弃,这样的话我们也就无法跟踪进程在运行过程中是否异常之类的错误信息了。为方便起见,swoole为我们提供了另一个配置选项log_file,我们可以指定日志路径,这样swoole在运行时就会把所有的标准输出统统记载到该文件内。

二、信号

学习本文之前,我们了解到,swoole是常驻内存的,若想让修改后的代码生效,就必须Ctrl+C,然后再重启server。对于守护进程化的server呢?了解过kill命令的同学要说了,我直接把它干掉,然后终端下再重启,就可以了。

事实上,对于线上繁忙的server,如果你直接把它干掉了,可能某个进程刚好就只处理了一半的数据,对于天天来回倒腾的你来说,面对错乱的数据你不头疼,DBA也想long死你!

这个时候我们就需要考虑如何平滑重启server的问题了。所谓的平滑重启,也叫“热重启”,就是在不影响用户的情况下重启服务,更新内存中已经加载的php程序代码,从而达到对业务逻辑的更新。

swoole为我们提供了平滑重启机制,我们只需要向swoole_server的主进程发送特定的信号,即可完成对server的重启。

我们在进程模型一文中介绍主进程的主线程的时候也提到过主线程的主要任务之一就是处理信号。

那什么是信号呢?

信号是软件中断,每一个信号都有一个名字。通常,信号的名字都以“SIG”开头,比如我们最熟悉的Ctrl+C就是一个名字叫“SIGINT”的信号,意味着“终端中断”。

三、平滑重启

在swoole中,我们可以向主进程发送各种不同的信号,主进程根据接收到的信号类型做出不同的处理。比如下面这几个

  • SIGTERM,一种优雅的终止信号,会待进程执行完当前程序之后中断,而不是直接干掉进程

  • SIGUSR1,将平稳的重启所有的Worker进程

  • SIGUSR2,将平稳的重启所有的Task进程

如果我们要实现重启server,只需要向主进程发送SIGUSR1信号就好了。

平滑重启的原理:当主进程收到SIGUSR1信号时,主进程就会向一个子进程发送安全退出的信号,所谓的安全退出的意思是主进程并不会直接把Worker进程杀死,而是等这个子进程处理完手上的工作之后,再让其光荣的“退休”,最后再拉起新的子进程(重新载入新的PHP程序代码)。然后再向其他子进程发送“退休”命令,就这样一个接一个的重启所有的子进程。

我们注意到,平滑重启实际上就是让旧的子进程逐个退出并重新创建新的进程。为了在平滑重启时不影响到用户,这就要求进程中不要保存用户相关的状态信息,即业务进程最好是无状态的,避免由于进程退出导致信息丢失。

感觉很美好的样子,凡是重启只要简单的向主进程发送信号就完事了呗。

理想很丰满,现实并非如此。

在swoole中,重启只能针对Worker进程启动之后载入的文件才有效!什么意思呢,就是说只有在onWorkerStart回调之后加载的文件,重启才有意义。在Worker进程启动之前就已经加载到内存中的文件,如果想让它重新生效,还是只能乖乖的关闭server再重启。

lsof -i:9501
kill -9 pid

说了这么多,我们写个例子看看到底怎么样向主进程发送SIGUSR1信号以便有效重启Worker进程。

首先我们创建一个Test类,用于处理onReceive回调的数据,为什么要把onReceive回调的业务拿出来单独写,看完例子你就明白了。

<?php

class Test
{
    public function run($data)
    {
        echo $data;
    }
}

在Test::run方法中,我们第一步仅仅是echo输出swoole_server接收到的数据。

当前目录下我们创建一个swoole_server的类NoReload.php

_serv = new Swoole\Server("127.0.0.1", 9501);
        $this->_serv->set([
            "worker_num" => 1,
        ]);
        $this->_serv->on("Receive", [$this, "onReceive"]);

        $this->_test = new Test;
    }
    /**
     * start server
     */
    public function start()
    {
        $this->_serv->start();
    }
    public function onReceive($serv, $fd, $fromId, $data)
    {
        $this->_test->run($data);
    }
}

$noReload = new NoReload;
$noReload->start();

特别提醒:我们在初始化swoole_server的时候的写法是命名空间的写法

new Swoole\Server

该种风格的写法等同于下划线写法 ,swoole对这两种风格的写法都支持

new swoole_server

此外我们看下server的代码逻辑:类定义之前require_once了Test.php,初始化的时候设置了一个Worker进程,注册了NoReload::onReceive方法为swoole_server的onReceive回调,在onReceive回调内接收到的数据传递给了Test::run方法处理。

接下来我们写一个client脚本测试下运行结果

connect("127.0.0.1", 9501) || exit("connect failed. Error: {$client->errCode}\n");
// 向服务端发送数据
$client -> send("Just a test.\n");
$client->close();

客户端的测试代码也很简单,连接server并向server发一个字符串信息

01.png

正常,没发现问题,server所在终端输出了客户端send的内容。

在server不动的情况下我们修改下Test.php,代码如下

<?php
class Test
{
    public function run($data)
    {
        // echo $data;
        $data = json_decode($data, true);
        if (!is_array($data)) {
            echo "server receive \$data format error.\n";
            return ;
        } 
        var_dump($data);
    }
}

原先echo直接输出,现在我们改了下Test的代码,如果接收到的数据经过json_decode处理后不是数组,就返回一段内容并结束,否则打印receive到的数据

如果这个时候我们不对server进行重启,运行client的结果肯定还是一样的,看下结果

01.png

server端新的代码未生效,如果Test.php新的代码生效了,会在server所在终端输出“server receive $data format error.”,这符合我们的认知。

下面我们通过ps命令查看下左侧server的主进程的pid,然后通过kill命令向该进程发送SIGUSR1信号,看看结果如何

01.png结果发现即使向主进程发送了SIGUSR1信号,但是左侧server终端显示的依然是未生效的php代码,这也是对的,因为我们说过新的程序代码只针对在onWorkerStart回调之后才加载进来的php文件才能生效,我们事例中Test.php是在class定义之前就加载进来了,所以肯定不生效。

我们新建一个Reload.php文件,把server的代码修改如下

<?php

class Reload
{
    private $_serv;
    private $_test;

    /**
     * init
     */
    public function __construct()
    {
        $this->_serv = new Swoole\Server("127.0.0.1", 9501);
        $this->_serv->set([
            "worker_num" => 1,
        ]);
        $this->_serv->on("Receive", [$this, "onReceive"]);
        $this->_serv->on("WorkerStart", [$this, "onWorkerStart"]);
    }
    /**
     * start server
     */
    public function start()
    {
        $this->_serv->start();
    }
    public function onWorkerStart($serv, $workerId)
    {
        require_once("Test.php");
        $this->_test = new Test;
    }
    public function onReceive($serv, $fd, $fromId, $data)
    {
        $this->_test->run($data);
    }
}

$reload = new Reload;
$reload->start();

仔细观察,我们仅仅移除了在类定义之前引入Test.php以及在__construct中new Test的操作。

而是在__construct方法中增加了onWorkerStart回调,并在该回调内引入Test.php并初始化Test类。

Test.php的代码,我们仍然先后用上面的两处代码为例,运行client看下结果

01.png

图例右侧运行client过程中,给主进程发送SIGUSR1信号之前,记得修改Test.php的代码,然后再运行client脚本测试。

结果我们发现,在给主进程发送SIGUSR1信号之后,Test.php的新代码生效了。这也便实现了热重启的效果。

如此,我们在Test.php中不论如何更新代码,只需要找到主进程的PID,向它发送SIGUSR1信号即可。同理,SIGUSR2信号是只针对Task进程的,后面可以自行测试下。

热重启的效果实现了,现在针对Reload.php的server,让该server进程守护化看看。

__construct中,$serv->set代码修改如下

$this->_serv->set([   
    "worker_num" => 1,          
    "daemonize" => true,          
    "log_file" => __DIR__ . "/server.log",
]);

我们在终端下在运行下Reload.php

php Reload.php

代码好像突然就执行完毕了,现在终端不“卡”着了,终端的执行权又重新交给了终端,我们的server呢?怎么回事?

其实这就是守护进程化的概念,我们开启的swoole_server进程已经在后端跑着了,不信我们ps看下

ps aux | grep Reload
manks 14117   xxx...    1:51下午   0:07.49 php Reload.php
manks 14117   xxx...    1:51下午   0:07.47 php Reload.php
manks 36807   xxx...    1:54下午   0:00.00 grep Reload
manks 14116   xxx...    1:51下午   0:00.01 php Reload.php

发现还真有几个进程在跑着。

不光如此,我们再看下当前目录下是不是有一个server.log的日志文件,我们在swoole_server::set的log_file配置项指定了日志文件就是它,那么在server运行的过程中,所有的标准输出都会输出到这个文件中,此时我们再运行下client.php,然后打开server.log看看是不是终端输出的结果都显示在该文件内了呢?毋庸置疑。

注意!!!!当使用daemonize,需要在启动的回调里面同时改变一下目录,否则会有奇怪的事情发生

public function onWorkerStart($serv, $workerId){
   chdir(__DIR__);
    require_once "Test.php";
    $this->_test = new Test;
}
阅读 104