协程环境与常驻内存
Viswoole 运行在 Swoole 常驻内存 + 协程(Coroutine)环境中。这带来两件事:一是极高的并发能力——一个 Worker 进程内成千上万个协程交错处理请求;二是一套与传统 PHP-FPM 完全不同的状态管理规则。本文解释框架的协程模型,并给出常驻内存环境下的开发红线——理解它们是写出正确业务代码的前提。
本文依据框架源码
src/Core/Coroutine.php、src/Core/Coroutine/Context.php、src/Core/Channel/ConnectionPool.php、src/Core/Channel/ChannelManager.php、src/Core/Container.php、src/Database/ConnectManager.php编写。
Swoole 协程基础
协程是用户态的轻量线程:调度发生在函数层面而非操作系统层面,切换成本极低。Swoole 的关键能力是 IO 自动让出——协程内执行 MySQL 查询、Redis 命令、HTTP 调用等阻塞式 IO 时,底层自动切换执行其他协程,IO 完成后再切回来。对业务代码而言这就是「同步的写法、异步的效率」:
use Swoole\Coroutine;
Coroutine::create(function () { // 创建一个协程
$user = Db::table('user')->find(1); // IO 等待期间,其他请求的协程继续运行
Cache::set('user:1', $user, 3600); // 协程自动调度,无需回调
});在 HTTP 服务中,每个请求由 Swoole 在独立的根协程中处理:请求进入时创建,响应完成后销毁。请求处理链路中再通过 go()/Coroutine::create() 创建的协程是它的子协程,形成一棵协程树。协程之间存在父子关系,这一结构是框架隔离机制的坐标系统。
Coroutine 增强类
Viswoole\Core\Coroutine 继承 Swoole\Coroutine,在此之上补充了非协程环境兼容能力与根协程定位能力:
| 方法 | 签名 | 说明 |
|---|---|---|
getContext | getContext(int $cid = 0): ?Context | 获取协程上下文;非协程环境返回虚拟上下文($mock_context),调用方无需判空 |
isCoroutine | isCoroutine(): bool | 当前是否处于协程环境(getCid() !== -1) |
id | id(): int | 当前协程 ID,非协程环境返回 -1 |
getTopId | getTopId(?int $cid = null, bool $unableToFindReturnSelfId = true): false|int | 沿协程树向上迭代查找根协程 ID;找无父协程的根节点,找不到时按参数返回自身 ID 或 false |
getTopId() 是整个隔离体系的基石:无论业务代码在协程树的哪一层执行,getTopId() 都能定位到该请求链路的根协程。实现采用迭代而非递归,避免深层协程栈溢出。
上下文辅助类 Context
Viswoole\Core\Coroutine\Context 封装协程上下文的键值读写,支持指定协程 ID 操作:
| 方法 | 说明 |
|---|---|
set(string $key, mixed $value, int $id = 0) | 写入键值($id 为 0 表示当前协程) |
get(string $key, mixed $default = null, int $id = 0) | 读取键值 |
has(string $key, int $id = 0) | 判断键是否存在 |
remove(string $key, int $id = 0) | 移除键 |
copy(int $fromCoroutineId, array $keys = [], bool $merge = false) | 将源协程上下文拷贝到当前协程(可选仅拷贝指定键、合并或覆盖) |
协程上下文的生命周期与协程绑定:协程结束,上下文连同其中所有数据一并销毁。容器、数据库事务都建立在这个保证之上。
请求链路内传递数据
协程上下文是在请求链路内透传数据的天然载体:根协程写入的数据,其全部子协程共享;请求结束自动清理,不会跨请求泄漏。
use Viswoole\Core\Coroutine\Context;
use Viswoole\Core\Coroutine;
// 中间件中写入当前请求的追踪 ID
Context::set('trace_id', uniqid('req_'));
// 请求链路内的任意位置(控制器、服务层、子协程)读取
$traceId = Context::get('trace_id');
// 子协程默认无法拿到根协程的上下文引用,需要拷贝
Coroutine::create(function () {
Context::copy(Coroutine::getTopId()); // 从根协程拷贝全部上下文到当前协程
$traceId = Context::get('trace_id'); // 此时可以读到
});与容器的请求级单例同理:写入协程上下文的数据以请求根协程为生命周期边界,替代全局变量或静态属性存放请求级状态。
连接池与通道
协程并发下不能每请求新建数据库连接(连接数会随并发失控),框架用连接池统一管理。
ConnectionPool:通用连接池
Viswoole\Core\Channel\ConnectionPool 是抽象基类,基于 Swoole\Coroutine\Channel 实现连接的借出、归还、填充与销毁。工作模型:
pop()/get() 借出连接 put() 归还连接
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ Channel(协程安全的队列) │
│ 池内闲置连接数 ≤ max_size(默认 DEFAULT_SIZE = 10)│
└──────────────────────────────────────────┘
借出时检测连接可用性,不可用则丢弃重建;
池空时按需新建;已达容量时协程挂起等待归还要点:
- 容量按总连接数计:已借出 + 池内闲置的总和受
max_size约束,高并发借出期不会无限新建连接; - 健康检测:借出与归还时调用子类实现的
connectionDetection()检测连接可用性,失效连接被关闭并补建; - 子类扩展点:实现
createConnection()(创建连接)、closeConnection()(关闭连接)、connectionDetection()(可用性检测)三个抽象方法即可获得完整的池化能力; - 非协程环境兼容:CLI 场景下
pop()直接新建连接、put()显式关闭连接,避免长循环脚本积压连接。
Viswoole\Core\Channel\ChannelManager 是连接池管理基类:管理多个命名的连接池通道(addChannel/getChannel/setDefaultChannel/hasChannel),并通过对默认通道的代理调用(__call)暴露 get()/put() 等池操作。数据库的 DbManager、缓存的 Redis 驱动都基于这套机制构建。
连接对业务代码透明
业务代码从不直接接触连接:Db::table()、Cache::set() 每次从池借出连接、用完归还。事务期间通过协程上下文中的 ConnectManager 复用同一连接,事务状态按协程隔离——并发请求各自的事务互不干扰。
常驻内存红线
传统 PHP-FPM 每请求加载一切、请求结束释放一切;Swoole Worker 进程常驻内存、跨请求复用。以下红线均来自框架源码的实际行为,违反会导致跨请求数据污染或资源泄漏。
1. 禁止用 static 属性保存请求级状态
Worker 进程常驻,类的静态属性在请求之间不会被重置。第一条请求写入的静态状态会被第二条请求读到:
class CurrentUser
{
private static ?array $user = null; // ❌ 危险:所有请求共享
public static function set(array $user): void
{
self::$user = $user; // 请求 A 设置的用户,请求 B 也能读到
}
}请求级状态的正确去处是协程上下文或容器单例(两者都按请求隔离)。对比框架自身的行为:容器缓存反射元数据($classMetaCache 等),注释明确这些是「进程级只读元数据缓存」——只读不变的数据才能跨请求共享,有请求语义的状态一律隔离存放。
2. 全局服务是进程级长生命周期对象
log、cache、db、router 等全局服务在进程启动时注册(App::loadService() 仅执行一次),整个进程生命周期复用。不要在这些服务对象上挂请求级属性;config()->set() 的修改也仅进程内有效、随重启丢失。
3. 连接由连接池管理,勿手动缓存连接
数据库与 Redis 连接由连接池按需借出、用完归还(见上文)。手动把连接存到全局变量或类属性会破坏这套机制:连接在协程挂起期间被其他协程借走、或已被检测回收,缓存的引用就会失效或串用。需要复用连接语义(如事务)时,框架已通过协程上下文实现——事务内的多条语句自动走同一连接(ConnectManager 以 '$_db_transaction' 为键按协程存取),直接使用 Db::startTransaction() 即可。
4. 进程级初始化逻辑挂在 workerStart
每个 Worker 进程是独立进程,主进程创建的资源无法共享给 Worker。框架连接池的初始填充就挂载在 workerStart 事件中——源码注释明确了原因:start 事件在主进程(master)触发,此时 Worker 已 fork,主进程填充的连接无法被 Worker 使用。同理:
- 每个 Worker 都要执行的初始化(注册通道、添加事件监听)使用
ServerEventHook::addEvent('workerStart', ...)或监听AppInitialized事件; DbManager::addChannel、LogManager::addChannel等修改服务配置的操作必须在服务启动前调用(如config/listens.php或服务提供者的boot()中)。
5. 修改代码需重启进程
业务代码、配置、路由、服务提供者都在进程启动阶段一次性加载,之后常驻内存。修改后必须重启服务(php viswoole server:close + php viswoole server:start)才会生效;PHP 不会像 FPM 那样在每个请求重新读取文件。路由缓存版本不兼容时执行 php viswoole router:clear-cache 清理。
协程阻塞
协程是协作式调度:sleep()、pdo 同步扩展、CPU 密集循环等阻塞操作会卡住整个 Worker 进程上的所有请求。耗时 IO 使用 Swoole 协程客户端(框架的 Db/Cache/Redis 驱动均已协程化),CPU 密集任务投递给异步任务(Task)处理。
use Swoole\Coroutine;
// ❌ 阻塞整个 Worker 进程,期间所有请求停摆
sleep(5);
// ✅ 只挂起当前协程,其他请求的协程正常调度
Coroutine::sleep(5);判断标准
写完一段代码后问自己:这段代码在「同一个 Worker 进程内的第 10001 个请求」中执行时,状态是否依然正确?答案取决于状态放在哪里——协程上下文与容器单例安全,static 属性与全局变量危险。
