优化 Workerman 检查主进程是否存活的逻辑

2021-03-17
次阅读
5 分钟阅读时长

主要新增了判断进程是否为 Workerman 进程的逻辑,从而优化了确定主进程是否存活的准确性。

发现问题

年前逛 GitHub 的时候,发现 Workerman 有一个 2017 年打开的 Issue:already running,原文如下:

Where is the problem?! I reboot the server and it is the first time I want to run workerman

php index.php start -d
The result is

Workerman[index.php] start in DAEMON mode
Workerman[index.php] already running

大概意思就是重启服务器之后,第一次启动 Workerman 会提示已经在运行了,但实际上并没有运行。

因为重启服务器之后,保存 Workerman 主进程 PID 的文件仍保留在磁盘上。

正常情况下,Workerman 退出时会清理掉这个文件,但是该用户重启服务器后文件并没有被清理,导致 Workerman 误认为已经在运行中。

作者给出了一个补救方法:手动删除记录主进程 PID 的文件。虽然临时解决了问题,但是每次出现都要去手动处理一下,感觉不太友好。

要想解决这个问题,首先得弄清楚两个问题:

  • 为什么 Workerman 没有清理 PID 文件?
  • 为什么重启服务器后启动 Workerman 提示已经在运行中?

Workerman 判断是否已运行的逻辑

Workerman 在启动的时候会生成一个文件,用于记录主进程的 PID。

// Start file.
$backtrace        = \debug_backtrace();
static::$_startFile = $backtrace[\count($backtrace) - 1]['file'];

// 生成文件名
$unique_prefix = \str_replace('/', '_', static::$_startFile);

// 保存记录主进程 PID 的文件路径
if (empty(static::$pidFile)) {
    static::$pidFile = __DIR__ . "/../$unique_prefix.pid";
}

// 设置主进程名称(记住这个进程名称,后面会用到)
static::setProcessTitle(static::$processTitle . ': master process  start_file=' . static::$_startFile);

然后检查 Workerman 是否已经在运行中。

// 获取主进程的 PID,如果文件不存在或者不是一个正常的文件则返回 0
$master_pid      = \is_file(static::$pidFile) ? \file_get_contents(static::$pidFile) : 0;

// 如果 PID 存在就给它发送一个信号 `0`,信号量 `0` 类似于 ping,用于检测进程是否存活
// 然后判断当前进程 PID 是否不等于文件中记录的 PID(不相等说明 Workerman 已经在运行中,但是又再次执行命令了)
$master_is_alive = $master_pid && \posix_kill($master_pid, 0) && \posix_getpid() !== $master_pid;
if ($master_is_alive) {
    // 如果主进程存活并且执行的命令为 start,提示 Workerman 正在运行中并退出
    if ($command === 'start') {
        static::log("Workerman[$start_file] already running");
        exit;
    }
} elseif ($command !== 'start' && $command !== 'restart') {
    // 如果主进程未存活且执行的命令不是 start 或 restart,则提示 Workerman 未运行并退出
    static::log("Workerman[$start_file] not run");
    exit;
}

当一系列检查通过后,开始保存主进程的 PID。

protected static function saveMasterPid()
{
    // 非 Linux 系统不保存 PID
    if (static::$_OS !== \OS_TYPE_LINUX) {
        return;
    }

    // 获取主进程的 PID
    static::$_masterPid = \posix_getpid();

    // 将主进程的 PID 写入到文件中
    if (false === \file_put_contents(static::$pidFile, static::$_masterPid)) {
        throw new Exception('can not save pid to ' . static::$pidFile);
    }
}

当收到 SIGINTSIGTERMSIGHUP 等信号时,将进程状态设置为 STATUS_SHUTDOWN 并通知子进程退出。

如果主进程的状态为 STATUS_SHUTDOWN 并且所有子进程已经退出,就会去清除 PID 文件并退出。

protected static function exitAndClearAll()
{
    foreach (static::$_workers as $worker) {
        $socket_name = $worker->getSocketName();
        if ($worker->transport === 'unix' && $socket_name) {
            list(, $address) = \explode(':', $socket_name, 2);
            @\unlink($address);
        }
    }
    // 删除 PID 文件
    @\unlink(static::$pidFile);
    static::log("Workerman[" . \basename(static::$_startFile) . "] has been stopped");
    if (static::$onMasterStop) {
        \call_user_func(static::$onMasterStop);
    }
    // 退出进程
    exit(0);
}

复现问题

看到这有人肯定会问了,这不是有清理 PID 文件的机制吗?为什么还能从文件中获取到 PID?

我先在虚拟机中进行了测试,服务器在重启的时候会发送 SIGTERM 信号通知进程,Workerman 可以正常退出并且清理 PID 文件。

但是在云服务器中测试的时候,如果勾选了强制重启会导致 Workerman 收不到信号,也就不能够执行 exitAndClearAll() 里面的代码了。

来自服务器厂商的提醒:强制重启会导致云服务器中未保存的数据丢失,请谨慎操作。

为什么给 PID 文件中的进程发信号还会返回 true 呢?

服务器在重启后,另一个进程启动了,它的 PID 与 Workerman 的旧 PID 相同(没错,就是这么巧)。

所以在检查主进程是否存活时,还要判断该进程是否为 Workerman 的进程。

解决问题

Issue 中 @detain 给出了一个使用 shell 脚本的解决方法:

To check to see if its running and safely remove pid files can do something like:

