Windows 桌面应用自研 PHP 队列(下):完整代码与六大工程化优化

Windows 桌面应用自研 PHP 队列(下):完整代码与六大工程化优化

导读 :上篇我们拆解了 Windows 作业对象连坐机制,并给出了 start /B + pclose 的破局方案。本篇将直接贴上生产可用的完整代码,并逐一详解我在实战中总结出的六大工程化优化点------每一个都是踩坑换来的经验。

一、进程管理器完整代码(app/process/Task.php)

这是整个队列的大脑,负责 Worker 的启动、监控、扩缩容和日志清理:

php 复制代码
<?php
namespace app\process;

use Workerman\Timer;
use support\Log;

class Task
{
    private string $pidFile;
    private string $logsDir;
    private string $workerScript;
    private string $phpBinary;
    private int $desiredCount = 4;

    public function onWorkerStart()
    {
        $runtimeDir = base_path() . '/runtime';
        $this->pidFile = $runtimeDir . '/real_workers_pid.txt';
        $this->logsDir = $runtimeDir . '/logs';
        $this->workerScript = base_path() . '/core/worker.php';

        @mkdir($runtimeDir, 0777, true);
        @mkdir($this->logsDir, 0777, true);

        $this->cleanupBeforeStart();
        $this->startRealWorkers();
        $this->monitorWorkers();
    }

    private function cleanupBeforeStart(): void
    {
        if (file_exists($this->pidFile)) {
            $oldPids = file($this->pidFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
            foreach ($oldPids as $pid) {
                $pid = intval(trim($pid));
                if ($pid > 0) {
                    shell_exec("taskkill /F /PID {$pid} >nul 2>&1");
                }
            }
        }
        file_put_contents($this->pidFile, '');

        $queueLogPattern = $this->logsDir . '/queue_*.log';
        foreach (glob($queueLogPattern) as $file) {
            @unlink($file);
        }
        Log::info("Task manager: 清理全部历史队列日志完成");
    }

    private function startRealWorkers(): void
    {
        if (!$this->checkPrerequisites()) return;

        Log::info("Task manager: 启动 {$this->desiredCount} 个图片处理队列进程");
        for ($i = 0; $i < $this->desiredCount; $i++) {
            $errorLog = $this->logsDir . '/queue_start_err_' . time() . '_' . mt_rand(1000, 9999) . '.log';
            $cmd = 'cmd /c start "" /B "' . $this->phpBinary . '" "' . $this->workerScript . '" > "' . $errorLog . '" 2>&1';
            pclose(popen($cmd, 'r'));
        }

        // ✅ 优化1:轮询检测PID就绪,替代固定sleep
        $waitMaxMs = 2000;
        $waitStepMs = 200;
        $waited = 0;
        while ($waited < $waitMaxMs) {
            $alivePids = $this->getAliveWorkerPids();
            if (count($alivePids) >= $this->desiredCount) break;
            usleep($waitStepMs * 1000);
            $waited += $waitStepMs;
        }

        $this->refreshPidFile();
        Log::info("Task manager: 队列进程启动完成,存活PID:" . implode(',', $this->getAliveWorkerPids()));
    }

    private function monitorWorkers(): void
    {
        Timer::add(3, function() {
            $alivePids = $this->getAliveWorkerPids();
            $currentCount = count($alivePids);

            // ✅ 优化2:超额进程循环查杀
            if ($currentCount > $this->desiredCount) {
                sort($alivePids);
                $extraPids = array_slice($alivePids, $this->desiredCount);
                foreach ($extraPids as $pid) {
                    $killRetry = 3;
                    while ($killRetry > 0 && $this->isPidAlive($pid)) {
                        shell_exec("taskkill /F /PID {$pid} >nul 2>&1");
                        usleep(300 * 1000);
                        $killRetry--;
                    }
                    @unlink($this->logsDir . "/queue_worker_{$pid}.log");
                    @unlink($this->logsDir . "/queue_crash_{$pid}.log");
                }
                Log::warning("Task manager: 超额进程已清理,共移除" . count($extraPids) . "个队列worker");
                $alivePids = array_slice($alivePids, 0, $this->desiredCount);
            }

            // 进程不足自动补充
            if ($currentCount < $this->desiredCount) {
                $need = $this->desiredCount - $currentCount;
                Log::warning("Task manager: 当前仅{$currentCount}个进程运行,补充{$need}个队列worker");
                for ($i = 0; $i < $need; $i++) {
                    $errorLog = $this->logsDir . '/queue_start_err_' . time() . '_' . mt_rand(1000, 9999) . '.log';
                    $cmd = 'cmd /c start "" /B "' . $this->phpBinary . '" "' . $this->workerScript . '" > "' . $errorLog . '" 2>&1';
                    pclose(popen($cmd, 'r'));
                }
                sleep(1);
            }

            $this->refreshPidFile();
            $this->cleanDeadWorkerLogs();

            // ✅ 优化3:自动清理老旧启动错误日志
            $errLogList = glob($this->logsDir . '/queue_start_err_*.log');
            if (count($errLogList) > 10) {
                usort($errLogList, fn($a, $b) => filemtime($a) <=> filemtime($b));
                $delFiles = array_slice($errLogList, 0, count($errLogList) - 10);
                foreach ($delFiles as $f) @unlink($f);
            }
        });
    }

    private function refreshPidFile(): void
    {
        $alivePids = $this->getAliveWorkerPids();
        $fp = fopen($this->pidFile, 'c+');
        if (flock($fp, LOCK_EX)) {
            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, implode("\n", $alivePids));
            fflush($fp);
            flock($fp, LOCK_UN);
        }
        fclose($fp);
    }

