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...

相关推荐
Lhappy嘻嘻1 小时前
Java IO|File 文件操作 + 字节流 / 字符流完整笔记 + 递归删除文件实战
java·笔记·php
2601_963771374 小时前
How to Scale Your WordPress Store Traffic in 2026
前端·php·plugin
右耳朵猫AI8 小时前
PHP周刊2026W28 | 安全更新、Laravel 13.18、Twig 3.28
开发语言·php·laravel
罗政8 小时前
PDF 批量合并工具:本地 AI 自动排序、识别正文日期与合同编号
数据库·pdf·php
糖果店的幽灵9 小时前
安全测试从入门到精通_网络基础与HTTPS
网络·https·php
2601_9637713710 小时前
WordPress SEO Guide: Boost Rankings Without Technical Jargon
前端·php
日取其半万世不竭13 小时前
CS2 服务器搜不到?GSLT、端口和启动参数逐项检查
运维·服务器·php
360智汇云15 小时前
一次Redis超时引发的“冤案“
数据库·redis·php
ljs64827395115 小时前
Linux 网络常用命令与端口基础详解
linux·网络·php
m0_738120721 天前
PHP代码审计基础——超全局变量(三)
开发语言·安全·网络安全·php