PHP多进程小实践

上周四的时候,发现线上小米推送又积压了一百多万条推送,我忍它很久了,决定动手干掉这个问题。中间遇到了两个坑,这里简单记录一下。

之前的设计是这样的,因为线上服务端向小米服务端请求推送走的是网络,偶发性出现推送延迟会使很多请求不能及时释放,造成机器负载增高。为了解决这个问题,我变同步推为异步推,所有的推送请求都塞到一个Redis的队列里,另一端有一个常驻进程从队列里取推送任务,然后像小米发推送请求,这算是推送模块的第二版。后来随着用户量变大,和业务逻辑变复杂,推送请求也成倍增加,单进程也有些吃不消,就暴力的把单进程改成多进程,10进程从队列里取推送任务,一度效果很好,这应该是推送模块第三版。

可是后面偶然会听到用户抱怨收不到推送,一查发现是推送任务积压,每次都手动清空队列,然后重启推动脚本。我记得今年有一次闰秒,那天Redis的队列快把内存吃光了。这种问题一次两次还好,次数多了每次手动处理难免蛋疼,而且和第三方的对接很多问题也无法追查。随着时间的考验推送模块的问题逐渐暴露出来:

  • 没有良好的监控体系,每次都是通过自己试用或用户吐槽发现问题,发现问题不够及时
  • 发现问题之后,每次都手动重启,不够自动化,效率偏低
  • 最蛋疼的是多进程重启,每次要挨个kill子进程,kill的手都麻了

所以推送模块的第四版就应运而生。

1. 模块通过信号优雅关闭
这个场景比较常见,当用守护进程写服务的时候,如果服务涉及到比较关键的数据操作,如果需要重启粗暴的kill -9 很容易造成数据不一致的情况;另一方面如果服务以多进程形式存在,只kill父进程,子进程会自动挂到init 0号进程下面,无法做到一条命令kill掉全部进程,所以就需要信号的引入,优雅的处理这种场景的问题。
信号是进程间异步通信的一种机制,通知进程发生了某种事件。
Liunx信号
Liunx信号
信号本质是一种软件中断。一个进程一旦接收到信号就会打断原来的程序执行流程来处理信号,下面列出一些常用信号:

信号 描述
SIGINT 终止进程 中断进程 (control+c)
SIGQUIT 退出进程
SIGTERM 终止进程 软件终止信号 (默认信号 kill pid)
SIGKILL 终止进程 杀死进程 (kill -9)
SIGALRM 闹钟信号
SIGCHLD 子进程被杀

特别强调一下,进程结束信号 SIGTERM 和 SIGKILL 的区别:
SIGTERM比较友好,进程能捕捉这个信号,根据您的需要来关闭程序。在关闭程序之前,您可以结束打开的记录文件和完成正在做的任务。在某些情况下,假如进程正在进行作业而且不能中断,那么进程可以忽略这个SIGTERM信号。
对于SIGKILL信号,进程是不能忽略的。这是一个 “我不管您在做什么,立刻停止”的信号。假如您发送SIGKILL信号给进程,Linux就将进程停止在那里。另一个不能忽略的信号是SIGSTOP。
所以我这里采用SIGTERM信号优雅关闭推送模块。

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
# 配合pcntl_signal使用,简单的说,是为了让系统产生时间云,让信号捕捉函数能够捕捉到信号量
declare(ticks = 1);

# 定义信号处理器
function signalHandler($signal) {
global $childPidArray;
global $stopFlag;

switch ($signal) {
case SIGTERM:
$stopFlag = true;
# 遍历子进程列表,关闭子进程
foreach ($childPidArray as $pid) {
system('kill -9 '.$pid);
unset($childPidArray[$pid]);
}
die(0);
break;
default:
break;
}
}

# 注册信号
pcntl_signal(SIGTERM, 'signalHandler');