    private function cleanDeadWorkerLogs(): void
    {
        $alivePids = $this->getAliveWorkerPids();
        foreach (glob($this->logsDir . '/queue_worker_*.log') as $logFile) {
            if (preg_match('/queue_worker_(\d+)\.log/', basename($logFile), $matches)) {
                if (!in_array((int)$matches[1], $alivePids)) {
                    @unlink($logFile);
                    @unlink($this->logsDir . "/queue_crash_{$matches[1]}.log");
                }
            }
        }
    }

    private function checkPrerequisites(): bool
    {
        $this->phpBinary = PHP_BINARY;
        if (stripos($this->phpBinary, 'php-cgi') !== false) {
            $this->phpBinary = str_ireplace('php-cgi', 'php', $this->phpBinary);
        }
        if (!file_exists($this->phpBinary)) {
            $this->phpBinary = 'D:\phpEnv\php\php-8.3\php.exe';
        }
        if (!file_exists($this->phpBinary)) {
            Log::error("Task manager: PHP可执行文件未找到:{$this->phpBinary}");
            return false;
        }
        if (!file_exists($this->workerScript)) {
            Log::error("Task manager: worker启动脚本不存在:{$this->workerScript}");
            return false;
        }
        return true;
    }

    private function getAliveWorkerPids(): array
    {
        if (!file_exists($this->pidFile)) return [];
        $pids = file($this->pidFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        $alive = [];
        foreach ($pids as $pid) {
            $pid = intval(trim($pid));
            if ($pid > 0 && $this->isPidAlive($pid)) {
                $alive[] = $pid;
            }
        }
        return $alive;
    }

    private function isPidAlive(int $pid): bool
    {
        $result = shell_exec("tasklist /FI \"PID eq {$pid}\" 2>&1");
        return $result && strpos($result, (string)$pid) !== false;
    }
}

二、Worker 消费进程完整代码(core/worker.php)

php 复制代码
<?php
/**
 * core/worker.php - 队列工作进程
 */
// ✅ 优化4:致命错误兜底
register_shutdown_function(function() {
    $error = error_get_last();
    if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR])) {
        $pid = getmypid();
        $crashLog = __DIR__ . '/../runtime/logs/queue_crash_' . $pid . '.log';
        @file_put_contents(
            $crashLog,
            date('Y-m-d H:i:s') . ' FATAL ERROR: ' . print_r($error, true) . PHP_EOL,
            FILE_APPEND
        );
    }
});

define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
Webman\Config::load(BASE_PATH . '/config', ['app', 'log']);
require_once BASE_PATH . '/core/db.php';

$pid = getmypid();
$pidFile = BASE_PATH . '/runtime/real_workers_pid.txt';
$logFile = BASE_PATH . '/runtime/logs/queue_worker_' . $pid . '.log';

$fp = fopen($pidFile, 'a+');
if (flock($fp, LOCK_EX)) {
    fwrite($fp, $pid . PHP_EOL);
    fflush($fp);
    flock($fp, LOCK_UN);
}
fclose($fp);

file_put_contents($logFile, date('Y-m-d H:i:s') . " Worker {$pid} 队列消费进程已启动,等待任务" . PHP_EOL, FILE_APPEND);

$dataDir = BASE_PATH . '/data';
@mkdir($dataDir, 0777, true);
$dbPath = $dataDir . '/queue.db';

// ✅ 优化5:数据库断线重连
function getDbConn(string $dbPath, int $retryMax = 2): ?PDO
{
    $retry = 0;
    while ($retry <= $retryMax) {
        try {
            $pdo = new \PDO("sqlite:$dbPath");
            $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
            return $pdo;
        } catch (\Exception $e) {
            $retry++;
            if ($retry > $retryMax) return null;
            usleep(500 * 1000);
        }
    }
    return null;
}

$pdo = getDbConn($dbPath);
if ($pdo === null) {
    $crashLog = __DIR__ . '/../runtime/logs/queue_crash_' . $pid . '.log';
    @file_put_contents($crashLog, date('Y-m-d H:i:s') . ' SQLite连接失败,重试耗尽,进程退出' . PHP_EOL, FILE_APPEND);
    exit(1);
}

// ✅ 优化6:自适应阶梯休眠
$queueName = 'default';
$emptyCount = 0;
$sleepStep = [200000, 800000, 2000000]; // 0.2s / 0.8s / 2s

while (true) {
    try {
        try {
            $pdo->query("SELECT 1");
        } catch (\Exception $_) {
            $pdo = getDbConn($dbPath);
            if ($pdo === null) throw new \Exception("数据库重连失败");
        }

        $task = dequeueTask($queueName);
        if ($task) {
            $emptyCount = 0;
            $taskId = $task['id'];
            $payload = json_decode($task['payload'], true);
            $type = $payload['type'] ?? 'default';

            support\Log::info("[Worker {$pid}] 任务#{$taskId} 开始执行, 类型: {$type}");
            $className = str_replace('_', '', ucwords($type, '_'));
            $fullClass = "\\app\\jobs\\{$className}";

            if (!class_exists($fullClass)) {
                support\Log::error("[Worker {$pid}] 任务#{$taskId} 执行失败: 处理类 {$fullClass} 不存在");
                updateTaskStatus($taskId, 'failed');
                continue;
            }

            (new $fullClass())->handle($payload);
            updateTaskStatus($taskId, 'completed');
            $cost = number_format((microtime(true) - $task['created_at']), 2);
            support\Log::info("[Worker {$pid}] 任务#{$taskId} 执行完成,耗时: {$cost}s,进程PID: {$pid}");
        } else {
            $emptyCount++;
            $sleepIdx = min($emptyCount - 1, count($sleepStep) - 1);
            usleep($sleepStep[$sleepIdx]);
        }
    } catch (\Exception $e) {
        support\Log::error("[Worker {$pid}] 任务异常: " . $e->getMessage() . PHP_EOL . $e->getTraceAsString());
        if (isset($taskId)) updateTaskStatus($taskId, 'failed');
        sleep(1);
        $emptyCount = 0;
    }
}

三、六大优化点深度解析

✅ 优化1:轮询检测 PID 就绪,替代固定 sleep

popen 是异步的,调用后进程不会立刻就绪。固定 sleep(2) 要么等太久浪费时间,要么等不够拿到空 PID。改为 200ms 步长轮询,最多等 2 秒,既快又稳。

✅ 优化2:超额进程循环查杀

Windows 下 taskkill /F 并非总是立即生效,尤其当目标进程正在执行 I/O 操作时。单次查杀可能失败,改为最多重试 3 次、每次间隔 300ms 的循环查杀,确保进程彻底终止。

✅ 优化3:自动清理老旧启动错误日志

每次启动 Worker 都会生成一个带时间戳的错误日志文件,长期运行会导致文件爆炸。保留最新 10 个,按修改时间排序删除旧的,防止磁盘占满。

✅ 优化4:register_shutdown_function 致命错误兜底

Worker 是常驻进程,E_ERRORE_PARSE 等致命错误无法被 try-catch 捕获,会导致进程静默退出且无任何记录。通过 shutdown 函数兜底写入 crash 日志,让每一次异常死亡都有迹可循。

✅ 优化5:SQLite 断线重连

SQLite 虽然稳定,但在多进程并发写入、文件被外部工具锁定等场景下仍可能断开。每次取任务前用 SELECT 1 探活,失败则重试重连,避免 Worker 因数据库问题进入假死状态。

✅ 优化6:自适应阶梯休眠

固定 sleep(2) 在任务密集时延迟太高,usleep(100000) 在空闲时 CPU 占用又太大。采用 0.2s → 0.8s → 2s 三级阶梯:有任务立即重置到最短间隔保证响应速度,连续无任务逐步延长休眠降低资源消耗。

四、写在最后

这套队列已经在实际桌面应用中稳定运行,支撑了日均数万张图片的异步处理。它证明了:即使在 Windows 这个 PHP 的"非主场",只要理解了底层机制,依然可以构建出可靠的多进程系统

如果你也在做 PHP 桌面应用,或者对这套队列有任何改进建议,欢迎评论区交流!


系列导航上篇:绕过 Webman 单进程限制与作业对象连坐陷阱 关于作者 :有需要联系我,邮箱:lizhilimaster@163.com 下载代码gitcode.com/lizhilimast...

相关推荐
BingoGo2 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
JaguarJack2 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
用户3074596982073 天前
PHP 扩展——从入门到理解
php
鹏仔先生4 天前
拷贝漫画APP下载页PHP程序,后台带免费AI写作
php
云水一下4 天前
从零开始学 PHP 系列(一):PHP 的前世今生与开发环境搭建
开发语言·php
xingpanvip4 天前
星盘接口开发文档:本命盘接口指南
android·开发语言·css·php·lua
酉鬼女又兒4 天前
零基础入门计算机网络运输层:端到端通信核心作用、端口号分类规则、复用分用工作机制及UDP与TCP协议全方位对比详解
网络·网络协议·tcp/ip·计算机网络·考研·udp·php
dog2504 天前
不要再继续优化 TCP
网络协议·tcp/ip·php
Channing Lewis4 天前
PHP 解析 Excel 的那些坑:一次“行号错位”引发的数据丢失
开发语言·php·excel