swoole 第7章 守护进程、信号和平滑重启 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 回调的业务拿出来单独写,看完例子你就明白了。

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

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

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

    $this->_serv_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 发一个字符串信息

https://file.lulublog.cn/images/3/2022/07/jCbMLYY6bbeWESmEJJczeOzjODmBEe.png

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

在 server 不动的情况下我们修改下 Test.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 的结果肯定还是一样的,看下结果

https://file.lulublog.cn/images/3/2022/07/g7nP2njsEj6E56d6emb22t5jN63m4E.png

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

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

https://file.lulublog.cn/images/3/2022/07/PoyCPYAOdK0UU1rt81l1cOa1E8TAOP.png

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

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

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 看下结果

https://file.lulublog.cn/images/3/2022/07/X689YE8w9UYU1Ey2CMwwyyguGRwcPN.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;
}

四、平滑重启操作

1、给 swoole 进程定义别名

$server->on('start', function (Swoole\WebSocket\Server $server) {
    swoole_set_process_name("swoole_websocket_server");
});

2、reload.sh 脚本

#/bin/bash
echo "loading..."
pid="pidof swoole_websocket_server"
echo $pid
kill -USR1 $pid
echo "loading success"

linux 中执行

reload.sh

五、程序内的自重启

1、$server->reload

安全地重启所有 Worker/Task 进程,确保正在执行的任务执行完成后才重启,master/manager 进程不会停止。

bool Server->reload(bool $only_reload_taskworkrer = false)

$only_reload_taskworkrer 是否仅重启Task进程。
reload有保护机制,当一次reload正在进行时,收到新的重启信号会丢弃。

如果设置了user/group,Worker进程可能没有权限向Master进程发送信息,这种情况下必须使用root账户,在shell中执行kill指令进行
重启reload指令对addProcess添加的用户进程无效。

注意:平滑重启只对 onWorkerStart 或 onReceive 等在 Worker 进程中 include / require 的 PHP 文件有效,Server 启动前就已经 include / require 的PHP文件,不能通过平滑重启重新加载。

对于 Server 的配置即 $serv->set() 中传入的参数设置,必须关闭/重启整个 Server 才可以重新加载。这就要求你能判断出,你修改的文件是在哪里被引入的,task / worker / master。

2、$server->stop()

停止 Worker 进程。默认是停止当前 Worker 进程。

3、$server->shutdown()

关闭服务器。可在 Worker 进程内调用来关闭服务器,如果 Worker 进程内发生致命错误或者逻辑错误时可这样做。

客户端的连接是否会保持,取决于服务的运行模式:

  • SWOOLE_PROCESS:来自客户端的连接是由 master 进程管理,worker 进程的重启和异常退出,不会影响连接本身。

  • SWOOLE_BASE:客户端连接直接维持在 Worker 进程中,因此 reload 时会切断所有连接。并且 Base 模式不支持 reload Task进程

打赏

取消

感谢您的支持,我会继续努力的!

扫码支持
扫码打赏,你说多少就多少

打开微信扫一扫,即可进行扫码打赏哦

阅读 948