某些情况下,我们除了提供web界面给用户,还需要运行一些后台任务。这些任务可能是由用户触发的(比如用户提交了一个请求,而这种请求很特殊,例如从github克隆一个项目并执行构建,至少需要几分钟才能执行完成,这种情况不适合阻塞的方式让浏览器等待结果返回);也可能是一些常规性的系统任务(比如将日志进行归档,转移到统一的地方进行备份)。前者一般是引入消息队列,用户的请求只是增加了一条待构建的消息到消息队列,然后有一个专门的订阅者读取消息,调度分发执行这个任务。后者最简单的方式便是crontab,但缺点是每个机器需要单独进行设置,不易维护;当然也可以通过一个统一的调度器,分发任务到多个任务节点的方式来执行。
这里只说第一种情况,业务背景是许可范围内按照用户的配置生成移动端APP。对于Android,项目构建可以使用Ant或者Gradle,这样可以通过命令行调用,在程序中就可以fork一个进程,设置相应的参数来执行了。
对于不同的Android APP,package name需要是不同的;除此之外,对于用户的一些配置,也会体现到最终待构建的项目文件中。因此,只能生成一套APP的模板代码,按照用户的输入,修改java代码和资源文件,最后再执行构建产生apk。这个构建过程可能需要几分钟。
这里我们采用的方案是有一个单独的daemon进程,读取消息队列,得到前端写入的待构建的消息,fork进程执行Ant,完成构建后(成功或失败)将状态写入数据库,前端采用定期查询的方式以便获知任务是否结束。这样daemon进程的逻辑相对简单,构建的过程都是发生在其他进程空间的,不会对daemon进程产生影响。
代码采用了类似 Phalcon 示例 中Multiple
的形式。
启动程序
命令行的入口文件,参考 @guweigang 的falcon,通过一些参数指定运行哪个模块,哪个action,以及传递给action的参数。这里通过getopt
解析命令行参数,-d
决定了是否变成daemon进程。
<?php
/**
* Usage:
* php console.php -d -m module-name -t task-name -a action-name -p parameters
*
* If you has multiple parameters to pass to an action, use it like this:
* -p first -p second
*
*/
error_reporting(E_ALL);
ini_set("memory_limit", "4G");
$HELP_TEXT = <<<EOT
appcreator console
usage: php console.php [OPTION]... [PARAMS]...
-h, --help show help message
-d, --daemon running as daemon
-m, --module specify module
-t, --task specify task
-a, --action specify action
-p, --param parameter passed to action
:)
EOT;
try {
$opts = "dm:t:a:p:h";
$longopts = array(
"module:",
"task:",
"action:",
"param:",
"help",
"daemon",
);
$options = getopt($opts, $longopts);
if (isset($options['h']) || isset($options['help'])) {
print($HELP_TEXT);
exit(0);
}
if (isset($options['d']) || isset($options['daemon'])) {
$pid = pcntl_fork();
if ($pid == -1) {
error_log('fork process failed');
exit(1);
} elseif ($pid) {
// if in parent process, exit
exit(0);
}
fclose(STDIN);
fclose(STDOUT);
fclose(STDERR);
// TODO
$STDIN = fopen('/dev/null', 'r');
$STDOUT = fopen('/dev/null', 'w');
$STDERR = fopen('/dev/null', 'w');
posix_setsid();
}
$args = array();
// 处理传递给action的参数
foreach(array('p', 'param') as $p) {
if (isset($options[$p])) {
if (is_array($options[$p])) {
$args = array_merge($options[$p]);
} else {
$args[] = $options[$p];
}
}
}
if (!isset($options['a']) && !isset($options['action'])) {
error_log('Please specify the action you want to run with -a or --action');
exit(1);
}
/*
* set cli parameters
*/
$args['module'] = (isset($options['m']) || isset($options['module'])) ? (
isset($options['m']) ? $options['m'] : $options['module']) : 'appcreator';
$args['task'] = (isset($options['t']) || isset($options['task'])) ? (
isset($options['t']) ? $options['t'] : $options['task']) : 'package';
$args['action'] = isset($options['a']) ? $options['a'] : $options['action'];
/*
* 这里实例化\Phalcon\CLI\Console并加载服务和模块
*/
$application = new \Phalcon\CLI\Console();
/* load services and modules */
...
/* Finally handle args */
$application->handle($args);
} catch (Exception $e) {
error_log('[AppCreator]' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
echo $e->getMessage();
echo "\n\nBacktrace:" . $e->getTraceAsString()."\n";
}
循环读取消息,执行任务
这里是真正的daemon进程执行的代码。目前的逻辑比较简单,就是定期获取前端写入的待构建的消息,创建进程执行一次构建任务。
<?php
public function daemonAction()
{
// change to working dir
chdir(WORKING_DIR);
// install signal handlers
$this->_setupSignals();
$nextWakeUp = 0;
// 每隔时间检查一次当前需不需要生成APP
while(1) {
$this->roused = FALSE;
if ($nextWakeUp <= time()) {
$nextWakeUp = time() + self::SLEEP_SECONDS;
}
sleep($nextWakeUp - time());
pcntl_signal_dispatch();
if ($this->roused) {
continue;
}
$limit = self::MAX_PROCESS - count(self::$childs);
// exceeds process limit
if ($limit <= 0) {
continue;
}
// find todo tasks
$tasks = ...;
foreach ($tasks as $task) {
$pid = $this->_runTask($task);
}
}
}
通过子进程执行任务
_runTask()
其实就是调用一个新的PHP进程,执行特定的action。我们在这个action再去fork进程执行Ant
或者Gradle
。
<?php
private function _runTask($task)
{
$pid = pcntl_fork();
if ($pid == -1) {
return FALSE;
} else if ($pid) {
return $pid;
} else {
pcntl_exec($_SERVER['_'], $this->_getTaskCommand($task));
// fork succeed but exec failed,
// need to be _exit(). actually exit() will cause problem
exit(1);
}
}
signal处理
这里daemon进程处理的signal比较简单,主要就是需要退出和需要wait子进程结束的信息,以便OS清理其留在进程表中的信息。但是pcntl_signal_dispatch()
有个问题,有可能两个接近同时退出的子进程,会导致可能当时只发了一个SIGCHLD的信号,另一个信号下一次dispatch才会发出,所以只能通过循环来wait。
signal handler需要注意一些问题,这篇文章有很详尽的介绍,非常值得一读。也可以看看Nginx中是怎么做的。signal是古老UNIX的产物,signal handler是异步触发的,这导致如果signal handler不是可重入的话,很可能会出现问题,虽然一般情况可能不会发生。更现代的处理方式是signalfd(类似的东西还有eventfd,timerfd),这个fd可以通过select
,epoll
来监听,有时候和程序的事件机制就结合在一起了。
<?php
private function _setupSignals()
{
pcntl_signal(SIGTERM, array($this, "signalHandler"));
pcntl_signal(SIGINT, array($this, "signalHandler"));
pcntl_signal(SIGCHLD, array($this, "signalHandler"));
}
protected function signalHandler($signo)
{
$this->roused = TRUE;
switch($signo) {
case SIGTERM:
case SIGINT:
exit(1);
case SIGCHLD:
while(1) {
$pid = pcntl_wait($status, WNOHANG);
if ($pid == 0) {
break;
}
if ($pid > 0) {
// logs
} else {
break;
}
if (!pcntl_wifexited($status)) {
// error happened
} else if (($code = pcntl_wexitstatus($status)) != 0) {
// error happened
} else {
// normal exit
}
}
break;
default:
break;
}
}