某些情况下,我们除了提供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可以通过selectepoll来监听,有时候和程序的事件机制就结合在一起了。

<?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;
    }
}
使用 PHP 和 Phalcon 作 daemon 进程
标签: