多进程编程 - 孤儿进程/僵尸进程/信号量通信

多进程编程中经常会涉及到孤儿进程/僵尸进程的概念,下面将用代码实际的演示一遍。

孤儿进程

孤儿进程指的是父进程结束运行时,还未运行结束的子进程。这些子进程将会成为系统的孤儿进程,系统将会调用 pid = 1 的 init 进程来负责接管这些孤儿进程,监听其是否运行结束,以便回收资源。

所以,孤儿进程对系统没有太大的影响。在某些场景下我们可能还会可以的利用孤儿进程,比如编写守护进程,或一些不需要等待执行结果的多任务时。

僵尸进程

僵尸进程(zombie),可以方便的使用 top 命令来查看系统是否存在。僵尸进程产生的原因是父进程仍在运行,但没有对子进程运行结束时发送的 SIGCHLD 信号进行处理(常用的是使用 wait/waitpid 来配合处理) 或 手动捕获 SIGCHLD 信号却没做任何处理(作死)。因为父进程还在,所以也无法被系统的 init 进程接管,便成为了会影响系统性能的僵尸进程。

查看系统中是否存在僵尸进程

top

多进程编程 - 孤儿进程/僵尸进程/信号量通信

ps -A -o stat,ppid,pid,cmd | grep -e '^[Zz]'

多进程编程 - 孤儿进程/僵尸进程/信号量通信

3261 即为父进程的 pid,kill -9 {pid} 即可清除这些僵尸进程

如何避免僵尸进程

在了解了僵尸进程产生的原因和影响后,我们要尽可能的避免僵尸进程的产生。

其实一般你随便写个多进程大都不会产生长期存在的僵尸进程,如果不需要同步父子进程的执行状态,或父进程在短时间内会立即退出,子进程可能会出现短暂的僵尸进程的状态,但最终都会因为父进程退出转为孤儿进程被 init 回收。

但如果父进程在提供常驻服务时创建子进程,且不使用 wait/waitpid 或 对 SIGCHLD信号 做 SIG_IGN 处理,子进程就变成僵尸进程长期占用系统资源了。

SIG_DFL / SIG_IGN

// c/c++
signal(SIGCHLD, SIG_IGN)
// php
pcntl_signal(SIGCHLD, SIG_IGN)

显式的声明将 SIGCHLD 信号做 SIG_IGN 处理(简单的理解成强制孤儿化子进程就好),结束的子进程则会交由系统 init 回收,父进程无需关注子进程的退出,故可以提高服务性能。

这里还需要理解 SIG_IGN 是在运行时层面忽略信号,而并非捕获了不作处理,部分信号量 SIG_IGN 和 捕获但不处理 的效果相同,但对一些特别的信号量(SIGCHLD)二者是有很大区别的。

<?php
// 都可以屏蔽终端暂停信号 ctrl+z
pcntl_signal(SIGTSTP, SIG_IGN);// 忽略
pcntl_signal(SIGTSTP, function () {
    // 捕获但不做处理
});
// 都可以屏蔽终端结束信号 ctrl+c
pcntl_signal(SIGINT, SIG_IGN);// 忽略
pcntl_signal(SIGINT, function () {
    // 捕获但不做处理
});

// 二者结果完全不同
pcntl_signal(SIGCHLD, SIG_IGN);// 忽略 交由 init 处理 安全
pcntl_signal(SIGCHLD, function () {
    // 捕获但不做处理 就会导致子进程成为僵尸进程 非安全
});

父进程已退出,子进程成为孤儿进程最终被 init 回收。
父进程未退出,但不处理子进程退出时发送过来的的 SIGCHLD 信号,子进程成为僵尸进程。

我们只需要保证:在父进程退出前,父进程调用了 wait/waitpid 函数处理了可能来自子进程的 SIGCHLD 信号即可。

wait/waitpid 会阻塞父进程,等待/返回某子进程的退出状态及 pid。

我们可以用来避免僵尸进程的产生/同步父子进程的执行状态,在一些场景下我们可能正需要父进程等待所有的子进程执行完毕后去汇总某些数据。

