广告位 · 页头(后台-模块管理-新建模块后替换此区域)

PHP执行系统命令如何设置超时,避免脚本无限挂起?

在 PHP 里执行系统命令时,很多情况下我们会直接使用 exec、system 或者 shell_exec。这些函数用起来简单,但也埋着一个明显的问题:一旦外部命令因为网络不通、等待输入、文件锁竞争或者进程阻塞而迟迟不退出,PHP 脚本就会一直卡在调用处。对于 Web 请求来说,这通常意味着浏览器一直转圈,服务器端的 PHP-FPM 进程被长期占用,连接数很快就会被耗尽。

PHP执行系统命令如何设置超时,避免脚本无限挂起?

要解决这个问题,必须理解 PHP 执行系统命令的底层行为。exec、system 这类函数本质上都是阻塞式调用,它们会等待子进程结束才返回。PHP 本身没有为这些函数提供超时参数,而 set_time_limit 限制的只是 PHP 脚本在用户态执行的时间,无法打断阻塞在底层 wait 或 read 上的系统调用。所以真正可靠的做法,是换用更底层的进程控制函数,自己实现超时检测与强制终止逻辑。

为什么系统命令会让 PHP 脚本无限挂起

先看一个典型场景。假设你通过 PHP 调用一个远程数据同步脚本,而这个脚本内部使用 curl 访问一个响应极慢的接口。如果接口没有设置合理的超时,curl 可能会等待几分钟甚至更久。此时 PHP 的 exec 函数会一直等待这个子进程退出,期间无法处理任何其他逻辑。更糟的是,如果该脚本试图从标准输入读取数据,而 PHP 没有给子进程提供输入流,它就会永远阻塞在 read 上。

有人可能会想到用 set_time_limit 来控制脚本执行时间。例如在调用 exec 之前执行 set_time_limit 会用于检测脚本运行时长,超过限制后 PHP 会发出致命错误。不过当脚本阻塞在 exec 内部等待外部命令时,PHP 的解释器并没有执行任何 PHP 字节码,因此 set_time_limit 的超时检测根本不会触发。同理,max_execution_time 配置项也无法打断这种系统级阻塞。这就是为什么即使设置了很短的 max_execution_time,PHP 脚本依然可能长时间卡住。

另一个常见误区是使用 ini_set 修改 max_execution_time,认为这样就能控制外部命令的执行时长。实际上 max_execution_time 只统计 PHP 自身消耗的 CPU 时间,外部命令运行期间 PHP 处于等待状态,并不会消耗 CPU 时间,所以这个限制形同虚设。要真正控制外部命令的执行时间,必须从进程层面入手,要么使用带超时功能的命令行工具包装,要么通过 proc_open 自己监控子进程状态。

使用 proc_open 与 stream_select 实现真正的超时控制

proc_open 是 PHP 提供的一个强大的进程打开函数,它允许你指定子进程的标准输入、输出和错误管道,并且可以精细控制这些管道的工作方式。配合 stream_select,可以在等待管道数据的同时设置超时时间。一旦超过指定时间还没有数据或进程没有结束,就可以主动终止子进程。

下面是一段完整的实现代码。代码中先通过 proc_open 启动一个外部命令,管道设置为非阻塞模式,然后使用 stream_select 监听标准输出和标准错误。如果超过 3 秒没有任何输出并且进程仍未结束,就调用 proc_terminate 强制结束子进程,最后通过 proc_close 回收资源。

