协程环境与常驻内存

Viswoole 运行在 Swoole 常驻内存 + 协程(Coroutine)环境中。这带来两件事:一是极高的并发能力——一个 Worker 进程内成千上万个协程交错处理请求;二是一套与传统 PHP-FPM 完全不同的状态管理规则。本文解释框架的协程模型,并给出常驻内存环境下的开发红线——理解它们是写出正确业务代码的前提。

本文依据框架源码 src/Core/Coroutine.phpsrc/Core/Coroutine/Context.phpsrc/Core/Channel/ConnectionPool.phpsrc/Core/Channel/ChannelManager.phpsrc/Core/Container.phpsrc/Database/ConnectManager.php 编写。

Swoole 协程基础

协程是用户态的轻量线程:调度发生在函数层面而非操作系统层面,切换成本极低。Swoole 的关键能力是 IO 自动让出——协程内执行 MySQL 查询、Redis 命令、HTTP 调用等阻塞式 IO 时,底层自动切换执行其他协程,IO 完成后再切回来。对业务代码而言这就是「同步的写法、异步的效率」:

php
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,在此之上补充了非协程环境兼容能力与根协程定位能力:

方法签名说明
getContextgetContext(int $cid = 0): ?Context获取协程上下文;非协程环境返回虚拟上下文$mock_context),调用方无需判空
isCoroutineisCoroutine(): bool当前是否处于协程环境(getCid() !== -1
idid(): int当前协程 ID,非协程环境返回 -1
getTopIdgetTopId(?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)将源协程上下文拷贝到当前协程(可选仅拷贝指定键、合并或覆盖)

协程上下文的生命周期与协程绑定:协程结束,上下文连同其中所有数据一并销毁。容器、数据库事务都建立在这个保证之上。

请求链路内传递数据

协程上下文是在请求链路内透传数据的天然载体:根协程写入的数据,其全部子协程共享;请求结束自动清理,不会跨请求泄漏。

php
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 实现连接的借出、归还、填充与销毁。工作模型:

text
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 进程常驻,类的静态属性在请求之间不会被重置。第一条请求写入的静态状态会被第二条请求读到:

php
class CurrentUser
{
    private static ?array $user = null; // ❌ 危险:所有请求共享

    public static function set(array $user): void
    {
        self::$user = $user; // 请求 A 设置的用户,请求 B 也能读到
    }
}

请求级状态的正确去处是协程上下文或容器单例(两者都按请求隔离)。对比框架自身的行为:容器缓存反射元数据($classMetaCache 等),注释明确这些是「进程级只读元数据缓存」——只读不变的数据才能跨请求共享,有请求语义的状态一律隔离存放。

2. 全局服务是进程级长生命周期对象

logcachedbrouter 等全局服务在进程启动时注册(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::addChannelLogManager::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)处理。

php
use Swoole\Coroutine;

// ❌ 阻塞整个 Worker 进程,期间所有请求停摆
sleep(5);

// ✅ 只挂起当前协程,其他请求的协程正常调度
Coroutine::sleep(5);

判断标准

写完一段代码后问自己:这段代码在「同一个 Worker 进程内的第 10001 个请求」中执行时,状态是否依然正确?答案取决于状态放在哪里——协程上下文与容器单例安全,static 属性与全局变量危险。

下一步