☰
PHP 8.3怎么用Swoole提升API并发能力
2026/10/2 3:53:11 网站建设 项目流程

前言

先澄清一个常见误解:Swoole 是一个 PECL 扩展,并发能力来自扩展本身,而不是 PHP 8.3 这个语言版本。PHP 8.3 在这里的角色只是「运行 Swoole 的宿主」,它自己不会让 API 变快。标题容易让人以为是 8.3 带来了并发特性,实际上是「装对了 Swoole,并且在 8.3 上跑」这两件事叠加的结果。

真正需要先确认的是版本匹配:Swoole 采用独立的分支维护,不同分支支持的 PHP 版本区间不同。按官方发布说明,5.1.x 分支面向 PHP 8.0–8.3,6.0.x 分支面向 PHP 8.1–8.4,而 4.x 分支已停止维护、只支持到 PHP 7.4 一线。在 PHP 8.3 上装 Swoole 4.x 会加载失败或直接段错误,这是升级 PHP 时最常见的翻车点。安装前请先核对扩展分支与 PHP 版本,具体对应关系以官方发布说明为准。

本文讲三件事:Swoole 为什么能提高并发,协程模型下哪些写法会失效,以及一个可以直接跑的并发抓取示例。

一、FPM 模型和常驻内存模型的差别

理解收益从哪来,才能知道它不解决什么问题:

维度传统 php-fpmSwoole 常驻进程
进程生命周期每个请求都要重新初始化一次进程常驻,框架只加载一次
请求处理一个进程同一时刻只处理一个请求一个 worker 内多个协程交替推进
IO 等待进程被阻塞,什么也做不了协程让出,调度器去跑别的协程
并发上限受pm.max_children限制受内存与max_coroutine限制
状态请求结束就销毁全局变量会跨请求残留

一个直观的比喻:FPM 是「排队打电话」,一个柜台一次只服务一个人;协程是「一个人同时打很多通电话」,等对方接听的间隙就去说另一通。

需要注意的是,协程提高的是「等待期间的吞吐」,不是单个请求的速度。如果一段代码是纯 CPU 计算,比如循环做大量加密运算,协程不会让它变快——因为协程是协作式调度,没有抢占,一个不让出 CPU 的协程会把整个 worker 卡住。

二、协程是怎么让出的

Swoole 在 worker 进程里跑一个事件循环(event loop),协程遇到 IO 时主动让出控制权:

协程 A: 发起查询 ──► 让出 ────────────────► 收到结果,继续执行 协程 B: 发起请求 ──► 让出 ────────────► 收到结果,继续 协程 C: 计算 ──► 完成,无需等待 ──► 结束 ↑ 同一个线程在三条协程之间切换

让出发生在「IO 操作」上,前提是这个 IO 操作是协程化的。Swoole 提供两类做法:


  1. 使用协程客户端:Swoole\Coroutine\Http\Client、Swoole\Coroutine\Channel、Swoole\Coroutine\System::sleep()等。

  2. 打开运行时钩子:Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL),让部分原生的阻塞调用(文件操作、sleep、以及被支持的数据库/网络扩展)在底层替换成协程版本。具体支持哪些扩展,请以你所用扩展版本的文档为准,因为不同分支的钩子覆盖面并不一致。


没有协程化的阻塞调用是一个隐形的性能黑洞:它不会报错,只是把整个 worker 卡住,表现为「并发数上不去,CPU 还很闲」。

三、并发上限要自己控制

协程很轻,轻到容易失控:一个foreach里对 1000 个 URL 各起一个协程,就会瞬间打开 1000 个连接,把内存和文件描述符吃光。所以协程编程里最常见的模式是用Channel当信号量限制并发:

手段作用
Swoole\Coroutine\Channel(带容量)做并发信号量,push满则挂起
max_coroutine配置全局兜底,防止协程无限增长
连接池限制到下游的并发连接数,复用连接

代码实战:并发抓取接口

下面是一个完整的 Swoole HTTP 服务(需要 PHP 8.0+ 与 Swoole 5.x,PHP 8.3 可直接运行),它在处理一个请求时并发请求多个上游,并把并发数控制在 4:

