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

要解决这个问题,必须理解 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 脚本挂起问题。