天才的人生果然不会那么顺利。。。测试的时候父进程死活收不到任何信号,说好的信号处理机制呢。我甚至去掉了信号注册,直接kill PID 发现还是不行,我一度怀疑人生。。。
尝试各种关键词搜索“进程 信号处理”、“进程 无法捕获信号”、“子进程随父进程结束”、“父进程杀死子进程”。。。直到搜索到“多进程 信号”这个组合的时候终于发现了一些眉目,原来我父进用pcntl_wait()阻塞的去等待子进程的退出,在这种情况下父进程收不到任何信号!真是有点儿醉。。。接着我在php.net上翻到了解决方法

一看原来是自己姿势不对,就加一个参数嘛,各种开心马上拿来试,可是左试试右试试还是不合符预期,就去查查这第三个参数是什么名堂,结果一查更囧了。。。

接着我去查如何解决或绕过这个BUG,发现吐槽这个问题的还不少,最后看到一篇写workerman哪个大神写的PHP进程进程控制的文章,决定弃用pcntl_wait(),然后用SIGCHLD去捕获子进程退出的事件,然后进行进程管理。搞定!

2. 推送超时自杀
函数超时处理我认为是信号另外一个特别合适的使用场景,有一个很独特的信号函数:pcntl_alarm(),他可以指定一定秒数向自己发一个SIGALRM信号,进程的执行就可以被中断。通常都用这个机制,来处理可能某些可能执行超时的函数。

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
# 定义信号处理器
function signalHandler($signal) {
global $childPidArray;

switch ($signal) {
case SIGTERM:
# do something
break;
case SIGALRM:
# 这里很简单只抛一个异常
throw new Exception;
break;
default:
break;
}
}

# 注册一个新的SIGALRM的信号处理
pcntl_signal(SIGALRM, 'signalHandler');

try {
# 设置5秒钟之后发一个SIGALRM信号
pcntl_alarm(5);
# do something may be time out
pcntl_alarm(0);
} catch (Exception $e) {
# 捕获超时异常,这里也可以做其他操作
die();
}

3. 子进程MAX REQUEST机制 && 自杀重启
小米推送偶尔会遇到不知原因的阻塞,为了能解决这个问题,仿照Web服务器的MAX REQUEST机制,也给每个推送的子进程增加一个类似的机制,Web服务器是为了解决潜在的内存溢出问题。这个超级简单,就是每个子进程启动就新建一个计数器,计数到某个特定的值就die()掉。
但是,子进程一个一个的死掉,父进程不能坐视不管,他要起到监控子进程数量复活子进程(其实复活的并不是同一个子进程了,因为PID变了2333)的责任。父进程维护一个全局变量$childPidArray,每当子进程自杀时,把子进程的PID从这个列表剔除。父进程轮询这个列表的长度,当小于预设子进程数量的时候,就pcntl_fork()一个新的子进程来干活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (true) {
if (count($childPidArray) >= PROCESS_COUNT || $stopFlag) {
sleep(1);
continue;
}

$pid = pcntl_fork();
if ($pid == -1) {
// do nothing
} else {
if ($pid == 0) { # 子进程去干活
handlePush();
} else { # 父进程把子进程的PID记录下来
$childPidArray[$pid] = $pid;
}
}
}

有两个业务都需要用到这个脚本,先部署了一个一切顺利,到另外一个机器上跑的时候,每隔三五分钟就又开始莫名其妙阻塞。很奇怪,我把程序拿到前台来跑,发现原来每隔一段时间就会报一批PHP Notice出来,然后这些子进程就都罢工了,报错贴出来:

查了很多,有一个github的issue专门吐槽这个问题,最后确认原因是,在父进程中新建的Redis连接会随子进程的结束而释放,再在新的子进程中使用就会报这个错。最后解决办法是:每个子进程新建自己的连接进行Redis访问。
查到原因之后,我就好奇另一台机器会什么不会遇到这个问题,我仔细对比了一下,另一台机器的Redis是sock连接,也许不会轻易被释放掉吧2333,总之都搞定了!