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_ERROR、E_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...