Repository: kcloze/multiprocess Branch: master Commit: 3a882e6a8583 Files: 19 Total size: 49.5 KB Directory structure: gitextract_s9djh6fr/ ├── .gitignore ├── .php_cs ├── README.en.md ├── README.md ├── composer.json ├── config.php ├── multiprocess ├── multiprocess.php ├── src/ │ ├── Config.php │ ├── Console.php │ ├── Logs.php │ ├── Process.php │ ├── Utils.php │ └── XRedis.php ├── systemd/ │ └── multiprocess.service └── test/ ├── cli/ │ ├── test.php │ ├── test2.php │ └── test3.py └── testConfig.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ vendor/* .DS_Store* log/* composer.lock src/master.pid ================================================ FILE: .php_cs ================================================ This source file is subject to the MIT license that is bundled with this source code in the file LICENSE. EOF; return PhpCsFixer\Config::create() ->setRiskyAllowed(true) ->setRules([ '@Symfony' => true, '@Symfony:risky' => true, 'array_syntax' => ['syntax' => 'short'], 'combine_consecutive_unsets' => true, // one should use PHPUnit methods to set up expected exception instead of annotations 'general_phpdoc_annotation_remove' => ['expectedException', 'expectedExceptionMessage', 'expectedExceptionMessageRegExp'], 'header_comment' => ['header' => $header], 'heredoc_to_nowdoc' => true, 'no_extra_consecutive_blank_lines' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block'], 'no_unreachable_default_argument_value' => true, 'no_useless_else' => true, 'no_useless_return' => true, 'ordered_class_elements' => true, 'ordered_imports' => true, 'php_unit_strict' => true, 'phpdoc_add_missing_param_annotation' => true, 'phpdoc_order' => true, 'psr4' => true, 'strict_comparison' => false, 'strict_param' => true, 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], 'concat_space' => ['spacing' => 'one'], 'no_empty_statement' => true, 'simplified_null_return' => true, 'no_extra_consecutive_blank_lines' => true, 'pre_increment' => false ]) ->setFinder( PhpCsFixer\Finder::create() ->exclude('vendor') ->in(__DIR__) ) ->setUsingCache(false) ; ================================================ FILE: README.en.md ================================================ # multiprocess [[中文文档]](README.md) * Based on swoole script management, for multi-process and daemon management * Easy to make the common PHP script change daemon and multi-process execution * The number of processes can be configured and multiple commands can be executed at once * Automatic restart when the child process exits in an abnormal way * When the main process exits in an abnormal way, the sub-process exits (smoothing out) after the work is done. * Not limited programming language, PHP/Python/Java/Golang/C# and other scripts can be managed ## Scenario * PHP requires running one or more cli script consumption queues (resident) * The implementation of the script automatically pulls up after the exit, preventing the consumption queue from working, affecting the business * In fact, the supervisor can easily do something, this is just another implementation of PHP, no need to change the technology stack ## Flow ![流程图](flow.png) ## Installation * git clone https://github.com/kcloze/multiprocess.git * composer install * modify config.php based on your business configuration ## Configure the instance * Execute multiple commands at once ``` 'logPath' => __DIR__ . '/log', 'exec' => [ [ 'name' => 'kcloze-test-1', 'bin' => '/usr/bin/php', 'binArgs' => [__DIR__ . '/test/test.php', 'oop', '123'], 'workNum' => 3, ], [ 'name' => 'kcloze-test-2', 'bin' => '/usr/bin/php', 'binArgs' => [__DIR__ . '/test/test2.php', 'oop', '456'], 'workNum' => 5, ], [ 'name' => 'kcloze-test-3', 'bin' => '/usr/bin/python', 'binArgs' => [__DIR__ . '/test/test3.py', 'oop', '369'], 'workNum' => 2, ], ], ``` ## Running ### 1.start * chmod -R u+r log/ * php multiprocess.php start >> log/worker.log 2>&1 ### 2.stop * php multiprocess.php stop ### 3.exit * php multiprocess.php exit ### 4.restart * php multiprocess.php restart >> log/worker.log 2>&1 ### 5.monitor * ps -ef|grep 'multi-process' ### Command ``` NAME php multiprocess - manage multiprocess SYNOPSIS php multiprocess -s command [options] -c config file path Manage multiprocess daemons. WORKFLOWS help [command] Show this help, or workflow help for command. -s restart Stop, then start multiprocess master and workers. -s start Start multiprocess master and workers. -s start -c ./config Start multiprocess with specail config file. -s stop Wait all running workers smooth exit, please check multiprocess status for a while. -s exit Kill all running workers and master PIDs. ``` ## Monitor ![monitor img](monitor.png) ## Change log #### 2017-11-30 * Refactor mercilessly v2 version * Add exit param,waiting for the child processes run over ## Thanks * [swoole](http://www.swoole.com/) ## Contact QQ group:141059677 ================================================ FILE: README.md ================================================ # multiprocess * [[readme in english]](README.en.md) * 基于swoole的脚本管理,用于多进程和守护进程管理; * 可轻松让普通脚本变守护进程和多进程执行; * 进程个数可配置,可以根据配置一次性执行多条命令; * 子进程异常退出时,主进程收到信号,自动拉起重新执行; * 支持子进程平滑退出,防止重启服务对业务造成影响; * 不限定编程语言,PHP/Python/Java/Golang/C#等脚本都可以管理 ## 1. 场景 * PHP/python/js等脚本需要跑一个或多个脚本消费队列/计算等任务 * 实现脚本退出后自动拉起,防止消费队列不工作,影响业务 * 其实supervisor可以轻松做个事情,这个只是PHP的另一种实现,不需要换技术栈 ## 2. 流程图 ![流程图](flow.png) ## 3. 安装 * git clone https://github.com/kcloze/multiprocess.git * composer install * 根据自己业务配置,修改config.php ## 4. 配置实例 * 一次性执行多个命令 ``` 'logPath' => __DIR__ . '/log', 'exec' => [ [ 'name' => 'kcloze-test-1', 'bin' => '/usr/bin/php', 'binArgs' => [__DIR__ . '/test/test.php', 'oop', '123'], 'workNum' => 3, ], [ 'name' => 'kcloze-test-2', 'bin' => '/usr/bin/php', 'binArgs' => [__DIR__ . '/test/test2.php', 'oop', '456'], 'workNum' => 5, ], [ 'name' => 'kcloze-test-3', 'bin' => '/usr/bin/python', 'binArgs' => [__DIR__ . '/test/test3.py', 'oop', '369'], 'workNum' => 2, ], ], ``` ## 5. 运行 ### 5.1 启动 * chmod -R u+r log/ * php multiprocess.php start >> log/system.log 2>&1 ### 5.2 平滑停止服务,根据子进程执行时间等待所有服务停止 * php multiprocess.php stop ### 5.3 强制停止服务[慎用] * php multiprocess.php exit ### 5.4 强制重启 * php multiprocess.php restart >> log/system.log 2>&1 ### 5.5 监控 * ps -ef|grep 'multi-process' ### 5.6 启动参数说明 ``` NAME php multiprocess - manage multiprocess SYNOPSIS php multiprocess -s command [options] -c config file path Manage multiprocess daemons. WORKFLOWS help [command] Show this help, or workflow help for command. -s restart Stop, then start multiprocess master and workers. -s start Start multiprocess master and workers. -s start -c ./config Start multiprocess with specail config file. -s stop Wait all running workers smooth exit, please check multiprocess status for a while. -s exit Kill all running workers and master PIDs. ``` ## 6. 服务管理 ### 启动和关闭服务,有两种方式: #### 6.1 php脚本(主进程挂了之后,需要手动启动) ``` ./multiprocess.php start|stop|exit|restart ``` #### 6.2 使用systemd管理(故障重启、开机自启动) [更多systemd介绍](https://www.swoole.com/wiki/page/699.html) ``` 1. 根据自己项目路径,修改 systemd/multiprocess.service 2. sudo cp -f systemd/multiprocess.service /etc/systemd/system/ 3. sudo systemctl --system daemon-reload 4. 服务管理 #启动服务 sudo systemctl start multiprocess.service #reload服务 sudo systemctl reload multiprocess.service #关闭服务 sudo systemctl stop multiprocess.service ``` ## 7. 系统状态 ![监控图](monitor.png) ## 8. change log #### 2017-11-30 * 彻底重构v2版本 * 增加exit启动参数,默认stop等待子进程平滑退出 ## 9. 感谢 * [swoole](http://www.swoole.com/) ## 10. 联系 qq群:141059677 ================================================ FILE: composer.json ================================================ { "name": "kcloze/multiprocess", "description": "基于swoole的cli多进程管理", "keywords": [ "swoole", "多进程", "multi-process" ], "homepage": "https://github.com/kcloze/multiprocess", "license": "MIT", "require": { "php": ">=7.0", "ext-swoole": ">=1.8.9" }, "authors": [ { "name": "kcloze", "email": "pei.greet@qq.com" } ], "autoload": { "psr-4": { "Kcloze\\MultiProcess\\": "src" } }, "bin": [ "multiProcess" ] } ================================================ FILE: config.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ return $config = [ //log目录 'logPath' => __DIR__ . '/log', 'logSaveFileApp' => 'application.log', //默认log存储名字 'logSaveFileWorker'=> 'workers.log', // 进程启动相关log存储名字 'pidPath' => __DIR__ . '/log', 'processName' => ':swooleMultiProcess', // 设置进程名, 方便管理, 默认值 swooleTopicQueue 'sleepTime' => 3000, // 子进程退出之后,自动拉起暂停毫秒数 'redis' => [ 'host' => '192.168.10.129', 'port' => '6379', 'preKey'=> 'SwooleMultiProcess-', //'password'=>'', 'select' => 0, // 操作库(可选参数,默认0) 'serialize' => true, // 是否序列化(可选参数,默认true) ], //exec任务相关,name的名字不能相同 'exec' => [ [ 'name' => 'kcloze-test-1', 'bin' => '/usr/local/php7/bin/php', 'binArgs' => ['/mnt/hgfs/www/saletool/think', 'testAmqp', '0'], 'workNum' => 1, // 外部程序进程数(固定) 'queueNumCacheKey' => 'test_mq_queue', // 控制动态进程数队列长度缓存key,注意缓存数据为["total" => 10000,"update_time" => 15812345678](可选参数,不设置或为空时只有固定进程) 'dynamicWorkNum' => 2, // 外部程序动态进程数,总进程数=固定+动态(可选参数,设置参数queueNumCacheKey时为必填参数) ], /* [ 'name' => 'kcloze-test-1', 'bin' => '/usr/local/bin/php', 'binArgs' => [__DIR__ . '/test/cli/test.php', 'oop', '123'], 'workNum' => 3, ], */ /* [ 'name' => 'kcloze-test-2', 'bin' => '/usr/local/bin/php', 'binArgs' => [__DIR__ . '/test/cli/test.php', 'oop', '123'], 'workNum' => 2, ], */ /* [ 'name' => 'kcloze-test-3', 'bin' => '/usr/local/bin/php', 'binArgs' => [__DIR__ . '/test/cli/test2.php', 'oop', '456'], 'workNum' => 5, ], */ // [ // 'name' => 'kcloze-test-3', // 'bin' => '/usr/bin/python', // 'binArgs' => [__DIR__ . '/test/cli/test3.py', 'oop', '369'], // 'workNum' => 2, // ], ], ]; ================================================ FILE: multiprocess ================================================ #!/usr/bin/env php * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ define('APP_PATH', __DIR__); date_default_timezone_set('Asia/Shanghai'); require APP_PATH . '/vendor/autoload.php'; $param = getopt('s:c:'); $opt =$param['s'] ?? ''; $configFile =$param['c'] ?? APP_PATH . '/config.php'; if ($configFile && file_exists($configFile)) { $config = require_once $configFile; } else { die('config file can not find!'); } $console = new Kcloze\MultiProcess\Console($opt, $config); $console->run(); ================================================ FILE: multiprocess.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ define('APP_PATH', __DIR__); date_default_timezone_set('Asia/Shanghai'); require APP_PATH . '/vendor/autoload.php'; $param = getopt('s:c::'); $opt =$param['s'] ?? ''; $configFile =$param['c'] ?? APP_PATH . '/config.php'; if ($configFile && file_exists($configFile)) { $config = require_once $configFile; } else { die('config file can not find!'); } $console = new Kcloze\MultiProcess\Console($opt, $config); $console->run(); ================================================ FILE: src/Config.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace Kcloze\MultiProcess; class Config { private static $config=[]; public static function setConfig($config) { self::$config=$config; } public static function getConfig() { return self::$config; } public static function hasRepeatingName($config=[], $chckKey='name') { $nameList=[]; foreach ($config as $key => $value) { if (isset($nameList[$value[$chckKey]])) { return true; } $nameList[$value[$chckKey]]=$value[$chckKey]; } return false; } } ================================================ FILE: src/Console.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace Kcloze\MultiProcess; class Console { public $logger = null; private $config = []; private $opt = []; private $redis = null; public function __construct($opt, $config) { $this->opt=$opt; if (empty($this->opt)) { $this->printHelpMessage(); exit(1); } Config::setConfig($config); $this->config = Config::getConfig(); $this->logger = new Logs(Config::getConfig()['logPath'] ?? '', $this->config['logSaveFileApp'] ?? ''); } public function run() { $this->runOpt(); } public function start() { //启动 $process = new Process(); $process->start(); } /** * 给主进程发送信号: * SIGUSR1 自定义信号,让子进程平滑退出 * SIGTERM 程序终止,让子进程强制退出. * * @param [type] $signal */ public function stop($signal=SIGUSR1) { $this->logger->log(($signal == SIGUSR1) ? 'smooth to exit...' : 'force to exit...'); if (isset($this->config['pidPath']) && !empty($this->config['pidPath'])) { $masterPidFile=$this->config['pidPath'] . '/master.pid'; } else { die('config pidPath must be set!'); } if (file_exists($masterPidFile)) { $ppid=file_get_contents($masterPidFile); if (empty($ppid)) { exit('service is not running' . PHP_EOL); } //给主进程发送信号 if (@\Swoole\Process::kill($ppid, $signal)) { $this->logger->log('[pid: ' . $ppid . '] has been stopped success'); } else { $this->logger->log('[pid: ' . $ppid . '] has been stopped fail'); } $this->getRedis()->set(Process::REDIS_MASTER_KEY, Process::STATUS_WAIT); } else { exit('service is not running' . PHP_EOL); } } public function restart() { $this->logger->log('restarting...'); $this->exit(); sleep(3); $this->start(); } public function exit() { $this->stop(SIGTERM); } public function runOpt() { switch ($this->opt) { case 'start': $this->start(); break; case 'stop': $this->stop(); break; case 'exit': $this->exit(); break; case 'restart': $this->restart(); break; case 'help': $this->printHelpMessage(); break; default: $this->printHelpMessage(); break; } } public function printHelpMessage() { $msg=<<<'EOF' NAME php multiprocess - manage multiprocess SYNOPSIS php multiprocess command [options] Manage multiprocess daemons. WORKFLOWS help [command] Show this help, or workflow help for command. -s restart Stop, then start multiprocess master and workers. -s start Start multiprocess master and workers. -s start -c=./config Start multiprocess with specail config file. -s stop Wait all running workers smooth exit, please check multiprocess status for a while. -s exit Kill all running workers and master PIDs. EOF; echo $msg; } private function getRedis() { if ($this->redis && $this->redis->ping()) { return $this->redis; } $this->redis = new XRedis($this->config['redis']); return $this->redis; } } ================================================ FILE: src/Logs.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace Kcloze\MultiProcess; class Logs { const LEVEL_TRACE = 'trace'; const LEVEL_WARNING = 'warning'; const LEVEL_ERROR = 'error'; const LEVEL_INFO = 'info'; const LEVEL_PROFILE = 'profile'; const MAX_LOGS = 10000; public $rotateByCopy = true; public $maxLogFiles = 5; public $maxFileSize = 100; // in MB private $logPath = ''; //单个类型log private $logs = []; private $logCount = 0; //默认log文件存储名 private $logSaveFileApp = 'application.log'; private static $instance=null; public function __construct($logPath, $logSaveFileApp='') { if (empty($logPath)) { die('config logPath must be set!' . PHP_EOL); } Utils::mkdir($logPath); $this->logPath = $logPath; if ($logSaveFileApp) { $this->logSaveFileApp = $logSaveFileApp; } } /** * 获取日志实例. * * @$logPath * * @param mixed $logPath * @param mixed $logSaveFileApp */ public static function getLogger($logPath='', $logSaveFileApp='') { if (isset(self::$instance) && self::$instance !== null) { return self::$instance; } self::$instance=new self($logPath, $logSaveFileApp); return self::$instance; } /** * 格式化日志信息. * * @param mixed $message * @param mixed $level * @param mixed $category * @param mixed $time */ public function formatLogMessage($message, $level, $category, $time) { return @date('Y/m/d H:i:s', $time) . " [$level] [$category] $message\n"; } /** * 日志分类处理. * * @param mixed $message * @param mixed $level * @param mixed $category * @param mixed $flush */ public function log($message, $level = 'info', $category = '', $flush = true) { if (empty($category)) { $category=$this->logSaveFileApp; } $this->logs[$category][] = [$message, $level, $category, microtime(true)]; $this->logCount++; if ($this->logCount >= self::MAX_LOGS || true == $flush) { $this->flush($category); } } /** * 日志分类处理. */ public function processLogs() { $logsAll=[]; foreach ((array) $this->logs as $key => $logs) { $logsAll[$key] = ''; foreach ((array) $logs as $log) { $logsAll[$key] .= $this->formatLogMessage($log[0], $log[1], $log[2], $log[3]); } } return $logsAll; } /** * 写日志到文件. */ public function flush() { if ($this->logCount <= 0) { return false; } $logsAll = $this->processLogs(); $this->write($logsAll); $this->logs = []; $this->logCount = 0; } /** * [write 根据日志类型写到不同的日志文件]. * * @param $logsAll * * @throws \Exception */ public function write($logsAll) { if (empty($logsAll)) { return; } //$this->logPath = ROOT_PATH . 'src/runtime/'; if (!is_dir($this->logPath)) { self::mkdir($this->logPath, [], true); } foreach ($logsAll as $key => $value) { if (empty($key)) { continue; } $fileName = $this->logPath . '/' . $key; if (($fp = @fopen($fileName, 'a')) === false) { throw new \Exception("Unable to append to log file: {$fileName}"); } @flock($fp, LOCK_EX); if (@filesize($fileName) > $this->maxFileSize * 1024 * 1024) { $this->rotateFiles($fileName); } @fwrite($fp, $value); @flock($fp, LOCK_UN); @fclose($fp); } } /** * Rotates log files. * * @param mixed $file */ protected function rotateFiles($file) { for ($i = $this->maxLogFiles; $i >= 0; --$i) { // $i == 0 is the original log file $rotateFile = $file . ($i === 0 ? '' : '.' . $i); //var_dump($rotateFile); if (is_file($rotateFile)) { // suppress errors because it's possible multiple processes enter into this section if ($i === $this->maxLogFiles) { @unlink($rotateFile); } else { if ($this->rotateByCopy) { @copy($rotateFile, $file . '.' . ($i + 1)); if ($fp = @fopen($rotateFile, 'a')) { @ftruncate($fp, 0); @fclose($fp); } } else { @rename($rotateFile, $file . '.' . ($i + 1)); } } } } } /** * Shared environment safe version of mkdir. Supports recursive creation. * For avoidance of umask side-effects chmod is used. * * @param string $dst path to be created * @param array $options newDirMode element used, must contain access bitmask * @param bool $recursive whether to create directory structure recursive if parent dirs do not exist * * @return bool result of mkdir * * @see mkdir */ private static function mkdir($dst, array $options, $recursive) { $prevDir = dirname($dst); if ($recursive && !is_dir($dst) && !is_dir($prevDir)) { self::mkdir(dirname($dst), $options, true); } $mode = isset($options['newDirMode']) ? $options['newDirMode'] : 0777; $res = mkdir($dst, $mode); @chmod($dst, $mode); return $res; } } ================================================ FILE: src/Process.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace Kcloze\MultiProcess; class Process { const CHILD_PROCESS_CAN_RESTART ='staticWorker'; //子进程可以重启,进程个数固定 const CHILD_PROCESS_CAN_NOT_RESTART ='dynamicWorker'; //子进程不可以重启,进程个数根据队列堵塞情况动态分配 const STATUS_START ='start'; //主进程启动中状态 const STATUS_RUNNING ='runnning'; //主进程正常running状态 const STATUS_WAIT ='wait'; //主进程wait状态 const STATUS_STOP ='stop'; //主进程stop状态 const STATUS_RECOVER ='recover'; //主进程recover状态 const REDIS_MASTER_KEY ='Status'; //Redis主进程状态key const REDIS_WORKER_STATUS_KEY ='Status-'; //Redis 子进程状态key const REDIS_WORKER_MEMBER_KEY ='Members-'; //主进程recover状态 public $processName = ':swooleMultiProcess'; // 进程重命名, 方便 shell 脚本管理 private $workers; private $workersByPidName; private $ppid; private $configWorkersByNameNum; private $checkTickTimer = 5000; //检查服务是否正常定时器,单位ms private $sleepTime = 2000; //子进程退出之后,自动拉起暂停毫秒数 private $config = []; private $pidFile = 'master.pid'; private $status =''; //主进程状态 private $timer =''; //定时器id private $redis =null; //redis连接 private $logSaveFileWorker = 'workers.log'; private $queueMaxNum = 1000; //队列达到一定长度,增加子进程个数 private $workersInfoList = []; // 子进程队列 private $dynamicWorkerNum = []; //动态(不能重启)子进程计数,最大数为每个脚本配置dynamicWorkNum,它的个数是动态变化的 public function __construct() { $this->config = Config::getConfig(); if (Config::hasRepeatingName($this->config['exec'], 'name')) { die('exec name has repeating name,fetal error!'); } $this->logger = new Logs(Config::getConfig()['logPath'] ?? '', $this->config['logSaveFileApp'] ?? ''); if (isset($this->config['pidPath']) && !empty($this->config['pidPath'])) { Utils::mkdir($this->config['pidPath']); $this->pidFile =$this->config['pidPath'] . '/' . $this->pidFile; } else { die('config pidPath must be set!'); } if (isset($this->config['processName']) && !empty($this->config['processName'])) { $this->processName = $this->config['processName']; } if (isset($this->config['sleepTime']) && !empty($this->config['sleepTime'])) { $this->sleepTime = $this->config['sleepTime']; } if (isset($this->config['logSaveFileWorker']) && !empty($this->config['logSaveFileWorker'])) { $this->logSaveFileWorker = $this->config['logSaveFileWorker']; } /* * master.pid 文件记录 master 进程 pid, 方便之后进程管理 * 请管理好此文件位置, 使用 systemd 管理进程时会用到此文件 * 判断文件是否存在,并判断进程是否在运行 */ if (file_exists($this->pidFile)) { $pid=$this->getMasterPid(); if ($pid && @\Swoole\Process::kill($pid, 0)) { die('已有进程运行中,请先结束或重启' . PHP_EOL); } } \Swoole\Process::daemon(); $this->ppid = getmypid(); $this->saveMasterPid(); $this->setProcessName('multiprocess master ' . $this->ppid . $this->processName); } /** * 启动主进程. */ public function start() { $this->saveMasterData([self::REDIS_MASTER_KEY =>self::STATUS_START]); if (!isset($this->config['exec'])) { die('config exec must be not null!'); } $this->logger->log('process start pid: ' . $this->ppid, 'info', $this->logSaveFileWorker); $this->configWorkersByNameNum=[]; foreach ($this->config['exec'] as $key => $value) { if (!isset($value['bin']) || !isset($value['binArgs'])) { $this->logger->log('config bin/binArgs must be not null!', 'error', $this->logSaveFileWorker); } $workOne['bin'] = $value['bin']; $workOne['name'] = $value['name']; $workOne['binArgs'] = $value['binArgs']; //开启多个子进程 for ($i = 0; $i < $value['workNum']; ++$i) { $this->reserveExec($i, $workOne, self::CHILD_PROCESS_CAN_RESTART); } $this->configWorkersByNameNum[$value['name']] = $value['workNum']; } if (empty($this->timer)) { $this->registSignal(); $this->registTimer(); }//启动成功,修改状态 $this->saveMasterData([self::REDIS_MASTER_KEY=>self::STATUS_RUNNING]); } public function startByWorkerName($workName) { $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workName=>self::STATUS_START]); foreach ($this->config['exec'] as $key => $value) { if ($value['name'] != $workName) { continue; } if (!isset($value['bin']) || !isset($value['binArgs'])) { $this->logger->log('config bin/binArgs must be not null!', 'error', $this->logSaveFileWorker); } $workOne['bin'] = $value['bin']; $workOne['name'] = $value['name']; $workOne['binArgs'] = $value['binArgs']; //开启多个子进程 for ($i = 0; $i < $value['workNum']; ++$i) { $this->reserveExec($i, $workOne); } } $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workName=>self::STATUS_RUNNING]); } /** * 启动子进程,跑业务代码 * * @param int $workNum * @param mixed $workOne * @param string $workerType 是否会重启 canRestart|unRestart */ public function reserveExec($workNum, $workOne, $workerType=self::CHILD_PROCESS_CAN_RESTART) { $reserveProcess = new \Swoole\Process(function ($worker) use ($workNum, $workOne) { usleep($this->sleepTime * 1000); // usleep单位是微妙,$this->sleepTime * 1000 转为毫秒 $this->checkMpid($worker); try { $this->logger->log('Worker exec: ' . $workOne['bin'] . ' ' . implode(' ', $workOne['binArgs']), 'info', $this->logSaveFileWorker); //执行一个外部程序 $worker->exec($workOne['bin'], $workOne['binArgs']); } catch (\Throwable $e) { Utils::catchError($this->logger, $e); } catch (\Exception $e) { Utils::catchError($this->logger, $e); } $this->logger->log('worker id: ' . $workNum . ' is done!!!', 'info', $this->logSaveFileWorker); $worker->exit(0); }); $pid = $reserveProcess->start(); $this->workers[$pid] = $reserveProcess; $this->workersInfoList[$pid]['type'] = $workerType; $this->workersInfoList[$pid]['workOne'] = $workOne; $this->setWorkerList(self::REDIS_WORKER_MEMBER_KEY . $workOne['name'], $pid, 'add'); $this->workersByPidName[$pid] = $workOne['name']; $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workOne['name'] =>self::STATUS_RUNNING]); $this->logger->log('worker id: ' . $workNum . ' pid: ' . $pid . ' is start... ' . $workerType, 'info', $this->logSaveFileWorker); } /** * 注册信号. */ public function registSignal() { \Swoole\Process::signal(SIGTERM, function ($signo) { $this->killWorkersAndExitMaster(); }); \Swoole\Process::signal(SIGKILL, function ($signo) { $this->killWorkersAndExitMaster(); }); \Swoole\Process::signal(SIGUSR1, function ($signo) { $this->waitWorkers(); }); \Swoole\Process::signal(SIGCHLD, function ($signo) { while (true) { // 捕获回收子进程异常 try { $ret = \Swoole\Process::wait(false); } catch (\Exception $e) { $this->logger->log('signoError: ' . $signo . $e->getMessage(), 'error', Logs::LOG_SAVE_FILE_WORKER); } if ($ret) { $pid = $ret['pid']; $childProcess = $this->workers[$pid]; $workName = $this->workersByPidName[$pid]; $workerType = $this->workersInfoList[$pid]['type']; $this->status=$this->getMasterData(self::REDIS_MASTER_KEY); //根据wokerName,获取其运行状态 $workNameStatus=$this->getMasterData(self::REDIS_WORKER_STATUS_KEY . $workName); //子进程为可重启进程,主进程状态为start,running且子进程组不是recover状态才需要拉起子进程 if (self::CHILD_PROCESS_CAN_RESTART == $workerType && self::STATUS_RECOVER != $workNameStatus && (self::STATUS_RUNNING == $this->status || self::STATUS_START == $this->status)) { try { $i=0; //重启有可能失败,最多尝试10次 while ($i <= 10) { $newPid = $childProcess->start(); if ($newPid > 0) { break; } $this->logger->log($workName . '子进程重启失败,子进程尝试' . $i . '次重启', 'info', $this->logSaveFileWorker); ++$i; } } catch (\Throwable $e) { Utils::catchError($this->logger, $e, 'error: woker restart fail...'); } catch (\Exception $e) { Utils::catchError($this->logger, $e, 'error: woker restart fail...'); } if ($newPid > 0) { $this->logger->log("Worker Restart, kill_signal={$ret['signal']} PID=" . $newPid, 'info', $this->logSaveFileWorker); $this->workers[$newPid] = $childProcess; $this->workersInfoList[$newPid]['type'] = $workerType; $this->workersInfoList[$newPid]['workOne'] = $this->workersInfoList[$pid]['workOne']; $this->setWorkerList(self::REDIS_WORKER_MEMBER_KEY . $workName, $newPid, 'add'); $this->workersByPidName[$newPid] = $workName; $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workName=>self::STATUS_RUNNING]); } else { $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workName=>self::STATUS_RECOVER]); $this->logger->log($workName . '子进程重启失败,该组子进程进入recover状态', 'info', $this->logSaveFileWorker); } } // 动态子进程 if (self::CHILD_PROCESS_CAN_NOT_RESTART == $workerType) { --$this->dynamicWorkerNum[$workName]; } $this->logger->log("Worker Exit, kill_signal={$ret['signal']} PID=" . $pid, 'info', $this->logSaveFileWorker); unset($this->workers[$pid], $this->workersByPidName[$pid], $this->workersInfoList[$pid]); $this->setWorkerList(self::REDIS_WORKER_MEMBER_KEY . $workName, $pid, 'del'); $this->logger->log('Worker count: ' . \count($this->workers) . ' [' . $workName . '] ' . $this->configWorkersByNameNum[$workName], 'info', $this->logSaveFileWorker); //如果$this->workers为空,且主进程状态为wait,说明所有子进程安全退出,这个时候主进程退出 if (empty($this->workers) && self::STATUS_WAIT == $this->status) { $this->logger->log('主进程收到所有信号子进程的退出信号,子进程安全退出完成', 'info', $this->logSaveFileWorker); $this->exitMaster(); } } else { break; } } }); } /** * 注册定时器. */ public function registTimer() { $this->timer=\Swoole\Timer::tick($this->checkTickTimer, function ($timerId) { $workNameStatus = ''; foreach ($this->configWorkersByNameNum as $workName => $value) { $this->status =$this->getMasterData(self::REDIS_MASTER_KEY); $workNameStatus=$this->getMasterData(self::REDIS_WORKER_STATUS_KEY . $workName); $workNameMembers=$this->getWorkerList(self::REDIS_WORKER_MEMBER_KEY . $workName); $this->checkChildProcess($workName, $workNameMembers); $count=\count($workNameMembers); if ($count <= 0) { $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workName=>self::STATUS_START]); $this->startByWorkerName($workName); $this->logger->log('主进程 recover 子进程:' . $workName, 'info', $this->logSaveFileWorker); } $this->logger->log('主进程状态:' . $this->status . ' 数量:' . \count($this->workers), 'info', $this->logSaveFileWorker); $this->logger->log('[' . $workName . ']子进程状态:' . $workNameStatus . ' 数量:' . $count . ' pids:' . serialize($workNameMembers), 'info', $this->logSaveFileWorker); } // 动态进程控制todo foreach ($this->config['exec'] as $key => $value) { if (!isset($value['dynamicWorkNum']) || $value['dynamicWorkNum'] < 1 || !isset($value['queueNumCacheKey']) || !$value['queueNumCacheKey']) { continue; } if (!isset($value['bin']) || !isset($value['binArgs'])) { $this->logger->log('config bin/binArgs must be not null!', 'error', $this->logSaveFileWorker); } // 获取队列的缓存数据,根据入队列数量控制动态进程 $queueCacheData = $this->getCacheData($value['queueNumCacheKey']); if(!$queueCacheData || !isset($queueCacheData["total"]) || !isset($queueCacheData["update_time"])) { continue; } $this->dynamicWorkerNum[$value['name']] = isset($this->dynamicWorkerNum[$value['name']]) ? $this->dynamicWorkerNum[$value['name']] : 0; if ($queueCacheData["total"] < $this->queueMaxNum || $this->dynamicWorkerNum[$value['name']] >= $value['dynamicWorkNum']) { continue; } // 由缓存的total和update_time组成的key控制是否需要启动进程(只运行一次) $runOneTimeKey = "sw_process_".$value['name']."_".$queueCacheData["total"]."_".$queueCacheData["update_time"]; $runOneTimeRes = $this->getCacheData($runOneTimeKey); if($runOneTimeRes) { continue; } $workOne['bin'] = $value['bin']; $workOne['name'] = $value['name']; $workOne['binArgs']= $value['binArgs']; $canStartNum = $value['dynamicWorkNum'] - $this->dynamicWorkerNum[$value['name']]; //开启多个子进程 for ($i = 0; $i < $canStartNum; ++$i) { $this->reserveExec($i, $workOne, self::CHILD_PROCESS_CAN_NOT_RESTART); ++$this->dynamicWorkerNum[$value['name']]; } $this->setCacheData($runOneTimeKey,1,7200); } }); } //检查子进程是否还活着 private function checkChildProcess($workName, $members) { foreach ($members as $key => $pid) { if ($pid) { if (!@\Swoole\Process::kill($pid, 0)) { unset($this->workers[$pid], $this->workersByPidName[$pid]); $this->setWorkerList(self::REDIS_WORKER_MEMBER_KEY . $workName, $pid, 'del'); $this->logger->log('子进程异常退出:' . $pid . ' name:' . $workName, 'error', $this->logSaveFileWorker); } else { $this->logger->log('子进程正常:' . $pid . ' name:' . $workName, 'info', $this->logSaveFileWorker); } } } } //平滑等待子进程退出之后,再退出主进程 private function killWorkersAndExitMaster() { //修改主进程状态为stop $this->status =self::STATUS_STOP; $this->saveMasterData([self::REDIS_MASTER_KEY=>self::STATUS_STOP]); if ($this->workers) { foreach ($this->workers as $pid => $worker) { //强制杀workers子进程 if (true == \Swoole\Process::kill($pid)) { unset($this->workers[$pid]); $this->logger->log('子进程[' . $pid . ']收到强制退出信号,退出成功', 'info', $this->logSaveFileWorker); } else { $this->logger->log('子进程[' . $pid . ']收到强制退出信号,但退出失败', 'info', $this->logSaveFileWorker); } $this->logger->log('Worker count: ' . \count($this->workers), 'info', $this->logSaveFileWorker); } } $this->exitMaster(); } //强制杀死子进程并退出主进程 private function waitWorkers() { //修改主进程状态为wait $this->saveMasterData([self::REDIS_MASTER_KEY=>self::STATUS_WAIT]); $this->status = self::STATUS_WAIT; foreach ($this->configWorkersByNameNum as $key => $value) { $workName =$key; $this->saveMasterData([self::REDIS_WORKER_STATUS_KEY . $workName=>self::STATUS_WAIT]); } } //退出主进程 private function exitMaster() { @unlink($this->pidFile); $this->clearMasterData(); $this->logger->log('Time: ' . microtime(true) . '主进程' . $this->ppid . '退出', 'info', $this->logSaveFileWorker); sleep(1); exit(); } /** * 设置进程名. * * @param mixed $name */ private function setProcessName($name) { //mac os不支持进程重命名 if (\function_exists('swoole_set_process_name') && PHP_OS != 'Darwin') { swoole_set_process_name($name); } } //主进程如果不存在了,子进程退出 private function checkMpid(&$worker) { if (!@\Swoole\Process::kill($this->ppid, 0)) { $worker->exit(); $this->logger->log("Master process exited, I [{$worker['pid']}] also quit"); } } private function saveMasterPid() { file_put_contents($this->pidFile, $this->ppid); } private function getMasterPid() { return file_get_contents($this->pidFile); } private function saveMasterData($data=[]) { $this->redis = $this->getRedis(); foreach ((array) $data as $key => $value) { $key && $this->redis->set($key, $value); } } private function clearMasterData() { $this->redis = $this->getRedis(); $data=$this->configWorkersByNameNum; foreach ((array) $data as $key => $value) { $value && $this->redis->del(self::REDIS_WORKER_STATUS_KEY . $key); $value && $this->redis->del(self::REDIS_WORKER_MEMBER_KEY . $key); $this->logger->log('主进程退出前删除woker redis key: ' . $key, 'info', $this->logSaveFileWorker); } $this->redis->del(self::REDIS_MASTER_KEY); $this->logger->log('主进程退出前删除master redis key: status', 'info', $this->logSaveFileWorker); } private function setWorkerList($key, $member, $opt='add') { $this->redis = $this->getRedis(); if ('add' == $opt) { return $this->redis->sAdd($key, $member); } elseif ('del' == $opt) { return $this->redis->sRemove($key, $member); } } /** * 获取子进程列表. * * @param string $key * * @return mixed */ private function getWorkerList($key) { $this->redis = $this->getRedis(); return $this->redis->sMembers($key); } /** * 获取主进程数据. * * @param string $key * * @return mixed */ private function getMasterData($key) { $this->redis = $this->getRedis(); if ($key) { return $this->redis->get($key); } } /** * 获取缓存数据. * * @param string $key * * @return mixed */ private function getCacheData($key) { $this->redis = $this->getRedis(); if ($key) { return $this->redis->get($key); } return false; } /** * 设置缓存数据. * * @param string $key * * @return mixed */ private function setCacheData($key,$value,$timeout = 3600) { $this->redis = $this->getRedis(); return $this->redis->set($key,$value,$timeout); } /** * 获取redis实例. */ private function getRedis() { if ($this->redis && $this->redis->ping()) { return $this->redis; } $this->redis = new XRedis($this->config['redis']); return $this->redis; } } ================================================ FILE: src/Utils.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace Kcloze\MultiProcess; class Utils { /** * 循环创建目录. * * @param mixed $path * @param mixed $recursive * @param mixed $mode */ public static function mkdir($path, $mode=0777, $recursive=true) { if (!is_dir($path)) { mkdir($path, $mode, $recursive); } } public static function catchError($logger, $exception, $error='') { $error .= '错误类型:' . get_class($exception) . PHP_EOL; $error .= '错误代码:' . $exception->getCode() . PHP_EOL; $error .= '错误信息:' . $exception->getMessage() . PHP_EOL; $error .= '错误堆栈:' . $exception->getTraceAsString() . PHP_EOL; $logger->log($error, 'error'); } } ================================================ FILE: src/XRedis.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ namespace Kcloze\MultiProcess; use Exception; use Redis; class XRedis { /** * @var Redis */ private $handler; private $config; /** * @param $config */ public function __construct($config) { $this->config = $config; $this->connect(); } /** * 调用redis. * * @param $method * @param $arguments * * @return mixed */ public function __call($method, $arguments) { if (!$this->handler) { $this->connect(); } return call_user_func_array([$this->handler, $method], $arguments); } public function get($key, $serialize = false) { if (!$this->handler) { $this->connect(); } if ($serialize === false) { isset($this->config['serialize']) && $serialize = $this->config['serialize']; } return $serialize ? unserialize($this->handler->get($key)) : $this->handler->get($key); } public function set($key, $value, $timeout = 0, $serialize = false) { if (!$this->handler) { $this->connect(); } if ($serialize === false) { isset($this->config['serialize']) && $serialize = $this->config['serialize']; } $value = $serialize ? serialize($value) : $value; if ($timeout > 0) { return $this->handler->set($key, $value, $timeout); } return $this->handler->set($key, $value); } public function hget($key, $hash, $serialize = false) { if (!$this->handler) { $this->connect(); } if ($serialize === false) { isset($this->config['serialize']) && $serialize = $this->config['serialize']; } return $serialize ? unserialize($this->handler->hget($key, $hash)) : $this->handler->hget($key, $hash); } public function hset($key, $hash, $value, $serialize = false) { if (!$this->handler) { $this->connect(); } if ($serialize === false) { isset($this->config['serialize']) && $serialize = $this->config['serialize']; } $value = $serialize ? serialize($value) : $value; return $this->handler->hset($key, $hash, $value); } /** * 创建handler. * * @throws Exception */ private function connect() { $this->handler = new Redis(); if (isset($this->config['keep-alive']) && $this->config['keep-alive']) { $fd = $this->handler->pconnect($this->config['host'], $this->config['port'], 60); } else { $fd = $this->handler->connect($this->config['host'], $this->config['port']); } if (isset($this->config['password'])) { $this->handler->auth($this->config['password']); } if (!$fd) { throw new Exception("Unable to connect to redis host: {$this->config['host']},port: {$this->config['port']}"); } // 选择数据库0-15 if (isset($this->config['select']) && 0 <= $this->config['select'] && $this->config['select'] <= 15) { $this->handler->select($this->config['select']); } //统一key前缀 if (isset($this->config['preKey']) && !empty($this->config['preKey'])) { $this->handler->setOption(Redis::OPT_PREFIX, $this->config['preKey']); } } } ================================================ FILE: systemd/multiprocess.service ================================================ [Unit] Description=Multiprocess Server After=network.target After=syslog.target [Service] Type=forking PIDFile=/media/kcloze/8685937c-af42-4319-aa9b-bb123ccd18ba/data/www/kcloze/multiprocess/log/master.pid ExecStart=/usr/bin/php7.0 /media/kcloze/8685937c-af42-4319-aa9b-bb123ccd18ba/data/www/kcloze/multiprocess/multiprocess.php start >> /media/kcloze/8685937c-af42-4319-aa9b-bb123ccd18ba/data/www/kcloze/multiprocess/log/server.log 2>&1 ExecStop=/bin/kill $MAINPID ExecReload=/bin/kill -USR1 $MAINPID Restart=always [Install] WantedBy=multi-user.target graphical.target ================================================ FILE: test/cli/test.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ ini_set('date.timezone', 'Asia/Shanghai'); echo 'test1 time: ' . date('Y-m-d H:i:s'); sleep(15); $i= mt_rand(1, 5); var_dump($i); // if ($i == 3) { // NotExit(); // } ================================================ FILE: test/cli/test2.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ ini_set('date.timezone', 'Asia/Shanghai'); sleep(10); echo 'test2 time: ' . date('Y-m-d H:i:s'); // while (true) { // echo '123' . PHP_EOL; // sleep(1); // } // sleep(10); // $i= mt_rand(1, 5); // var_dump($i); // if ($i == 3) { // NotExit(); // } ================================================ FILE: test/cli/test3.py ================================================ #coding=utf-8 import time def main(): print "Hello,Python!" time.sleep(5) main() ================================================ FILE: test/testConfig.php ================================================ * This source file is subject to the MIT license that is bundled * with this source code in the file LICENSE. */ define('APP_PATH', dirname(__DIR__)); date_default_timezone_set('Asia/Shanghai'); require APP_PATH . '/vendor/autoload.php'; $config = require_once APP_PATH . '/config.php'; var_dump($config); use Kcloze\MultiProcess\Config; $result=Config::hasRepeatingName($config['exec'], 'name'); var_dump($result);