最近在研究 workerman 的运行原理。其中运用到了 php 的多进程处理,本文将我的学习程记录下来。首先 php 要安装了 pcntl 和 posix 扩展。一般默认都安装了的,可以使用命令 php -m 确定是否已开启。

什么是多进程?

齐天大圣孙悟空就有这样的本领。抓一把猴毛,变化出多个分身,有了这么多分身就可以制定多种作战方式,这样打妖怪的效率将大大提升。主进程相当于孙悟空本身,子进程就相当于这些猴儿子。有了多个进程也能更好的利用多核 CPU。

php 生孩子的特殊技巧

php 生孩子的特殊技巧使用到了 pcntl_fork() 函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//创建子进程(生孩子)
$son = pcntl_fork();

if ($son === -1) {
    //生孩子失败
    throw new Exception("fork fail");
} else if ($son === 0) {
    //生孩子成功,这个 else if 代码块中都是儿子的领地,让他存活 10 秒,每秒都说句话
    $liveTime = 10;
    for ($j = 0; $j < $liveTime; $j++) {
        sleep(1);
        echo "son: " . $j . PHP_EOL;
    }
    exit(0);
}

//主体生存 20 秒,也每秒都说句话
$liveTime = 20;
for($i = 0; $i < $liveTime; $i++) {
    sleep(1);
    echo  "father: " . $i . PHP_EOL;
}
exit(0);

在命令行执行 php 文件名.php 会发现爸爸和儿子是在同时打怪兽。得到的结果如下:

1
2
3
4
5
6
7
8
9
father: 0
son: 0
father: 1
son: 1
father: 2
son: 2
father: 3
son: 3
....

僵尸进程

当子进程比父进程先退出,而父进程没对其做任何处理的时候,子进程将会变成僵尸进程。但是这里的子进程死了话还留着一个空壳在,直到父进程回收它。僵尸进程虽然不占什么内存,但是很碍眼。

上面的代码运行十秒后,子进程退出,主进程还在运行。这样就能出现僵尸进程的情况(子进程比父进程先退出,并且父进程没有对它做处理)。top 命令 zombie 参数前面的数字就是僵尸进程数量。

一般来说,在父进程结束之前回收挂掉的子进程就可以了。在 pcntl 扩展里面有一个 pcntl_wait() 函数,它会将父进程挂起,直到有一个子进程退出为止。如果有一个子进程变成了僵尸的话,它会立即回收。把上面的代码改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//生三个孩子
for($i = 0; $i < 3; $i++) {
    //创建子进程(生孩子)
    $son = pcntl_fork();
    if ($son === -1) {
        //生孩子失败
        throw new Exception("fork fail");
    } else if ($son === 0) {
        //生孩子成功,这个 else if 代码块中都是儿子的领地,让他存活 10 秒,每秒都说句话
        $liveTime = 10;
        //获得这个孩子的进程 id
        $son_pid = getmypid();
        for ($j = 0; $j < $liveTime; $j++) {
            sleep(1);
            echo "{$son_pid} son: {$j}" . PHP_EOL;
        }
        exit(0);
    }
}

while(1) {
    sleep(1);
    //挂起主进程,主进程用来控制子进程
    $status = 0;
    $pid = pcntl_wait($status, WUNTRACED);
    if ($pid > 0) {
        //如果有子进程退出,可以做一些事情。比如记录日志,重新生成一个子进程
    }
}

这样就不会出现僵尸进程了,当然我们也可以做到发现有子进程退出,就重新 fork 一个补充上去。

当然还存在一种情况就是父进程比子进程先退出,然而子进程依旧还在正常运行。但是这个时候,子进程会被交给 1 号进程,1 号进程成为了这些子进程的继父。1 号进程会很好地处理这些进程的资源,当它们结束时 1 号进程会自动回收资源。所以,另一种处理僵尸进程的临时办法是关闭它们的父进程。

信号

信号在系统中是一个非常重要的东西。信号就是信号灯,点亮一个信号灯,程序就会做出反应。这个你一定用过,比如说在终端下运行某个程序,等了半天也没什么反应,可能你会按 Ctrl+C 来关闭这个程序。实际上,这里就是通过键盘向程序发送了一个中断的信号:SIGINT。有时候进程失去响应了还会执行 kill [PID] 命令,未加任何其他参数的话,程序会接收到一个 SIGTERM 信号。程序收到上面两个信号的时候,默认都会结束执行,那么是否有可能改变这种默认行为呢?必须能啊!

pcntl_signal() 函数是用来注册信号处理方法的。下面这段程序将给 SIGINT 重新定义行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 定义一个处理器,接收到 SIGINT 信号后只输出一行信息
function signalHandler($signal) {
    if ($signal == SIGINT) {
        echo 'signal received' . PHP_EOL;
    }
}
// 信号注册:当接收到 SIGINT 信号时,调用 signalHandler()函数
pcntl_signal(SIGINT, 'signalHandler');
while (true) {
    sleep(1);
    // do something
    pcntl_signal_dispatch(); // 接收到信号时,调用注册的 signalHandler()
}

执行一下,随时按下 Ctrl+C 看看会发生什么事。

说明一下:pcntl_signal() 函数仅仅是注册信号和它的处理方法,真正接收到信号并调用其处理方法的是 pcntl_signal_dispatch() 函数。

系统中的 kill 命令其实就是向进程发送信号,在 PHP 中也可以调用 posix_kill() 函数来达到相同的效果。有了它就可以在父进程中控制其他子进程的运行了。比如在父进程结束之前关闭所有子进程,那么 fork 的时候在父进程记录所有子进程的 PID,父进程结束之前依次给子进程发送结束信号即可。workerman 就是通过信号来控制程序的 stop reload 的。

重定向标准输出

之前我们进程的输出都是显示在终端里,一个项目正式运行的时候都是要离开终端,让程序在后台以 守护进程运行。然后程序的输出内容定向到一个文件中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
//自定义标准输出和标准错误保存文件
$stdoutFile = "/tmp/phpout.log";
//全局的标准输出和标准错误
global $STDOUT, $STDERR;
$handle = fopen($stdoutFile,"a");
if($handle) {
    unset($handle);
    //重新设置标准输出和标准错误的保存位置
    @fclose(STDOUT);
    @fclose(STDERR);
    $STDOUT = fopen($stdoutFile,"a");
    $STDERR = fopen($stdoutFile,"a");
} else {
    throw new Exception('can not open stdoutFile ' . $stdoutFile);
}

守护进程

程序要脱离终端运行,很简单。先 fork 一个子进程,把原来的主进程结束掉,然后把子进程设置为新的会话,这样就脱离了终端。为了保险起见,我们再 fork 一次,结束掉刚刚设置的新会话 leader。这样就没有可能再回到终端了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
//让 fork 出的子进程拥有最大权限
umask(0);

$pid = pcntl_fork();
if(-1 === $pid) {
    throw new Exception('fork fail');
} elseif ($pid > 0) {
    exit(0);
}
if(-1 === posix_setsid()) {
    throw new Exception("setsid fail");
}
// fork again avoid SVR4 system regain the control of terminal
$pid = pcntl_fork();
if(-1 === $pid) {
    throw new Exception("fork fail");
} elseif (0 !== $pid) {
    exit(0);
}

END

看完上面说了这么多,应该可以模仿 workerman 写一个简单的多进程控制了。希望对你有所帮助!