<?php
/**
 * 安全的多进程处理
 */
if (!function_exists('pcntl_fork')) {
    trigger_error("need pcntl extension!", E_USER_ERROR);
}

$workers_num = 4;
$workers_pid = [];

for ($i = 0; $i < $workers_num; $i++) {
    $pid = pcntl_fork();

    if ($pid == -1) {
        trigger_error("child process create failed!", E_USER_ERROR);
    }

    if ($pid == 0) {
        // 子进程执行模块
        echo "I am child pid: " . getmypid() . PHP_EOL;
        sleep(rand(1, 3));
        exit(0);
    } else {
        // 父进程管理子进程的pid
        $workers_pid[] = $pid;
    }
}

// 父进程使用 wait/waitpid 等待/处理子进程的 SIGCHLD 信号
while (true) {
    // 若有未退出的子进程
    if (! empty($workers_pid)) {
        // pcntl_wait 会阻塞/等待子进程发送的信号量
        $worker_pid = pcntl_wait($status);
        if ($status == 0) { // 正常退出
            echo 'child ' . $worker_pid . ' safe exited!' . PHP_EOL;
        } else { // exit 状态码
            echo 'child ' . $worker_pid . ' wrong end with status: ' . $status . PHP_EOL;
        }

        // 删除子进程的 pid
        $key = array_search($worker_pid, $workers_pid);
        unset($workers_pid[$key]);
    } else {
        // 所有子进程都已执行完毕
        break;
    }
}

// 此时所有的子进程已执行完毕 不会有孤儿/僵尸进程产生
echo "main end" . PHP_EOL;

我们在创建多进程任务时应该极力避免僵尸进程的产生,最常用的即父进程使用 wait/waitpid 函数监听子进程的退出并将其回收,或者可以用上面的 SIG_IGN 处理 SIGCHLD 信号,根据自身业务需求选择正确处理方式即可。

以上的代码父进程因 wait/waitpid 会处于阻塞状态,等待某一个子进程执行完毕发送 SIGCHLD 信号后获取其 pid 以及 exit_code 后才能继续运行。有没有什么更好的方式呢?比如子进程可以发送一个通知给父进程,父进程定时检测是否有此通知,有的话就回收子进程后再去做别的工作,这就是下面要讲的进程通信 -- 信号量。

进程通信--信号量

进程通信的方式有:管道,信号量,消息队列,共享内存。

这里我们简单的使用信号量来进行子父进程间的通信,通信目的也很简单:子执行完毕时通知父将其快点回收,别丢那里不管不问成了僵尸进程。

如果不使用信号量通信

<?php

for ($i = 0; $i < 4; $i++) {
    $pid = pcntl_fork();
    
    if ($pid == -1) {
        trigger_error("child process create failed!" . PHP_EOL, E_USER_ERROR);
    }

    if ($pid == 0) {
        // -- child process code --
        echo "child: " . getmypid() . " running!" . PHP_EOL;
        sleep(rand(1, 3));
        // child process exit code 可以被父进程接受到以判别子进程的退出状态
        exit(0);
        // -- child process code --
    } else {
        // father process code
        $children_pid_set[] = $pid;
        echo "father: child " . $pid . " created!" . PHP_EOL;
    }
}

while (true) {
    // 这里会阻塞直到接收到子进程发送的 SIGCHLD 信号
    $child_pid = pcntl_wait($status);
    
    // 删除子进程的 pid
    $key = array_search($child_pid, $children_pid_set);
    unset($children_pid_set[$key]);
    
    if (empty($children_pid_set)) {
        echo "all children process run finished!" . PHP_EOL;
        break;
    }
}

echo "father process run finished!" . PHP_EOL;

即父进程会被 pcntl_await 阻塞而不能做其他的事情,如果我们用信号量通信就可以更为灵活。

<?php
/**
 * @author big_cat
 * @version 0.0.1
 * 非阻塞版的父进程创建管理子进程
 * 采用 SIGCHLD 通信方式 父进程使用 pcntl_signal_dispatch 定时检测有无子进程的信号量
 * 如有则调用相应的注册好的方法回收子进程
 * 如没有则继续父进程的业务
 */