if [ $(php start.php status 2>/dev/null|grep "PROCESS STATUS"|wc -l) -eq 0 ]; then
  # clean up old run, remove pid file or run a stop command?
  php start.php stop
  php start.php start -d
fi

先通过 php start.php status 命令获取 Workerman 的状态,然后统计 PROCESS STATUS 出现的次数(每个进程都会有一个 PROCESS STATUS),如果次数为 0 说明没有运行中的进程,就可以执行停止命令,再启动 Workerman。

受到这个方法启发,然后基于它改造了 Workerman 检查主进程是否存的逻辑,一顿复制粘贴之后就有了第一版的代码:

// Get master process PID.
$master_pid      = \is_file(static::$pidFile) ? \file_get_contents(static::$pidFile) : 0;
// Master is still alive?
if (static::checkMasterIsAlive($master_pid)) {
    if ($command === 'start') {
        static::log("Workerman[$start_file] already running");
        exit;
    }
}


/**
 * Check master process is alive
 *
 * @param $master_pid
 * @return bool
 */
protected static function checkMasterIsAlive($master_pid)
{
    if (empty($master_pid)) {
        return false;
    }

    $master_is_alive = $master_pid && \posix_kill($master_pid, 0) && \posix_getpid() !== $master_pid;
    if (!$master_is_alive) {
        return false;
    }

    // Master process will send SIGUSR2 signal to all child processes.
    \posix_kill($master_pid, SIGUSR2);
    // Sleep 1 second.
    \sleep(1);

    return stripos(static::formatStatusData(), 'PROCESS STATUS') !== false;
}

逻辑跟 shell 脚本差不多,就不再解释了。这个解决方法也有两个小问题:

  • 执行命令时会延迟一秒钟,因为执行一些命令的时候需要 sleep 一秒钟等待子进程写入状态信息。
  • 如果另一个进程的 PID 与 Workerman 的旧 PID 相同,它将接收 SIGUSR2 信号。

感觉在启动的时候慢一秒应该还能接受,只要处理请求的时候不慢就行了,于是就提交了 PR,并描述了这一段代码的作用及带来的问题。

Fixed: #125

There is a problem:
if another process starts and the pid is the same as the workerman's old pid, it will receive the SIGUSR2 signal.

没过多久作者便在 PR 下面回复了我:

Thank you for your pr.

There is a problem:
if another process starts and the pid is the same as the workerman's old pid, it will receive the SIGUSR2 signal.

If the PR is merged, some commands will be delayed by one second.
I think a better way is to read /proc/PID information to determine whether it is a PHP process or a workerman process.

先说了延迟一秒钟的问题,接着又给出了更好的解决方法:读取 /proc/PID 信息来确定它是其它进程还是 Workerman 进程。

搜索资料之后发现可以读取 /proc/PID/cmdline 得到启动进程时的命令。

Workerman 在启动时会调用 Worker::setProcessTitle() 方法覆盖 cmdline 的内容,所以实际上得到的是 Workerman 的进程名称。

/**
 * Set process name.
 *
 * @param string $title
 * @return void
 */
protected static function setProcessTitle($title)
{
    // 设置一个空的错误处理函数,避免提示 PHP 错误
    \set_error_handler(function(){});
    // >=php 5.5
    if (\function_exists('cli_set_process_title')) {
        \cli_set_process_title($title);
    } // Need proctitle when php<=5.5 .
    elseif (\extension_loaded('proctitle') && \function_exists('setproctitle')) {
        \setproctitle($title);
    }
    // 还原错误处理函数
    \restore_error_handler();
}

默认情况下,进程名称将被设置为 WorkerMan: master process start_file=启动文件的完整路径。

所以只需要判断 cmdline 是否包含 Worker::$processTitle 就可以知道该进程是否为 Workerman 进程。

cmdline可以保存多少个字符跟启动命令有关,比如启动命令是 php index.php start -d,那么进程名称就有可能被截取为 WorkerMan: worker proce,所以这里用的是包含而不是等于。

protected static function checkMasterIsAlive($master_pid)
{
    if (empty($master_pid)) {
        return false;
    }

    // 检查进程是否存活
    $master_is_alive = $master_pid && \posix_kill($master_pid, 0) && \posix_getpid() !== $master_pid;
    if (!$master_is_alive) {
        return false;
    }

    // 到了这里说明进程是存活的,但是不能保证这个进程是 Workerman 进程
    // 需要读取进程信息才能确定,有任何一个步骤导致不能获取进程信息都要返回 true
    // 因为根据上面的检测结果,进程是存活的

    $cmdline = "/proc/{$master_pid}/cmdline";

    // 进程信息不可读或设置的进程名为空
    if (!is_readable($cmdline) || empty(static::$processTitle)) {
        return true;
    }
    
    $content = file_get_contents($cmdline);
    // 未读取到进程信息
    if (empty($content)) {
        return true;
    }

    // 判断是否包含进程名称
    return stripos($content, static::$processTitle) !== false;
}

再次提交,没过多久就收到了代码被合并的邮件。

总结

回答一下上面提出的两个问题:

Q:为什么 Workerman 没有清理 PID 文件?

A:因为 Workerman 没有正常退出(强制关机、重启、断电)

Q:为什么重启服务器后启动 Workerman 提示已经在运行中?

A:因为服务器重启后,其他进程的 PID 与 Workerman 的旧 PID 相同,误认为是 Workerman 进程。

相关链接

本文作者:她和她的猫
本文地址https://her-cat.com/posts/2021/03/17/optimize-workerman-to-check-whether-the-main-process-is-alive/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!