<?php // server.php —— 需要 PHP 8.0+ 与 Swoole 5.x 扩展 declare(strict_types=1); use Swoole\Coroutine\Channel; use Swoole\Coroutine\Http\Client; use Swoole\Http\Request; use Swoole\Http\Response; use Swoole\Http\Server; /** * 并发抓取一组上游地址,$limit 控制同时在飞的请求数。 * 这里用带容量的 Channel 当信号量:容量满时 push 会挂起当前协程,天然限流。 */ function fetchAll(array $targets, int $limit = 4, float $timeout = 2.0): array { $sem = new Channel($limit); // 并发额度 $done = new Channel(count($targets)); // 完成计数 $out = []; foreach ($targets as $i => $target) { $sem->push(true); // 占额度,满了就在这里等 // 注意 $out 必须按引用捕获,否则协程里写的结果不会回到外层数组 go(function () use ($sem, $done, &$out, $i, $target, $timeout): void { try { $cli = new Client($target['host'], $target['port'] ?? 80); $cli->set(['timeout' => $timeout]); $cli->get($target['path'] ?? '/'); $out[$i] = [ 'host' => $target['host'], 'status' => $cli->statusCode, 'bytes' => strlen((string) $cli->body), ]; $cli->close(); } catch (Throwable $e) { $out[$i] = ['host' => $target['host'], 'error' => $e->getMessage()]; } finally { $sem->pop(); // 无论成功失败都要还额度,否则并发会一路降到 0 $done->push(true); } }); } for ($n = count($targets); $n > 0; $n--) { $done->pop(); // 等所有协程收尾 } ksort($out); return array_values($out); } $server = new Server('0.0.0.0', 9501); $server->set([ 'worker_num' => 4, // 一般设为 CPU 核数 'enable_coroutine' => true, // 让 onRequest 回调自动跑在协程里 'max_coroutine' => 3000, // 单 worker 协程上限,兜底防爆 'log_level' => SWOOLE_LOG_WARN, ]); $server->on('Request', function (Request $req, Response $resp): void { $targets = [ ['host' => '127.0.0.1', 'port' => 9502, 'path' => '/a'], ['host' => '127.0.0.1', 'port' => 9502, 'path' => '/b'], ['host' => '127.0.0.1', 'port' => 9502, 'path' => '/c'], ['host' => '127.0.0.1', 'port' => 9502, 'path' => '/d'], ['host' => '127.0.0.1', 'port' => 9502, 'path' => '/e'], ['host' => '127.0.0.1', 'port' => 9502, 'path' => '/f'], ]; $started = microtime(true); $results = fetchAll($targets, 4, 2.0); $resp->header('Content-Type', 'application/json; charset=utf-8'); $resp->end(json_encode([ 'elapsed_ms' => round((microtime(true) - $started) * 1000, 1), 'items' => $results, ], JSON_UNESCAPED_UNICODE)); }); $server->start();

启动与压测:

php server.php # 另开终端 curl -s 127.0.0.1:9501/ | head -c 400

观察elapsed_ms就能看出协程的价值:6 个上游、并发上限 4,总耗时应该接近「两批串行」而不是「6 倍单个耗时」。具体数字取决于你本地的上游响应时间,请以自己机器上的实测为准,不要照搬任何基准数字。

下面是一个通用的连接池,用于复用数据库或 Redis 连接(需要 PHP 8.0+):

<?php // ConnectionPool.php —— 需要 PHP 8.0+ 与 Swoole 5.x declare(strict_types=1); use Swoole\Coroutine\Channel; final class ConnectionPool { private Channel $channel; /** @param Closure():object $factory 返回一个可用连接 */ public function __construct(private Closure $factory, private int $size = 16) { $this->channel = new Channel($size); } /** 在 worker 启动时预热,避免首波请求同时排队建连 */ public function warmup(): void { for ($i = 0; $i < $this->size; $i++) { $this->channel->push(($this->factory)()); } } /** 借出连接;池空时等 $timeout 秒,超时后临时新建一个 */ public function get(float $timeout = 1.0): object { $conn = $this->channel->pop($timeout); return $conn === false ? ($this->factory)() : $conn; } /** 归还连接;池已满(例如临时连接)就直接关闭,避免泄漏 */ public function put(object $conn): void { if ($this->channel->push($conn, 0.01) === false) { if (method_exists($conn, 'close')) { $conn->close(); } } } }

