Master-Worker 的模式結構
Master 程式為主程式,它維護了一個 Worker 程式佇列、子任務佇列和子結果集。Worker 程式佇列中的 Worker 程式,不停地從任務佇列中提取要處理的子任務,並將子任務的處理結果寫入結果集。
- 使用多程式
- 支援 Worker 錯誤重試,僅僅實現業務即可
- 任務累積過多,自動 Fork Worker 程式
- 常駐 Worker 程式,減少程式 Fork 開銷
- 非常駐 Worker 程式閒置,自動退出回收
- 支援日誌
Demo: 基於 Redis 生產消費佇列 在 test 目錄中
程式碼:
<?php
declare(ticks = 1);
// 必須先使用語句declare(ticks=1),否則註冊的singal-handel就不會執行了
//error_reporting(E_ERROR);
abstract class MasterWorker
{
// 子程式配置屬性
protected $maxWorkerNum; // 最多隻能開啟程式數
protected $minWorkerNum; // 最少常駐子程式數
protected $waitTaskTime; // 等待任務時間,單位秒
protected $waitTaskLoopTimes; // 連續這麼多次佇列為空就退出子程式
protected $consumeTryTimes; // 連續消費失敗次數
// 父程式專用屬性
protected $worker_list = [];
protected $check_internal = 1;
protected $masterExitCallback = [];
// 子程式專用屬性
protected $autoQuit = false;
protected $status = self::WORKER_STATUS_IDLE;
protected $taskData; // 任務資料
protected $workerExitCallback = [];
// 通用屬性
protected $stop_service = false;
protected $master = true;
// 通用配置
protected $logFile;
const WORKER_STATUS_IDLE = 'idle';
const WORKER_STATUS_FINISHED = 'finished';
const WORKER_STATUS_EXITING = 'exiting';
const WORKER_STATUS_WORKING = 'working';
const WORKER_STATUS_FAIL = 'fail';
const WORKER_STATUS_TERMINATED = 'terminated';
public function __construct($options = [])
{
$this->initConfig($options);
}
protected function initConfig($options = [])
{
$defaultConfig = [
'maxWorkerNum' => 10,
'minWorkerNum' => 3,
'waitTaskTime' => 0.01,
'waitTaskLoopTimes' => 50,
'consumeTryTimes' => 3,
'logFile' => './master_worker.log',
];
foreach ($defaultConfig as $key => $default) {
$this->$key = array_key_exists($key, $options) ? $options[$key] : $default;
}
}
public function start()
{
// 父程式異常,需要終止子程式
set_exception_handler([$this, 'exceptionHandler']);
// fork minWorkerNum 個子程式
$this->mutiForkWorker($this->minWorkerNum);
if ($this->getWorkerLength() <= 0) {
$this->masterWaitExit(true, 'fork 子程式全部失敗');
}
// 父程式監聽訊號
pcntl_signal(SIGTERM, [$this, 'sig_handler']);
pcntl_signal(SIGINT, [$this, 'sig_handler']);
pcntl_signal(SIGQUIT, [$this, 'sig_handler']);
pcntl_signal(SIGCHLD, [$this, 'sig_handler']);
// 監聽佇列,佇列比程式數多很多,則擴大程式,擴大部分的程式會空閒自動退出
$this->checkWorkerLength();
$this->masterWaitExit();
}
/**
* Master 等待退出
*
* @param boolean $force 強制退出
* @param string $msg 退出 message
* @return void
*/
protected function masterWaitExit($force = false, $msg = '')
{
// 強制傳送退出訊號
$force && $this->sig_handler(SIGTERM);
// 等到子程式退出
while ($this->stop_service) {
$this->checkExit($msg);
$this->msleep($this->check_internal);
}
}
protected function log($msg)
{
try {
$header = $this->isMaster() ? 'Master [permanent]' : sprintf('Worker [%s]', $this->autoQuit ? 'temporary' : 'permanent');
$this->writeLog($msg, $this->getLogFile(), $header);
} catch (\Exception $e) {
}
}
protected function mutiForkWorker($num, $autoQuit = false, $maxTryTimes = 3)
{
for ($i = 1; $i <= $num; ++$i) {
$this->forkWorker($autoQuit, $maxTryTimes);
}
}
protected function checkWorkerLength()
{
// 如果要退出父程式,就不執行檢測
while (! $this->stop_service) {
$this->msleep($this->check_internal);
// 處理程式
$workerLength = $this->getWorkerLength();
// 如果程式數小於最低程式數
$this->mutiForkWorker($this->minWorkerNum - $workerLength);
$workerLength = $this->getWorkerLength();
// 建立常駐worker程式失敗, 下次檢查繼續嘗試建立
if ($workerLength <= 0) {
continue;
}
if ($workerLength >= $this->maxWorkerNum) {
// 不需要增加程式
continue;
}
$num = $this->calculateAddWorkerNum();
// 不允許超過最大程式數
$num = min($num, $this->maxWorkerNum - $workerLength);
// 建立空閒自動退出worker程式
$this->mutiForkWorker($num, true);
}
}
protected function getWorkerLength()
{
return count($this->worker_list);
}
//訊號處理函式
public function sig_handler($sig)
{
switch ($sig) {
case SIGTERM:
case SIGINT:
case SIGQUIT:
// 退出: 給子程式傳送退出訊號,退出完成後自己退出
// 先標記一下,子程式完全退出後才能結束
$this->stop_service = true;
// 給子程式傳送訊號
foreach ($this->worker_list as $pid => $v) {
posix_kill($pid, SIGTERM);
}
break;
case SIGCHLD:
// 子程式退出, 回收子程式, 並且判斷程式是否需要退出
while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
// 去除子程式
unset($this->worker_list[$pid]);
// 子程式是否正常退出
// if (pcntl_wifexited($status)) {
// //
// }
}
$this->checkExit();
break;
default:
$this->default_sig_handler($sig);
break;
}
}
public function child_sig_handler($sig)
{
switch ($sig) {
case SIGINT:
case SIGQUIT:
case SIGTERM:
$this->stop_service = true;
break;
// 操作比較危險 在處理任務當初強制終止
// case SIGTERM:
// // 強制退出
// $this->stop_service = true;
// $this->status = self::WORKER_STATUS_TERMINATED;
// $this->beforeWorkerExitHandler();
// $this->status = self::WORKER_STATUS_EXITING;
// die(1);
// break;
}
}
protected function checkExit($msg = '')
{
if ($this->stop_service && empty($this->worker_list)) {
$this->beforeMasterExitHandler();
die($msg ?:'Master 程式結束, Worker 程式全部退出');
}
}
protected function forkWorker($autoQuit = false, $maxTryTimes = 3)
{
$times = 1;
do {
$pid = pcntl_fork();
if ($pid == -1) {
++$times;
} elseif($pid) {
$this->worker_list[$pid] = true;
//echo 'pid:', $pid, "\n";
return $pid;
} else {
// 子程式 消費
$this->autoQuit = $autoQuit;
$this->master = false;
// 處理訊號
pcntl_signal(SIGTERM, [$this, 'child_sig_handler']);
pcntl_signal(SIGINT, [$this, 'child_sig_handler']);
pcntl_signal(SIGQUIT, [$this, 'child_sig_handler']);
exit($this->runChild()); // worker程式結束
}
} while ($times <= $maxTryTimes);
// fork 3次都失敗
return false;
}
/**
* 子程式處理內容
*/
protected function runChild()
{
$noDataLoopTime = 0;
$status = 0;
while (!$this->autoQuit || ($noDataLoopTime <= $this->waitTaskLoopTimes)) {
// 處理退出
if ($this->stop_service) {
break;
}
$this->taskData = null;
try {
$this->taskData = $this->deQueue();
if ($this->taskData) {
$noDataLoopTime = 1; // 重新從1開始
$this->status = self::WORKER_STATUS_WORKING;
$this->consumeByRetry($this->taskData);
$this->status = self::WORKER_STATUS_FINISHED;
} else {
$this->status = self::WORKER_STATUS_IDLE;
// 避免溢位
$noDataLoopTime = $noDataLoopTime >= PHP_INT_MAX ? PHP_INT_MAX : ($noDataLoopTime + 1);
// 等待佇列
$this->msleep($this->waitTaskTime);
}
$status = 0;
} catch (\RedisException $e) {
$this->status = self::WORKER_STATUS_FAIL;
$this->consumeFail($this->taskData, $e);
$status = 1;
} catch (\Exception $e) {
// 消費出現錯誤
$this->status = self::WORKER_STATUS_FAIL;
$this->consumeFail($this->taskData, $e);
$status = 2;
}
}
$this->beforeWorkerExitHandler();
$this->status = self::WORKER_STATUS_EXITING;
return $status;
}
/**
* @param $data
* @param int $tryTimes
* @throws \Exception
*/
protected function consumeByRetry($data, $tryTimes = 1)
{
$tryTimes = 1;
$exception = null;
// consume 返回false 為失敗
while ($tryTimes <= $this->consumeTryTimes) {
try {
return $this->consume($data);
} catch (\Exception $e) {
$exception = $e;
++$tryTimes;
}
}
// 最後一次還報錯 寫日誌
if (($tryTimes > $this->consumeTryTimes) && $exception) {
throw $exception;
}
}
/**
* @param $mixed
* @param $filename
* @param $header
* @param bool $trace
* @return bool
* @throws \Exception
*/
protected function writeLog($mixed, $filename, $header, $trace = false)
{
if (is_string($mixed)) {
$text = $mixed;
} else {
$text = var_export($mixed, true);
}
$trace_list = "";
if ($trace) {
$_t = debug_backtrace();
$trace_list = "-- TRACE : \r\n";
foreach ($_t as $_line) {
$trace_list .= "-- " . $_line ['file'] . "[" . $_line ['line'] . "] : " . $_line ['function'] . "()" . "\r\n";
}
}
$text = "\r\n=" . $header . "==== " . strftime("[%Y-%m-%d %H:%M:%S] ") . " ===\r\n<" . getmypid() . "> : " . $text . "\r\n" . $trace_list;
$h = fopen($filename, 'a');
if (! $h) {
throw new \Exception('Could not open logfile:' . $filename);
}
// exclusive lock, will get released when the file is closed
if (! flock($h, LOCK_EX)) {
return false;
}
if (fwrite($h, $text) === false) {
throw new \Exception('Could not write to logfile:' . $filename);
}
flock($h, LOCK_UN);
fclose($h);
return true;
}
protected function msleep($time)
{
usleep($time * 1000000);
}
public function exceptionHandler($exception)
{
if ($this->isMaster()) {
$msg = '父程式['.posix_getpid().']錯誤退出中:' . $exception->getMessage();
$this->log($msg);
$this->masterWaitExit(true, $msg);
} else {
$this->child_sig_handler(SIGTERM);
}
}
public function isMaster()
{
return $this->master;
}
/**
* 預設的 worker 數量增加處理
*
* @return int
*/
public function calculateAddWorkerNum()
{
$workerLength = $this->getWorkerLength();
$taskLength = $this->getTaskLength();
// 還不夠多
if (($taskLength / $workerLength < 3) && ($taskLength - $workerLength < 10)) {
return 0;
}
// 增加一定數量的程式
return ceil($this->maxWorkerNum - $workerLength / 2);
}
/**
* 自定義日子檔案
*
* @return string
*/
protected function getLogFile()
{
return $this->logFile;
}
/**
* 自定義消費錯誤函式
*
* @param [type] $data
* @param \Exception $e
* @return void
*/
protected function consumeFail($data, \Exception $e)
{
$this->log(['data' => $data, 'errorCode' => $e->getCode(), 'errorMsg' => get_class($e) . ' : ' . $e->getMessage()]);
}
protected function beforeWorkerExitHandler()
{
foreach ($this->workerExitCallback as $callback) {
is_callable($callback) && call_user_func($callback, $this);
}
}
/**
* 設定Worker自定義結束回撥
*
* @param mixed $func
* @param boolean $prepend
* @return void
*/
public function setWorkerExitCallback($callback, $prepend = false)
{
return $this->setCallbackQueue('workerExitCallback', $callback, $prepend);
}
/**
* 設定Master自定義結束回撥
*
* @param callable $func
* @param boolean $prepend
* @return void
*/
public function setMasterExitCallback(callable $callback, $prepend = false)
{
return $this->setCallbackQueue('masterExitCallback', $callback, $prepend);
}
protected function setCallbackQueue($queueName, $callback, $prepend = false)
{
if (! isset($this->$queueName) || ! is_array($this->$queueName)) {
return false;
}
if (is_null($callback)) {
$this->$queueName = []; // 如果傳遞 null 就清空
return true;
} elseif (! is_callable($callback)) {
return false;
}
if ($prepend) {
array_unshift($this->$queueName, $callback);
} else {
$this->$queueName[] = $callback;
}
return true;
}
protected function beforeMasterExitHandler()
{
foreach ($this->masterExitCallback as $callback) {
is_callable($callback) && call_user_func($callback, $this);
}
}
protected function default_sig_handler($sig)
{
}
/**
* 得到待處理任務數量
*/
abstract protected function getTaskLength();
/**
* 出隊
* @return mixed
*/
abstract public function deQueue();
/**
* 入隊
* @param $data
* @return int
*/
abstract public function enQueue($data);
/**
* 消費的具體內容
* 不要進行失敗重試
* 會自動進行
* 如果失敗直接丟擲異常
* @param $data
*/
abstract protected function consume($data);
}
Demo
基於redis 的 生產者-消費者模式
RedisProducterConsumer.php
<?php
require "../src/MasterWorker.php";
class RedisProducterConsumer extends MasterWorker
{
const QUERY_NAME = 'query_name';
/**
* Master 和 Worker 的連線分開,否則會出現問題
*
* @var Redis[]
*/
protected $redis_connections = [];
public function __construct($options = [])
{
parent::__construct($options);
// 設定退出回撥
$this->setWorkerExitCallback(function ($worker) {
$this->closeRedis();
// 處理結束,把redis關閉
$this->log('程式退出:' . posix_getpid());
});
$this->setMasterExitCallback(function ($master) {
$this->closeRedis();
$this->log('master 程式退出:' . posix_getpid());
});
}
/**
* 得到佇列長度
*/
protected function getTaskLength()
{
return (int) $this->getRedis()->lSize(static::QUERY_NAME);
}
/**
* 出隊
* @return mixed
*/
public function deQueue()
{
return $this->getRedis()->lPop(static::QUERY_NAME);
}
/**
* 入隊
* @param $data
* @return int
*/
public function enQueue($data)
{
return $this->getRedis()->rPush(static::QUERY_NAME, (string) $data);
}
/**
* 消費的具體內容
* 不要進行失敗重試
* 會自動進行
* 如果失敗直接丟擲異常
* @param $data
*/
protected function consume($data)
{
// 錯誤丟擲異常
//throw new Exception('錯誤資訊');
$this->log('消費中 ' . $data);
$this->msleep(1);
$this->log('消費結束:' . $data . '; 剩餘個數:' . $this->getTaskLength());
}
/**
* @return Redis
*/
public function getRedis()
{
$index = $this->isMaster() ? 'master' : 'worker';
// 後續使用 predis 使用redis池
if (! isset($this->redis_connections[$index])) {
$connection = new \Redis();
$connection->connect('127.0.0.1', 6379, 2);
$this->redis_connections[$index] = $connection;
}
return $this->redis_connections[$index];
}
public function closeRedis()
{
foreach ($this->redis_connections as $key => $connection) {
$connection && $connection->close();
}
}
protected function consumeFail($data, \Exception $e)
{
parent::consumeFail($data, $e);
// 自定義操作,比如重新入隊,上報錯誤等
}
}
呼叫例子
<?php
require "./RedisProducterConsumer.php";
$producterConsumer = new RedisProducterConsumer();
// 清空任務佇列
$producterConsumer->getRedis()->ltrim(RedisProducterConsumer::QUERY_NAME, 1, 0);
// 寫入任務佇列
for ($i = 1; $i <= 100; ++$i) {
$producterConsumer->enQueue($i);
}
$producterConsumer->start();
// 接下來的寫的程式碼不會執行
// 檢視執行的程式
// ps aux | grep test.php
// 試一試 Ctrl + C 在執行上面產看程式命令
程式碼地址:https://github.com/MrSuperLi/php-master-wo...
本作品採用《CC 協議》,轉載必須註明作者和本文連結