1. 引言:为什么需要协程
PHP 的传统运行模型里,一个请求对应一个进程(或一个 FPM worker),请求结束,进程内的所有变量、连接、内存全部释放。这种「一次请求、一次生命周期」的模式简单可靠,但也带来了两个天花板:
- 并发能力受限于进程数:每个请求独占一个进程,进程数一多,内存和上下文切换成本迅速上升。
- 无法复用连接与状态:每次请求都要重新建立数据库连接、Redis 连接,无法把热点数据常驻在内存里。
Swoole 通过引入协程(Coroutine),让 PHP 也能像 Go 一样写出「同步的代码、异步的性能」。本文围绕 Swoole 协程模型展开,重点讲清三件事:
- 协程与线程、进程的本质区别;
- Swoole 的协程调度是怎么工作的;
- 常驻内存带来的「坑」,以及 Swoole 协程与 Go goroutine 的异同。
2. 协程、线程、进程:三者到底差在哪
要理解协程,先要把它放回「进程 → 线程 → 协程」这条抽象层级里。
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 用户态程序(运行时) |
| 切换成本 | 最高(切换页表、TLB 刷新) | 较高(内核态切换) | 极低(用户态保存少量寄存器) |
| 内存隔离 | 完全隔离 | 共享进程内存 | 共享线程内存 |
| 并发数量级 | 几十~几百 | 几百~几千 | 几万~几十万 |
| 抢占方式 | 抢占式 | 抢占式 | 协作式(主动让出) |
一句话概括:
- 进程 是资源分配的最小单位,线程是 CPU 调度的最小单位;
- 协程 不是操作系统概念,而是用户态的轻量级「执行流」,由程序自己调度,切换时不需要陷入内核。
正因为协程切换发生在用户态,一次切换只涉及少量寄存器的保存与恢复,成本大约是线程切换的百分之一甚至更低,这才使得「单机支撑十万级并发连接」成为可能。
下面用一张图直观对比三者的层级与切换路径:
2.1 协作式调度的前提:主动让出
协程是协作式的,意味着它不会像线程那样被内核随时打断,只有在遇到「会让出」的操作时才会切换。在 Swoole 中,这些让出点包括:
- 网络 IO:
Co::sleep、协程化的 MySQL/Redis/HTTP 客户端; - 文件 IO:开启
hook_flags后的文件读写; - 显式让出:
Swoole\Coroutine::yield()。
如果一段协程代码里全是纯 CPU 计算,它就会一直占着当前线程,其他协程得不到执行------这一点和 Go 的抢占式调度有本质区别,后文会详细对比。
3. Swoole 的协程调度模型
Swoole 的协程运行在 Worker 进程内的单线程上,采用「一个线程跑多个协程」的模型。整体结构可以这样理解:
关键点:
- 每个 Worker 进程是一个独立的单线程事件循环,协程在这个循环里被调度;
- 协程遇到 IO 时挂起,把控制权交还给事件循环,事件循环继续跑其他就绪的协程;
- IO 完成后,事件循环把对应协程重新放回就绪队列,等待被恢复执行。
协程在一次 IO 请求中的完整调度时序如下:
3.1 协程的创建与生命周期
php
<?php
// 创建一个协程
Co\run(function () {
go(function () {
Co::sleep(0.1);
echo "协程 A 完成\n";
});
go(function () {
echo "协程 B 立即执行\n";
});
echo "主协程继续\n";
});
执行顺序大致是:先输出「协程 B 立即执行」,再输出「主协程继续」,最后在 0.1 秒后输出「协程 A 完成」。因为协程 A 遇到 Co::sleep 主动让出,事件循环转去执行其他就绪协程。
3.2 协程上下文与协程 ID
每个协程都有独立的协程上下文(Context) ,可以通过 Co::getContext() 存取数据,天然隔离,不会像全局变量那样在协程之间串味:
php
<?php
Co\run(function () {
go(function () {
Co::getContext()['request_id'] = 'req-001';
Co::sleep(0.01);
// 这里读到的仍然是 req-001,不会被其他协程覆盖
echo Co::getContext()['request_id'], PHP_EOL;
});
go(function () {
Co::getContext()['request_id'] = 'req-002';
Co::sleep(0.01);
echo Co::getContext()['request_id'], PHP_EOL;
});
});
这一点非常重要:协程上下文是替代「全局变量存请求数据」的正确姿势,也是后面讲常驻内存「坑」时的核心解法之一。
实战:模拟 HTTP 请求处理中的请求上下文
下面用一个贴近真实场景的例子,模拟两个并发到达的 HTTP 请求。每个请求协程需要携带自己的 request_id 和用户信息,并在若干次 IO 让出之后仍然能读到属于自己的数据。
正确示范:用 Co::getContext() 存取请求数据
php
<?php
use Swoole\Coroutine as Co;
function handleRequest(string $requestId, array $user): void
{
// 请求级数据全部放进当前协程的上下文
$ctx = Co::getContext();
$ctx['request_id'] = $requestId;
$ctx['user'] = $user;
// 模拟多次 IO:查用户、查订单、写日志
Co::sleep(0.01);
$ctx = Co::getContext();
echo "[{$ctx['request_id']}] 用户:{$ctx['user']['name']},开始查询订单\n";
Co::sleep(0.02);
$ctx = Co::getContext();
echo "[{$ctx['request_id']}] 用户:{$ctx['user']['name']},订单查询完成\n";
}
Co\run(function () {
go(fn () => handleRequest('req-001', ['id' => 1, 'name' => 'Alice']));
go(fn () => handleRequest('req-002', ['id' => 2, 'name' => 'Bob']));
});
运行输出(顺序可能因调度略有差异,但每个请求的数据始终自洽):
text
[req-001] 用户:Alice,开始查询订单
[req-002] 用户:Bob,开始查询订单
[req-001] 用户:Alice,订单查询完成
[req-002] 用户:Bob,订单查询完成
错误示范:用 static 变量存请求数据
php
<?php
use Swoole\Coroutine as Co;
class RequestContext
{
// 危险:静态变量在常驻进程中全局唯一,所有协程共享
public static string $requestId = '';
public static array $user = [];
}
function handleRequestBad(string $requestId, array $user): void
{
RequestContext::$requestId = $requestId;
RequestContext::$user = $user;
Co::sleep(0.01); // 让出,另一个协程会覆盖静态变量
echo "[{RequestContext::$requestId}] 用户:{RequestContext::$user['name']},开始查询订单\n";
Co::sleep(0.02);
echo "[{RequestContext::$requestId}] 用户:{RequestContext::$user['name']},订单查询完成\n";
}
Co\run(function () {
go(fn () => handleRequestBad('req-001', ['id' => 1, 'name' => 'Alice']));
go(fn () => handleRequestBad('req-002', ['id' => 2, 'name' => 'Bob']));
});
运行输出(数据串味,两个请求都变成了后写入的 Bob):
text
[req-002] 用户:Bob,开始查询订单
[req-002] 用户:Bob,开始查询订单
[req-002] 用户:Bob,订单查询完成
[req-002] 用户:Bob,订单查询完成
对比结论
- 静态变量是「进程级」的,常驻进程下所有协程共享同一份,一旦发生协程切换就会被覆盖,日志、鉴权、计费全部错乱;
- 协程上下文是「协程级」的,随协程创建而创建、随协程退出而销毁,天然隔离,无需手动清理;
- 如果确实需要跨协程共享数据,应显式使用
Swoole\Coroutine\Channel或连接池等受控结构,而不是图省事用static。
4. 常驻内存:能力与陷阱
Swoole 服务启动后,Worker 进程会常驻内存,不会像 FPM 那样每个请求结束就销毁。这带来了巨大的性能红利,也埋下了不少坑。
常驻内存下,请求与 Worker 生命周期的关系与 FPM 截然不同:
4.1 红利:连接复用与状态常驻
- 数据库、Redis 连接可以在 Worker 内复用,省去反复握手;
- 热点配置、路由表可以加载一次、长期驻留;
- 配合协程,单进程即可处理大量并发请求。
4.2 坑一:全局变量与静态变量跨请求污染
这是最常见、也最隐蔽的坑。在 FPM 下,static 变量和全局变量随请求结束而销毁;但在 Swoole 常驻进程里,它们会一直活着。
php
<?php
// 错误示范:静态变量在常驻进程中会跨请求累积
class Counter
{
public static int $count = 0;
public static function incr(): int
{
return ++self::$count;
}
}
如果每个请求都调用 Counter::incr(),返回值会一直累加,而不是每次从 1 开始。正确做法是:请求级数据一律放进协程上下文,或者每次请求显式重置。
4.3 坑二:内存泄漏被放大
FPM 下即使有轻微内存泄漏,进程也会在请求结束后被回收,问题不明显;常驻进程下,泄漏会随着请求量线性累积,最终 OOM。
常见泄漏源:
- 往全局数组里
push数据却从不清理; - 事件监听器反复注册、从不移除;
- 协程里持有大对象引用,协程结束后未释放。
排查手段:定期打印 memory_get_usage(true),观察是否随请求数单调上升。
实战:协程泄漏的定位与排查
内存泄漏里有一类特别隐蔽:协程泄漏 。协程创建后迟迟不退出,它持有的栈、上下文、连接、大对象就一直挂在内存里,memory_get_usage(true) 会随请求数单调上升,但用常规的内存排查手段又很难直接看出是谁没退出。
排查协程泄漏,核心是两件事:先看数量趋势,再抓调用栈。
第一步:用 Coroutine::stats() 观察协程数量随请求增长
Swoole\Coroutine::stats() 会返回当前 Worker 内协程的统计信息,其中 coroutine_num 就是当前存活的协程数。正常服务里它应该在一个稳定区间内波动(约等于并发请求数),如果它随请求数持续上涨、请求结束后也不回落,基本可以判定存在协程泄漏。
php
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
// 模拟一个「泄漏」的协程:从 Channel 里 pop 却没有超时,永远等不到数据
function leakyTask(Channel $ch): void
{
// 危险:pop 不设超时,Channel 永远没有数据 push 进来,协程永久挂起
$data = $ch->pop();
echo "拿到数据:{$data}\n";
}
// 模拟一个「正常」的协程:有超时,超时后主动退出
function normalTask(Channel $ch): void
{
$data = $ch->pop(0.1); // 0.1 秒超时,拿不到就返回 false 并退出
echo $data === false ? "正常协程超时退出\n" : "拿到数据:{$data}\n";
}
Co\run(function () {
$ch = new Channel(1);
// 每轮模拟一批请求,观察协程数量变化
for ($round = 1; $round <= 5; $round++) {
for ($i = 0; $i < 10; $i++) {
go(fn () => leakyTask($ch)); // 泄漏源
go(fn () => normalTask($ch)); // 对照组
}
// 给正常协程留出超时退出的时间
Co::sleep(0.2);
$stats = Co::stats();
echo "第 {$round} 轮:当前存活协程数 = {$stats['coroutine_num']}\n";
}
});
运行输出大致如下(正常协程每轮都退出,泄漏协程每轮累积 10 个):
text
正常协程超时退出
正常协程超时退出
...
第 1 轮:当前存活协程数 = 10
第 2 轮:当前存活协程数 = 20
第 3 轮:当前存活协程数 = 30
第 4 轮:当前存活协程数 = 40
第 5 轮:当前存活协程数 = 50
coroutine_num 每轮稳定 +10,就是最直观的泄漏信号。线上服务里可以把这个值定期打到监控里,配合告警阈值使用。
第二步:用 Co::getCid() 与 Co::getBackTrace() 打印调用栈,定位是谁没退出
只知道「有 50 个协程没退出」还不够,得知道它们卡在哪一行。思路是:在协程创建时记录它的 cid,排查时对每个存活的 cid 打印调用栈。
php
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
// 记录所有创建过的协程 ID
$cids = [];
function trackedTask(Channel $ch): void
{
global $cids;
$cid = Co::getCid(); // 当前协程 ID
$cids[$cid] = true;
$data = $ch->pop(); // 泄漏点:无超时
echo "拿到数据:{$data}\n";
unset($cids[$cid]); // 正常退出时移除
}
Co\run(function () {
global $cids;
$ch = new Channel(1);
for ($i = 0; $i < 5; $i++) {
go(fn () => trackedTask($ch));
}
Co::sleep(0.2);
$stats = Co::stats();
echo "当前存活协程数:{$stats['coroutine_num']}\n";
// 对每个仍存活的协程打印调用栈
foreach (array_keys($cids) as $cid) {
echo "---- 协程 {$cid} 的调用栈 ----\n";
// getBackTrace 需要传入目标协程 ID,返回该协程当前的调用栈数组
$trace = Co::getBackTrace($cid);
foreach ($trace as $frame) {
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? 0;
$func = $frame['function'] ?? 'unknown';
echo " {$file}:{$line} {$func}()\n";
}
}
});
输出会直接指出每个泄漏协程卡在 trackedTask() 的 $ch->pop() 这一行,问题一目了然。线上排查时,可以把这段逻辑做成一个定时任务或调试接口,在内存告警时触发一次快照。
常见协程泄漏场景与修复写法
| 场景 | 泄漏原因 | 修复写法 |
|---|---|---|
Channel pop 无超时 |
生产者异常或未启动,消费者协程永久挂起 | pop($timeout) 设超时,超时后 return 或重试 |
| 协程内死循环 | while (true) 里没有让出点或退出条件 |
加退出条件,循环内 Co::sleep() 让出,或用 Channel 控制生命周期 |
| 未捕获异常导致协程挂起 | 协程内抛异常未被捕获,后续逻辑不再执行,资源未释放 | 协程入口统一 try/catch/finally,finally 里释放资源 |
go() 创建后无人等待 |
父协程不关心子协程结果,子协程卡住无人知晓 | 用 WaitGroup 等待,或记录 cid 做超时监控 |
对应的修复写法示例:
php
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\WaitGroup;
Co\run(function () {
$ch = new Channel(1);
$wg = new WaitGroup();
// 修复 1:pop 设超时,避免永久挂起
$wg->add();
go(function () use ($ch, $wg) {
try {
$data = $ch->pop(1.0); // 1 秒超时
if ($data === false) {
echo "等待超时,协程主动退出\n";
return;
}
echo "拿到数据:{$data}\n";
} catch (\Throwable $e) {
// 修复 3:捕获异常,避免协程因未捕获异常而中断
echo "协程异常:{$e->getMessage()}\n";
} finally {
$wg->done(); // 无论成功失败都通知 WaitGroup
}
});
// 修复 2:死循环加退出条件与让出点
$wg->add();
go(function () use ($wg) {
$running = true;
while ($running) {
// 业务逻辑
Co::sleep(0.01); // 主动让出,避免卡死事件循环
$running = false; // 满足条件后退出
}
$wg->done();
});
// 修复 4:用 WaitGroup 等待所有子协程结束,避免「创建后无人管」
$wg->wait();
echo "所有协程已退出,当前存活协程数:" . Co::stats()['coroutine_num'] . "\n";
});
下面用一张流程图把整条排查决策路径串起来,从监控发现异常到定位阻塞点、再到对应修复:
这张图把排查拆成「发现 → 抓现场 → 定位 → 修复」四步:先用 Co::stats()['coroutine_num'] 判断是否存在泄漏,再记录 cid 并用 Co::getBackTrace() 抓取调用栈,从栈顶找到真正卡住的那一行,最后按阻塞类型套用对应的修复写法。核心原则是:所有阻塞调用都要有超时,所有协程入口都要有 try/catch/finally,所有 go() 都要有人等待或监控。
排查要点小结
- 先看趋势 :
Co::stats()['coroutine_num']随请求数单调上涨且不回落,就是协程泄漏的强信号; - 再抓现场 :记录
cid,用Co::getBackTrace($cid)打印调用栈,直接定位卡住的行号; - 从源头防 :所有阻塞调用都要有超时,所有协程入口都要有
try/catch/finally,所有go()都要有人等待或监控。
4.4 坑三:连接与资源的状态残留
常驻的连接如果在上一个请求里进入了异常状态(比如事务未提交、连接被服务端断开),下一个请求复用时会直接踩雷。建议:
- 使用连接池,并在取出连接时做健康检查;
- 请求结束时确保事务提交或回滚;
- 对超时、断连做重连兜底。
实战:协程 MySQL 连接池 + 取出时健康检查
下面用一个最小可运行的例子,演示连接池的创建、取出连接时的健康检查(ping 失败则重建)、以及请求结束后的归还。示例基于 Swoole\Coroutine\Channel 手写一个轻量连接池,便于看清每一步在做什么。
php
<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;
class MySQLPool
{
private Channel $pool;
private array $config;
public function __construct(array $config, int $size = 10)
{
$this->config = $config;
// 用 Channel 作为池容器,容量即最大连接数
$this->pool = new Channel($size);
for ($i = 0; $i < $size; $i++) {
$conn = $this->createConnection();
if ($conn !== null) {
$this->pool->push($conn);
}
}
}
private function createConnection(): ?MySQL
{
$conn = new MySQL();
$ok = $conn->connect([
'host' => $this->config['host'],
'port' => $this->config['port'],
'user' => $this->config['user'],
'password' => $this->config['password'],
'database' => $this->config['database'],
'timeout' => 1,
]);
return $ok ? $conn : null;
}
/**
* 取出连接:必须做健康检查
* 常驻进程下连接可能已被服务端断开(wait_timeout、网络抖动、主从切换),
* 直接复用会抛 "MySQL server has gone away",所以取出时先 ping 一次。
*/
public function get(float $timeout = 1.0): ?MySQL
{
$conn = $this->pool->pop($timeout);
if ($conn === false) {
return null; // 池空且超时
}
// ping 失败说明连接已失效,丢弃并重建一条
if (!$conn->connected || !$conn->query('SELECT 1')) {
$conn->close();
$conn = $this->createConnection();
}
return $conn;
}
/**
* 归还连接:请求结束时务必归还,否则池会被耗尽
*/
public function put(?MySQL $conn): void
{
if ($conn === null) {
return;
}
// 归还前确认连接仍可用,坏连接直接丢弃,避免污染池
if ($conn->connected && $conn->query('SELECT 1')) {
$this->pool->push($conn);
} else {
$conn->close();
$new = $this->createConnection();
if ($new !== null) {
$this->pool->push($new);
}
}
}
}
// 使用示例:模拟一个请求协程
$pool = new MySQLPool([
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'root',
'password' => '123456',
'database' => 'test',
], 10);
Co\run(function () use ($pool) {
$conn = $pool->get();
if ($conn === null) {
echo "获取连接失败\n";
return;
}
try {
$rows = $conn->query('SELECT * FROM users LIMIT 1');
echo "查询结果条数:" . count($rows ?: []) . "\n";
} finally {
// 无论成功失败都要归还,防止连接泄漏
$pool->put($conn);
}
});
为什么常驻进程下必须做健康检查?
- 连接是长生命周期的 :FPM 下连接随请求结束而关闭,坏连接不会留到下一次;常驻进程里连接可能存活数小时,期间服务端的
wait_timeout、主从切换、网络中断都会让它悄悄失效。 - 失效连接不会自己报错 :连接对象看起来还在,但底层 socket 已断,直到真正执行 SQL 才抛
MySQL server has gone away,此时业务已经进入处理流程,回滚成本高。 - 健康检查把故障前置 :在取出连接时
ping一次,成本极低(一次轻量往返),却能把「坏连接」挡在业务逻辑之外,失败就重建,保证交给业务的连接一定是可用的。 - 归还时也要检查:请求过程中连接可能因超时或异常进入不可用状态,归还前再确认一次,避免把坏连接塞回池里污染后续请求。
4.5 坑四:协程间的共享状态竞争
虽然协程是单线程调度,不会出现真正的「同时写」,但交错执行依然会导致逻辑错误。例如两个协程都执行「读-改-写」:
php
<?php
Co\run(function () {
$shared = 0;
go(function () use (&$shared) {
$tmp = $shared;
Co::sleep(0.01); // 让出,另一个协程可能插进来
$shared = $tmp + 1;
});
go(function () use (&$shared) {
$tmp = $shared;
Co::sleep(0.01);
$shared = $tmp + 1;
});
});
最终 $shared 可能是 1 而不是 2。解决办法是使用 Swoole\Coroutine\Channel 或 Swoole\Lock 做同步,或者干脆避免共享可变状态。
用一张图看清「读-改-写」交错执行导致的结果偏差:
5. Swoole 协程 vs Go goroutine
两者都是用户态轻量级执行流,但设计取舍不同。下面从几个关键维度对比。
| 维度 | Swoole 协程 | Go goroutine |
|---|---|---|
| 调度方式 | 协作式,遇 IO 主动让出 | 抢占式(Go 1.14+ 基于信号的异步抢占) |
| 运行载体 | 单线程事件循环 | GMP 模型,多线程并行 |
| 并行能力 | 单 Worker 内不并行,靠多进程扩展 | 天然多核并行 |
| 栈大小 | 固定栈(stack_size 可配置,默认 2MB) |
动态伸缩栈,初始 2KB 起 |
| 通信方式 | Channel、协程上下文 | Channel、sync 包 |
| CPU 密集任务 | 会阻塞整个 Worker | 可被抢占,影响较小 |
5.1 调度:协作式 vs 抢占式
Swoole 协程是协作式的,一段死循环会卡死整个 Worker:
php
<?php
Co\run(function () {
go(function () {
while (true) {
// 纯 CPU 计算,没有让出点
}
});
go(function () {
echo "这个协程永远得不到执行\n";
});
});
Go 从 1.14 开始引入基于信号的异步抢占,长时间运行的 goroutine 会被强制打断,因此上面的场景在 Go 里不会饿死其他 goroutine。需要注意,抢占只发生在函数调用等安全点附近,且 Go 1.14 的抢占能力仍有限,直到后续版本才逐步完善。 实践建议 :Swoole 中避免在协程里做长时间 CPU 密集计算,必要时拆分成小段并主动 Co::sleep(0) 让出,或交给独立进程处理。
5.2 并行:单线程 vs 多核
Swoole 的协程跑在单线程事件循环上,同一 Worker 内的协程不会真正并行,只能通过多开 Worker 进程来利用多核。Go 的 GMP 模型则把 goroutine 分配到多个 P(逻辑处理器)上,由多个 M(系统线程)并行执行。
这意味着:
- Swoole 更适合 IO 密集型场景(网关、API、长连接推送);
- Go 在 CPU 密集型 + 高并发混合场景下更有优势。
两者的并行模型差异可以这样理解:
5.3 栈与内存占用
Swoole 协程使用固定大小的栈(由 stack_size 配置,默认 2MB),创建成本低但不够灵活,递归过深或局部变量过大时可能栈溢出;Go 的 goroutine 栈是动态伸缩的,初始约 2KB,按需增长与收缩,更适合递归深度不确定的场景。
5.4 心智模型的差异
- 写 Swoole 协程,要时刻记得「不要阻塞事件循环」,任何同步阻塞调用都可能拖垮整个 Worker;
- 写 Go goroutine,更多是「放心开,调度器会管」,但仍需注意数据竞争和 goroutine 泄漏。
6. 最佳实践清单
结合上面的分析,给出几条落地建议:
- 请求级数据放协程上下文,不要用全局变量或静态变量存请求状态;
- 避免在协程中做长耗时 CPU 计算,必要时拆分让出或交给独立进程;
- 使用连接池并做健康检查,防止复用坏连接;
- 监控内存曲线,常驻进程的内存泄漏必须尽早发现;
- 共享可变状态加锁或用 Channel,不要假设单线程就没有竞争;
- 理解协作式调度的边界,任何同步阻塞函数都是潜在的单点故障。
7. 总结
Swoole 的协程模型本质上是「单线程事件循环 + 用户态协作式调度」,它用极低的切换成本换来了高并发 IO 能力,但也要求开发者对常驻内存和阻塞操作保持警惕。
与 Go goroutine 相比:
- 相同点:都是用户态轻量执行流,都用 Channel 通信,都追求「同步写法、异步性能」;
- 不同点:Swoole 是协作式、单线程、靠多进程扩展;Go 是抢占式、多线程、天然多核并行。
理解了这些差异,才能在选型和编码时避开常驻内存的坑,把协程的威力真正发挥出来。