<?php
function runCommandWithTimeout($command, $timeoutSeconds = 3) {
    $descriptors = [
        0 => ['pipe', 'r'], // stdin
        1 => ['pipe', 'w'], // stdout
        2 => ['pipe', 'w'], // stderr
    ];
    $process = proc_open($command, $descriptors, $pipes);
    if (!is_resource($process)) {
        return ['exit_code' => -1, 'stdout' => '', 'stderr' => 'proc_open failed', 'timed_out' => false];
    }
    // 将所有管道设置为非阻塞模式
    foreach ($pipes as $pipe) {
        stream_set_blocking($pipe, false);
    }
    $stdout = '';
    $stderr = '';
    $timedOut = false;
    $startTime = microtime(true);
    while (true) {
        $read = [];
        if (!feof($pipes[1])) {
            $read[] = $pipes[1];
        }
        if (!feof($pipes[2])) {
            $read[] = $pipes[2];
        }
        if (empty($read)) {
            break;
        }
        $write = null;
        $except = null;
        // 计算剩余超时时间
        $elapsed = microtime(true) - $startTime;
        $remaining = $timeoutSeconds - $elapsed;
        if ($remaining <= 0) {
            $timedOut = true;
            break;
        }
        $sec = (int)floor($remaining);
        $usec = (int)(($remaining - $sec) * 1000000);
        $changed = stream_select($read, $write, $except, $sec, $usec);
        if ($changed === false) {
            break;
        }
        if ($changed === 0) {
            // 超时时间内没有任何数据,且进程仍未退出
            $status = proc_get_status($process);
            if (!$status['running']) {
                break; // 进程已经退出
            }
            $timedOut = true;
            break;
        }
        foreach ($read as $stream) {
            if ($stream === $pipes[1]) {
                $chunk = fread($stream, 8192);
                if ($chunk !== false && $chunk !== '') {
                    $stdout .= $chunk;
                }
            }
            if ($stream === $pipes[2]) {
                $chunk = fread($stream, 8192);
                if ($chunk !== false && $chunk !== '') {
                    $stderr .= $chunk;
                }
            }
        }
    }
    if ($timedOut) {
        // 强制终止子进程
        proc_terminate($process, 9);
        $stdout .= "\n[Timeout after {$timeoutSeconds} seconds]";
    }
    // 关闭管道
    fclose($pipes[0]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    $exitCode = proc_close($process);
    return [
        'exit_code' => $exitCode,
        'stdout' => $stdout,
        'stderr' => $stderr,
        'timed_out' => $timedOut,
    ];
}
$result = runCommandWithTimeout('sleep 10; echo done', 3);
var_dump($result);

这段代码的核心逻辑在于 stream_select 的第四个和第五个参数,它们分别指定秒和微秒级别的等待时间。每次循环都会重新计算剩余时间,因此即使进程持续输出大量数据,总执行时间也不会超过设定的超时值。一旦超时,就通过 proc_terminate 向子进程发送 SIGKILL 信号,确保它被立即终止。

需要注意的是,proc_get_status 函数在某些平台上会返回缓存的进程状态,为了避免误判,可以在调用 proc_get_status 之前通过 usleep 短暂等待或多次检查。另外,非阻塞管道下 fread 可能返回空字符串,这不代表读到了文件末尾,需要结合 feof 判断是否已结束。上面的代码已经处理了这种情况。

其他执行函数与超时替代方案

如果不想自己写复杂的管道监听代码,在 Linux 环境下可以利用系统自带的 timeout 命令包装要执行的外部程序。timeout 命令会在指定时间后自动向子进程发送终止信号,PHP 只需调用一次 exec 即可。下面是一个简单示例:

<?php
$command = 'timeout 5s python /path/to/script.py';
exec($command, $output, $returnVar);
if ($returnVar === 124) {
    echo "命令执行超时";
} else {
    echo "命令正常结束,退出码:{$returnVar}";
}

使用 timeout 命令的优点是代码量极少,缺点是它只适用于 Linux/Unix 平台,在 Windows 上没有内置的等价命令。此外 timeout 只能控制整个命令的总时长,如果你需要在命令执行过程中持续获取输出,或者需要区分标准输出与标准错误的超时情况,proc_open 方案会更加灵活。

另一种方案是将外部命令放到后台运行,把输出重定向到临时文件,然后由 PHP 定期轮询该文件的大小或进程状态。这种方案可以在不阻塞 Web 请求的情况下执行长时间任务,但它需要额外的状态管理和清理逻辑,实现复杂度更高。对于只需要防止脚本无限挂起的场景,proc_open 配合 stream_select 是最直接有效的选择。

平台差异与僵尸进程处理

在 Linux 系统上,proc_terminate 默认发送 SIGTERM 信号,对于大多数程序来说已经足够。但如果子进程忽略了 SIGTERM 或者有自己的信号处理逻辑,可能无法立即退出。此时可以像上面的代码那样,在 proc_terminate 的第二个参数中指定 9,也就是发送 SIGKILL 信号。SIGKILL 不能被捕获或忽略,能够强制结束进程。

Windows 平台下 proc_open 的行为与 Linux 略有不同。Windows 的进程终止机制不像 POSIX 那样支持丰富的信号,proc_terminate 在 Windows 上会调用 TerminateProcess API 强制结束进程。另外,stream_select 在 Windows 上只能用于套接字,不能用于管道。这意味着上面的 stream_select 代码在 Windows 下无法正常工作。如果你必须支持 Windows,可以考虑使用 popen 加轮询文件大小,或者借助 Symfony Process 组件,该组件封装了跨平台的进程超时控制。

无论使用哪种方式,子进程被强制终止后都必须调用 proc_close 来回收进程资源。如果忘记调用 proc_close,子进程可能会变成僵尸进程,虽然它已经不再运行,但会保留在进程表中,逐渐耗尽系统可用的进程号。上面的示例代码在返回结果前已经调用 proc_close 完成回收。另外,如果 proc_open 打开了多个管道,一定要在 proc_close 之前关闭所有管道句柄,否则可能造成死锁。

总结一下,解决 PHP 执行系统命令无限挂起的核心思路就是:放弃无法设置超时的阻塞函数,改用 proc_open 打开进程,通过非阻塞管道和 stream_select 实现精确的超时检测,超时后调用 proc_terminate 强制终止,并最终用 proc_close 清理资源。这套方案在 Linux 下非常可靠,能够有效避免因外部命令异常导致的 PHP 脚本挂起问题。

广告位 · 文章底部(后台-模块管理替换此区域)