if (!extension_loaded('pcntl')) {
    trigger_error("need pcntl extension!", E_USER_ERROR);
}

// 子进程数量
$children_num = 4;
// 存放子进程 pid
$children_pid_set = [];
// 是否退出执行
$sign_exit = false;

// 捕获子进程的退出信号 -- SIGCHLD 进行子进程回收
pcntl_signal(SIGCHLD, function ($signo) use (&$children_pid_set) {
    // 回收子进程 防止成为僵尸进程
    $child_pid = pcntl_wait($status);

    // 删除子进程的 pid
    $key = array_search($child_pid, $children_pid_set);
    unset($children_pid_set[$key]);

    if (0 == $status) {
        echo "child: " . $child_pid . " run finished!" . PHP_EOL;
    } else {
        echo "child: " . $child_pid . " run error and exit!" . PHP_EOL;
    }
});

// 做个软退出 -- SIGINT
pcntl_signal(SIGINT, function ($signo) use (&$sign_exit) {
    // 捕获 SIGINT ctrl+c 的退出执行命令后
    // 我们能可控的做一些退出清理工作
    $sign_exit = true;
});

/**
 * 创建一定数量的子进程
 * @param  [type] $process_num  创建的数量
 * @param  [type] &$children_pid_set 全局的子进程pid
 * @return [type]                [description]
 */
function process_pool($process_num, &$children_pid_set)
{
    // 创建子进程
    for ($i = 0; $i < $process_num; $i++) {
        $pid = pcntl_fork();

        if ($pid == -1) {
            trigger_error("child process create failed!" . PHP_EOL, E_USER_ERROR);
        }

        if ($pid == 0) {
            // -- child process code --
            echo "child: " . getmypid() . " running!" . PHP_EOL;
            sleep(rand(1, 3));
            // child process exit code 可以被父进程接受到以判别子进程的退出状态
            exit(0);
            // -- child process code --
        } else {
            // father process code
            $children_pid_set[] = $pid;
            echo "father: child " . $pid . " created!" . PHP_EOL;
        }
    }
}

// 预先创建若干个子进程
process_pool($children_num, $children_pid_set);

// 父进程使用 wait 函数等待所有子进程执行完毕
while (true) {
    // echo "father process running..." . PHP_EOL;

    // 始终维持 $children_num 个子进程
    if (($need_create = $children_num - count($children_pid_set)) > 0) {
        process_pool($need_create, $children_pid_set);
    }

    // PHP 信号捕获回调需要特定的使用此函数进行分发处理
    // declare(ticks=1) 存在浪费性能的可能
    // 故在主循环体中加入信号时间分发器
    pcntl_signal_dispatch();

    // 通过捕获 SIGINT 信号来实现软退出
    if ($sign_exit) {
        break;
    }

    // 模拟父进程耗时处理其他业务
    sleep(2000);
}

if (!empty($children_pid_set)) {
    // 可能会有一些还未结束的子进程 但无需担心 父进程退出后他们会成为孤儿进程被 init 接管
    // 你也可以自行对这些子进程做处理
    echo implode(" ", $children_pid_set) . ' are still running! will be ctrled by init process' . PHP_EOL;
}

echo "father process run finished!" . PHP_EOL;

源码解读:
1、父进程注册信号量 SIGCHLD 的 handler 方法,我们应在此信号量的 handler方法中做 pcntl_wait() 用来处理回收发送此信息号的子进程。
2、父进程创建子进程,并进入非阻塞循环,使用 pcntl_signal_dispatch() 来检测是否有信号待处理(使用 declare(ticks=1) 存在一些性能浪费的可能),若有待处理的信号,则父进程调用 pcntl_signal() 注册的信号及handler,若没有,则继续执行其他业务。
3、SIGCHLD 信号的 handler 中应使用 pcntl_wait() 方法来回收子进程,防止僵尸进程。

相关推荐