用Swoole\Coroutine\run()可以在普通脚本里跑一段协程代码,便于验证逻辑:

<?php // 需要 PHP 8.0+ 与 Swoole 5.x declare(strict_types=1); use Swoole\Coroutine; Coroutine\run(function (): void { $pool = new ConnectionPool(static function (): object { // 这里换成真实的 PDO 或 Redis 客户端 return new stdClass(); }, 4); $pool->warmup(); $conn = $pool->get(); $pool->put($conn); Coroutine\System::sleep(0.05); // 协程版 sleep,阻塞的是协程而不是线程 echo "done\n"; });

常见坑点


  1. 没打钩子的阻塞调用


❌ 在协程里用file_get_contents()、curl_exec()、原生sleep()、普通 PDO 查询,以为它会自动让出。 ✅ 用协程客户端(如Swoole\Coroutine\Http\Client),或显式Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL);用不到的功能宁可放到 Task 进程里做。判断方法:压测时并发上不去而 CPU 很闲,几乎都是这个原因。


  1. 在主进程里建连接


❌ 在new Server()之前连好数据库,想在各个 worker 里复用。 ✅ 在$server->on('WorkerStart', ...)里初始化连接池——fork 之后子进程会共享同一个 socket,主进程建的连接被子进程混用会导致数据错位。


  1. 全局变量和静态属性跨协程串数据


❌ 用public static $currentUser存当前请求的用户,协程切换后 A 的请求读到了 B 的用户。 ✅ 用局部变量或Swoole\Coroutine::getContext()存放请求级状态。


  1. 忘记归还并发额度


❌ 抓取失败抛异常时跳过了pop(),额度越来越少,最终并发降到 0,接口整体挂起。 ✅ 把归还写进finally,无论成功失败都执行。


  1. 协程里调用exit()


❌ 校验失败直接exit('forbidden'),整个 worker 进程被终止,同进程内的其他协程全部中断。 ✅ 用$response->end(...)结束响应,把进程退出的权力交还给框架。


  1. worker_num设得过大


❌ 直接写worker_num => 64,以为并发就等于 64;进程切换和内存占用反而拖慢整体。 ✅ 一般从 CPU 核数起步,把并发交给协程,再按压测结果微调。


  1. 常驻进程里的内存泄漏


❌ 把数据往全局数组里塞做「缓存」,跑了几天内存爆掉,进程被 OOM 杀掉。 ✅ 常驻进程任何全局累积都要有上限或过期策略;上线前用长连接压测跑够时间观察 RSS。


  1. 修改代码后不重启


❌ 改了 PHP 文件刷新页面看不到变化——常驻进程的代码只在启动时加载一次。 ✅ 平滑重载(reload)或重启服务,并在部署流程里把它写进去。

总结

关注点结论
并发来源Swoole 扩展,不是 PHP 8.3 语言特性
版本匹配5.1.x 对 PHP 8.0–8.3,6.0.x 对 PHP 8.1–8.4,以官方发布说明为准
让出时机只有协程化的 IO 才会让出,CPU 密集代码会卡整个 worker
并发控制带容量的Channel做信号量,max_coroutine做全局兜底
状态隔离请求级数据放协程上下文,不要放全局或静态变量
连接管理连接池必须在WorkerStart里创建,用完必须归还
生命周期进程常驻意味着要处理内存泄漏和代码热加载


用 Swoole 提并发,本质上是在做一次编程模型的切换:从「一个请求独占一个进程」变成「一个进程内多个协程交替推进」。装上扩展只是第一步,真正决定成败的是有没有把阻塞调用换成协程版、有没有隔离请求级状态、有没有给并发加上限。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询