PHP 併發程式設計之 Master-Worker 模式

李志成發表於2019-05-11

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 協議》,轉載必須註明作者和本文連結
有什麼想法歡迎提問或者資訊

相關文章