之前我分析过fpm中的进程管理,是采用纯c语言实现的,其实php本身的pcntl拓展也提供了进程创建,信号管理的功能。于是我打算模仿fpm在php里实现一套进程管理工具。
TODO列表
- 使用pcntl_signal 处理,SIGCHLD 和 SIGINT
- 创建多个进程,父进程记录pids,在子进程退出的时候更新pids
- 创建多个进程,父进程记录pids,在子进程退出的时候重启子进程,让子进程的数量固定
- 通过向父进程发送 SIGQUIT 实现退出所有进程
- 通过向父进程发送 SIGUSR2 实现重启所有进程(平滑)
- 父进程退出(或异常)所有子进程也退出
- 修改进程名 改成 master 和 worker
- 封装,子进程自定义函数执行
- 封装,父进程根据自定义函数调整进程数量
php提供的基本功能
php多进程编程本质上是将 c语言中 <unistd.h>,<signal.h>,<fcntl.h> <sys/socket.h> 等unix函数(应用层和内核打交道的接口)用php拓展的形式封装了一遍,并且自己用c语言封装了一些通用的功能。可以对比一下原生的c语言函数。
使用的pcntl中的函数
- pcntl_fork
pcntl_fork ( void ) : int
核心函数。fork是创建了一个子进程,父进程和子进程 都从fork的位置开始向下继续执行,不同的是父进程执行过程中,得到的fork返回值为子进程 号,而子进程得到的是0。调用一次返回两次。对应c语言中的 pid_t fork(void);
- pcntl_waitpid
pcntl_waitpid ( int $pid , int &$status [, int $options = 0 ] ) : int
等待或返回fork的子进程状态, 用来在SIGCHLD发生时回收子进程,正常返回子进程pid。 相当于c语言中的 pid_t waitpid(pid_t pid, int *stat_loc, int options);
- pcntl_signal
pcntl_signal ( int $signo , callback $handler [, bool $restart_syscalls = true ] ) : bool
安装一个信号处理器。相当于c语言中的 sig_t signal(int sig, sig_t func);
- pcntl_exec
pcntl_exec ( string $path [, array $args [, array $envs ]] ) : void
在一个进程里启动另一个进程,启动进程的所有内容(代码和数据)被清空,替换成新的进程。bash工作的时候就是fork一个新的进程然后在新的进程里exec的,进程id和父子关系都不会变。相当于c语言中的
int execve(const char *path, char *const argv[], char *const envp[]);
- pcntl_signal_dispatch
pcntl_signal_dispatch ( void ) : bool
函数pcntl_signal_dispatch()调用每个等待信号通过pcntl_signal() 安装的处理器。非阻塞的,有就调用,没有就返回。c语言中不需要,因为php算是一个大型的C项目,里面指令的粒度不一样,只能把信号先放到一个队列里,用户主动调用pcntl_signal_dispatch时依次调用安装的信号处理器,c语言操作系统就帮忙自动调用了,直接中断当前指令就好了
- pcntl_get_last_error
pcntl_get_last_error ( void ): int
php.net 中是Retrieve the error number set by the last pcntl function which failed。相当于c语言中的 errno,没错这只是个全局变量,需要#include <errno.h>使用
- pcntl_strerror
pcntl_strerror ( int $errno ) : string
将posix错误码转换为可读的字符串。相当于c语言中的 char* strerror(int errnum);
使用的stream中的函数
- stream_socket_pair
stream_socket_pair ( int $domain , int $type , int $protocol ) : array
创建unix套接字处理php中的信号,信号函数比较难控制。相当于c语言中的 int socketpair(int domain, int type, int protocol, int socket_vector[2]);
- stream_set_blocking
stream_set_blocking ( resource $stream , int $mode ) : bool
为资源流设置阻塞或者阻塞模式,防止event loop中读取socket pair 阻塞。相当于c语言中的 int fcntl(int fildes, int cmd, …);
使用的其他php函数
- cli_set_process_title
cli_set_process_title ( string $title ) : bool
设置进程名,这样在ps aux 里看到的就可以区分开了
实现
<?php
class ErrorLevel {
const WARNNING = 'warnning';
const FATAL = 'fatal';
const NOTICE = 'notice';
const DEBUG = 'debug';
}
class Process {
const STAGE_NORMAL = 'normal';
const STAGE_QUIT = 'quit';
const STAGE_RESTARTING = 'restart';
/**
* 标识子进程是否处于退出的状态
*
* @var boolean
*/
private $inShutDown = false;
/**
* 主进程的状态
*
* @var string
*/
private $masterStage = self::STAGE_NORMAL;
/**
* 记录所有当前存在的进程,只在父进程中维护,子进程的没有意义
*
* @var array
*/
private $pids = [];
/**
* 用全局变量标识父进程还是子进程
*
* @var boolean
*/
private $isParent = true; //
/**
* 处理信号量的管道,$sockets[0] 用来写,$sockets[1] 用来读
*
* @var array
*/
private $sockets = [];
/**
* 错误日志写入资源句柄
*
* @var resource
*/
private $logFd;
/**
* 开启的进程数量
*
* @var int
*/
private $forkCount;
/**
* event loop 每次检查信号的间隔,间隔越小,越能及时处理进程的死亡和拉起
*
* @var int
*/
private $eventLoopInterval = 1;
/**
* 主进程的pid
*
* @var integer
*/
private $masterPid = 0;
public function __construct($forkCount, $logFd = STDOUT) {
$this->forkCount = $forkCount;
$this->logFd = $logFd;
}
/**
* 运行程序入口
*
* @return void
*/
public function run()
{
$this->masterPid = posix_getpid();
// 创建socket(相当于匿名管道)
$this->sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
// 注册信号处理函数
pcntl_signal(SIGCHLD, array($this, "sigAction"));
pcntl_signal(SIGQUIT, array($this, "sigAction"));
pcntl_signal(SIGUSR2, array($this, "sigAction"));
// 初始化子进程
$ret = $this->makeChildren($this->forkCount);
if($ret == 2) { // fork失败,程序退出,TODO 这里没有处理子进程变成孤儿进程的情况
$this->log("fork failed " . pcntl_strerror(pcntl_get_last_error()), "fatal");
exit;
}
// 父进程
if($this->isParent) {
// 设置具柄为非阻塞的
stream_set_blocking( $this->sockets[1], False );
cli_set_process_title("php master");
// 相当于 fpm_event_loop
while (true) {
pcntl_signal_dispatch();
$this->checkSignal(); // 检查是否有信号发生
if(!$this->isParent) { // 可能在checkSiganl里fork了子进程,这里要退出
break;
}
if(count($this->pids) == 0) { // 父进程回收完所有子进程退出
if($this->masterStage == self::STAGE_QUIT) {
$this->log("master quiting");
break;
} else if($this->masterStage == self::STAGE_RESTARTING) {
// // 父进程回收完所有子进程重启
global $argv;
$this->log("master restarting");
pcntl_exec(exec("which php"), $argv);
}
}
$this->log("stage " . $this->masterStage . " child count " . count($this->pids). " fall sleep");
sleep($this->eventLoopInterval);
}
if($this->isParent) { // 关闭打开的管道
fclose($this->sockets[0]);
fclose($this->sockets[1]);
}
}
// 子进程
if(!$this->isParent) {
pcntl_signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD,尽管子进程应该没有child才合理
pcntl_signal(SIGQUIT, [$this, "sigActionChild"]);
cli_set_process_title("php worker");
fclose($this->sockets[0]); // 子进程用不上
fclose($this->sockets[1]);
// sleep(mt_rand(1, 5); 验证拉起子进程
// exit;
while(true) { // 验证重启和退出信号的代码
sleep(mt_rand(1, 5));
$this->log("I'm sleeping");
$this->heartbeat();
}
exit; // 要及时退出
}
}
/**
* 子进程中当一个单位的任务完成后调用,防止在一个任务执行中被中断
*
* @param \Callable $clearUpFunc
* @return void
*/
public function heartbeat($clearUpFunc = null){
// 检查是否被父进程告知要死去
pcntl_signal_dispatch();
if($this->inShutDown) {
$this->log("child quit for master notify");
} else if(!file_exists("/proc/{$this->masterPid}")) {
// 检查父进程是否已经死去
$this->log("child quit for master die");
$this->inShutDown = true;
}
if($this->inShutDown) {
if(is_callable($clearUpFunc)) {
call_user_func($clearUpFunc);
}
exit;
}
}
/**
* 真正的,信号处理函数 对应fpm中的 fpm_got_signal,从管道中读取信号
*
* @return void
*/
private function checkSignal()
{
do {
$c = fread($this->sockets[1], 1);
if(empty($c)) {
break;
}
switch ($c) {
case 'C':
// 表示有子进程挂掉了,pcntl_waitpid循环检查(因为SIGCHLD多个信号同时到来,没有及时处理掉,只会触发一次,为了防止僵尸进程的出现要循环检查,WNOHANG是不阻塞)
while(($pid = pcntl_waitpid(-1,$status,WNOHANG)) > 0) {
unset($this->pids[$pid]); // 从进程列表中移除
$this->log("child reaped ". $pid);
if($this->masterStage != self::STAGE_NORMAL) {
continue;
}
$ret = $this->makeChildren(1); // 拉起一个新的进程
if($ret == 2) {
$this->log("fork failed " . pcntl_strerror(pcntl_get_last_error()), 'fatal');
exit;
}
if(!$this->isParent) { // 子进程不处理信号,只有父进程处理
return;
}
}
break;
case "Q": // 退出
if($this->masterStage != self::STAGE_NORMAL) {
break;
}
$this->masterStage = self::STAGE_QUIT;
$this->killAll(SIGQUIT);
break;
case '2': // 重启
if($this->masterStage != self::STAGE_NORMAL) {
break;
}
$this->masterStage = self::STAGE_RESTARTING;
$this->killAll(SIGQUIT);
break;
}
} while(1);
}
/**
* 信号处理函数,只负责从管道写入信号
*
* @param int $signo
* @return void
*/
private function sigAction($signo) {
switch ($signo) {
case SIGCHLD:
fwrite($this->sockets[0], "C");
break;
case SIGQUIT:
fwrite($this->sockets[0], "Q");
break;
case SIGUSR2:
fwrite($this->sockets[0], "2");
break;
}
}
/**
* 子进程的信号函数
*
* @param int $signo
* @return void
*/
private function sigActionChild($signo) {
switch ($signo) {
case SIGQUIT:
// 标记退出状态
$this->inShutDown = true;
break;
}
}
/**
*
*
* @param int $n fork的进程数量
* @return void
*/
private function makeChildren($n)
{
for($i = 0; $i < $n; $i++) {
$pid = pcntl_fork();
switch ($pid) {
case 0:
$this->isParent = false;
return 0;
case -1:
return 2;
default:
$this->pids[$pid] = 1; // 记录当前存活的进程
break;
}
}
return 1;
}
/**
* 给所有子进程发送信号,重启或退出时用
*
* @param [type] $signo
* @return void
*/
private function killAll($signo){
$aliveCount = 0;
foreach($this->pids as $pid => $value) {
$ret = posix_kill($pid, $signo);
if(!$ret) {
$aliveCount++;
}
}
if($aliveCount > 0) {
$this->log("kill all alive count is : " . $aliveCount, ErrorLevel::WARNNING);
}
}
/**
* 写入日志
*
* @param string $str
* @param string $level
* @return void
*/
private function log($str, $level = ErrorLevel::DEBUG){
$str = sprintf("pid %s time %s level %s: %s" . PHP_EOL, posix_getpid(), microtime(true), $level, $str);
fwrite ($this->logFd , $str);
}
}
使用方式
$p = new Process(10);
$p->run();
验证拉起子进程
要打开141,142行的注释
> php Process.php
尽管时刻都有进程死去,但总的进程数量都是10
验证重启或退出
退出和重启的逻辑在114 和 124行之间,在收到信号时向所有子进程发送SIGQUIT,然后主进程感觉 $masterStage 变量决定重启还是退出。注意子进程如果是常驻进程一定要定期调用hearbeat方法,处理父进程发送的信号和检查父进程的存活。
kill -s SIGQUIT ${masterPid} #退出进程
kill -s SIGUSR2 ${masterPid} #重启所有进程
参考
[PHP] cli环境下php设置进程名字
PHP多进程与信号 pcntl,Signal
unix 环境高级编程 第8章