使用 PHP TrueAsync 改造 Laravel 协程异步化的可行路径
Laravel 的核心设计建立在传统的 request-per-process 模型之上:一个请求由一个独立进程处理。这个模型下,框架内部大量使用单例、静态变量和对象属性保存运行时状态,通常不会产生明显问题。
但在 PHP TrueAsync 这类协程运行时中,情况发生了变化。
TrueAsync 允许单个进程同时承载数百个请求。如果缺少适配,Laravel 原本安全的“进程级状态”就可能变成“所有请求共享的状态”,进而导致请求之间发生数据泄漏。
核心问题不是让 Laravel 跑在协程环境中,而是确保每个请求只能看到属于自己的状态。
社区中常见的判断是:将 Laravel 适配到并发运行环境,需要投入大量工作,并伴随大范围代码修改。事实是否如此?
本文以 laravel-spawn 为观察对象,分析核心包适配所需的改动量,并讨论 Laravel 实现协程异步化的可能路径。
一、Laravel 在协程环境中的核心风险
在传统同步模型中,一个请求结束后,进程也会被释放或进入干净状态。即使服务对象内部保存了当前请求的状态,也不会影响下一个请求。
但在协程模型中,一个进程会同时处理多个请求。这意味着以下状态如果继续存放在共享对象、静态属性或单例中,就可能在请求之间互相污染:
- 当前请求对象;
- 当前 Session;
- 当前认证用户;
- 当前路由;
- 当前 Locale;
- 延迟事件队列;
- 数据库事务计数器;
- Facade 已解析实例缓存;
- 服务对象内部的可变属性。
因此,Laravel 协程化的关键不是重写框架,而是解决一个问题:
如何把“请求级状态”从“进程级共享对象”中剥离出来。
二、两条改造路径
处理这个问题,大致有两条思路。
路径一:为每个请求创建新的服务实例
第一种方式最直接:
不再让所有请求共享同一个服务对象,而是让容器在每个请求内重新创建服务实例。
服务仍然通过依赖注入容器解析,只是容器不再跨请求复用同一个对象。
请求 A → AuthManager A
请求 B → AuthManager B
请求 C → AuthManager C
这种方式的优点非常明显:
优点:无需大规模修改服务代码。
原有服务仍然使用对象属性保存状态,只要这些对象是请求级实例,就不会发生跨请求泄漏。
但它也有明显缺点:
缺点:如果服务内部缓存了大量跨请求数据,内存占用会膨胀。
例如某些服务原本作为单例存在,是为了缓存配置、解析结果或元数据。如果每个请求都重新创建这些服务,内存使用和初始化成本都会上升,异步化带来的收益也会被部分抵消。
因此,这条路径适合用来快速完成初步适配,尤其适合那些状态复杂、短期内难以拆分的服务。
路径二:保留共享服务,将可变状态移入上下文
第二种方式更精细,也更符合协程运行时的设计。
服务对象仍然可以作为所有请求共享的单例存在,但它的可变状态不再放在对象属性中,而是放入请求级上下文。
共享服务对象
├── 不变逻辑:继续保留在服务实例中
└── 可变状态:移入请求 Scope Context
例如:
- 当前请求对象;
- 当前 Session;
- 当前认证状态;
- 当前路由;
- 当前 Locale;
- 当前 defer 队列。
这些都不应该继续作为共享单例的属性存在,而应该进入当前请求的上下文中。
更优的方向是:共享不可变逻辑,隔离可变状态。
这条路径的优点是内存更稳定,也更适合长期维护。缺点是需要识别框架中所有可能发生状态泄漏的位置,并逐一适配。
三、TrueAsync 的两层上下文模型
理解 TrueAsync 的关键,在于它提供了两层上下文。
1. Coroutine Context:协程级上下文
Coroutine Context 是单个协程的私有存储。
它适合保存只属于当前协程的数据,例如:
- 当前协程使用的数据库事务计数器;
- 当前协程的临时状态;
- 不应该被同一请求内其他协程共享的数据。
也就是说:
Coroutine Context 解决的是“同一请求内多个协程之间的隔离问题”。
2. Scope Context:作用域级上下文
Scope Context 是一组协程共享的上下文。Scope 可以形成层级结构,子 Scope 会继承父 Scope 中的内容。
服务器可以先创建一个全局的 Server Scope,然后每个请求再基于它创建自己的 Request Scope:
$requestScope = Scope::inherit($serverScope);
这样,请求 Scope 会继承服务器层级设置的共享内容,同时又可以保存只对当前请求可见的数据。
例如:
current_context()->set(ScopedService::REQUEST, $request);
current_context()->set(ScopedService::SESSION, $session);
current_context()->set(ScopedService::AUTH, $auth);
在 Controller、Middleware 或嵌套协程中,都可以通过当前上下文读取这些数据:
$request = current_context()->find(ScopedService::REQUEST);
这意味着,如果某个请求内部启动了多个嵌套协程,例如并行查询数据库,这些协程仍然可以访问父请求 Scope Context 中的 request、session、auth 等数据。
同时,它们又与其他请求的 Scope Context 完全隔离。
Server Scope
├── Request Scope A
│ ├── Coroutine A1
│ └── Coroutine A2
│
└── Request Scope B
├── Coroutine B1
└── Coroutine B2
Scope Context 解决的是“不同请求之间的状态隔离问题”。
四、laravel-spawn 的整体策略
laravel-spawn 并没有选择单一方案,而是结合了两条路径:
- 对部分服务使用请求级实例,避免共享可变状态;
- 对部分服务保留单例,但将可变状态移动到 Scope Context;
- 对 Laravel Facade 使用代理对象,避免静态缓存真实服务实例;
- 对隐藏较深的状态,通过 Trait 局部替换相关行为;
- 对数据库事务计数器等协程级状态,使用 Coroutine Context 隔离。
这种方式的核心优势在于:
不需要重写 Laravel,也不需要推翻现有服务体系,而是针对状态泄漏点做局部适配。
五、使用枚举作为上下文键
Scope Context 本质上是一个键值存储。为了避免字符串键冲突,可以使用对象作为键。
PHP 的枚举,也就是 Enum,本身是对象,非常适合作为上下文键。
enum ScopedService: string
{
case REQUEST = 'request';
case SESSION = 'session';
case AUTH = 'auth';
case AUTH_DRIVER = 'auth.driver';
case COOKIE = 'cookie';
}
写入当前请求对象:
current_context()->set(ScopedService::REQUEST, $request);
在代码任意位置读取:
$request = current_context()->find(ScopedService::REQUEST);
这种设计有三个好处。
1. 避免字符串键冲突
如果使用字符串键,不同模块可能不小心使用同一个键名:
'request'
'session'
'auth'
但使用 Enum 后,即使两个枚举的 backed value 相同,它们仍然是不同的对象键。
ScopedService::REQUEST
SomeOtherEnum::REQUEST
即使二者的值都是 'request',也不会互相覆盖。
不同 Enum case 是不同对象,不会因为字符串值相同而冲突。
2. 提高访问边界清晰度
使用公开 Enum 并不是安全边界,但它能显著减少误读、误写和误覆盖。
如果上下文键是普通字符串,任何代码都可以随手写入:
current_context()->set('session', $value);
而使用 Enum 后,代码必须显式依赖对应的枚举定义:
current_context()->set(ScopedService::SESSION, $session);
这让上下文访问变得更加可控,也更容易审查。
3. 对静态分析更友好
Enum 键可以被 IDE、PHPStan 或 Psalm 追踪。
例如搜索:
ScopedService::AUTH
就可以直接找到所有读取或写入认证状态的位置。
相比字符串键,Enum 键更适合长期维护。
对于协程适配来说,可追踪性非常重要。因为状态泄漏问题往往不是语法错误,而是运行时行为错误。
六、Facade 静态缓存带来的问题
Laravel Facade 有一个重要优化:它会把已经解析过的实例缓存到静态数组中。
例如调用:
Auth::user();
Laravel 并不会每次都执行:
$app->make('auth');
第一次访问时,Facade 会把解析结果保存到静态属性中:
static::$resolvedInstance['auth']
后续调用会直接复用这个实例。
在传统同步模型中,这是合理优化。因为一个进程只处理一个请求,请求结束后状态自然清理。
但在协程模型中,这会变成严重问题。
请求 A 首次调用 Auth::user()
→ Facade 缓存 AuthManager A
请求 B 调用 Auth::user()
→ Facade 直接复用 AuthManager A
如果 AuthManager 内部保存了当前用户、Guard 或 Session 状态,请求 B 就可能拿到请求 A 的认证信息。
Facade 的问题不在于静态调用本身,而在于它会静态缓存真实服务实例。
七、使用 ScopedServiceProxy 屏蔽 Facade 缓存
laravel-spawn 使用代理对象解决这个问题。
Facade 可以继续缓存对象,但缓存的不再是真实服务,而是一个代理。
class ScopedServiceProxy
{
public function __construct(
private readonly \Closure $resolver,
) {
}
public function __call(string $method, array $args): mixed
{
return ($this->resolver)()->$method(...$args);
}
public function __get(string $property): mixed
{
return ($this->resolver)()->$property;
}
}
这个代理本身可以被 Facade 安全缓存。
关键在于:
每次调用方法时,它都会重新执行 $resolver。
而 $resolver 会通过 current_context() 从当前请求的 Scope Context 中取出真正的服务实例。
Facade 缓存的对象:ScopedServiceProxy
真实服务实例:每次从当前 Scope Context 解析
这样一来,Facade 的缓存机制仍然存在,但它缓存的是无状态代理,而不是带有请求状态的服务对象。
八、AsyncApplication 中的替换逻辑
在 Laravel 中,Facade 通过容器读取服务。laravel-spawn 在 AsyncApplication::offsetGet() 中加入了替换逻辑。
public function offsetGet($key): mixed
{
if ($this->asyncMode) {
$alias = $this->getAlias($key);
if (isset(self::FACADE_PROXIED_MAP[$alias])) {
return new ScopedServiceProxy(
fn () => $this->tryResolveScoped($alias)
);
}
}
return parent::offsetGet($key);
}
其中,FACADE_PROXIED_MAP 只包含需要代理的服务,例如:
'auth'
'session'
也就是说,只有通过 Auth:: 和 Session:: 这类 Facade 访问的高风险服务才会被代理。
这是一个低侵入改造:不修改业务代码,也不修改 Facade 调用方式,只替换 Facade 最终拿到的对象。
九、Auth::user() 的完整调用链
适配后,Auth::user() 的调用链变成:
Auth::user()
→ Facade::__callStatic('user')
→ static::$resolvedInstance['auth']
→ ScopedServiceProxy
→ proxy->__call('user')
→ resolver()
→ tryResolveScoped('auth')
→ current_context()->find(...)
→ 当前请求的 AuthManager
→ $authManager->user()
两个并行请求同时调用:
Auth::user();
它们命中的可能是同一个 ScopedServiceProxy,但每次调用时,代理都会从当前协程所属的 Scope Context 中解析真实服务。
请求 A → ScopedServiceProxy → Request Scope A → AuthManager A
请求 B → ScopedServiceProxy → Request Scope B → AuthManager B
Facade 仍然可以缓存,但缓存的是代理;真实服务始终从当前请求上下文中获取。
这正是代理模式在 Laravel 协程适配中的价值。
十、使用 Trait 局部替换状态相关行为
并不是所有状态泄漏都适合通过代理或重新实例化解决。
有些服务会把可变状态隐藏在类的内部属性、静态数组或深层方法中。如果为了一个属性替换整个类,成本会非常高。
这种情况下,可以使用 Trait 局部替换相关方法。
Trait 的作用是:
只替换与状态相关的行为,保留原类其余逻辑。
这种策略尤其适合 Laravel 内部复杂类。它能在尽量少改代码的前提下,把高风险状态迁移到协程上下文中。
十一、数据库事务计数器的协程隔离
一个典型例子是数据库连接中的事务计数器。
TrueAsync 中的 PDO Pool 会为每个协程提供自己的物理数据库连接。但 Laravel 的 Connection 类会把嵌套事务计数器保存在对象属性中:
$this->transactions
如果多个协程共享同一个 Connection 实例,就会出现问题。
协程 A 开启事务
→ $this->transactions = 1
协程 B 查询 transactionLevel()
→ 也看到 1
但协程 B 实际上并没有开启事务。
这就是典型的共享对象状态泄漏。
十二、CoroutineTransactions 的处理方式
laravel-spawn 通过 CoroutineTransactions Trait 将事务计数器移入 Coroutine Context。
trait CoroutineTransactions
{
private const CTX_TRANSACTIONS = 'db.transactions';
public function transactionLevel()
{
if ($this->asyncTransactions) {
return coroutine_context()->find(self::CTX_TRANSACTIONS) ?? 0;
}
return $this->transactions;
}
private function setTransactionLevel(int $level): void
{
if ($this->asyncTransactions) {
$ctx = coroutine_context();
$ctx->set(self::CTX_TRANSACTIONS, $level, replace: true);
} else {
$this->transactions = $level;
}
}
}
这个 Trait 会拦截以下方法:
transactionLevel();beginTransaction();commit();rollBack();- 事务错误处理相关方法。
每个方法都不再直接依赖 $this->transactions,而是通过 coroutine_context() 读取当前协程自己的事务状态。
这里需要特别注意:
事务计数器应该绑定到 Coroutine Context,而不是 Scope Context。
原因是 Scope Context 用于请求级共享状态,而事务计数器与当前协程使用的物理数据库连接相关。
如果一个请求内部启动多个并行协程,每个协程都可能从连接池中获取不同的物理连接。此时事务状态应该跟随协程,而不是跟随整个请求。
Request Scope
├── Coroutine A → PDO Connection A → transactionLevel A
└── Coroutine B → PDO Connection B → transactionLevel B
因此,事务计数器使用 coroutine_context() 是更合理的选择。
十三、推荐的 Laravel 协程化改造顺序
结合 laravel-spawn 的实践,比较现实的改造路径可以分为五步。
第一步:识别可变状态
优先扫描以下位置:
- 静态属性写入;
- 单例服务中的可变属性;
- Facade 已解析实例缓存;
- 保存 request、session、auth、route、locale 的对象;
- 数据库连接、事务、事件队列等运行时状态。
这一阶段可以借助 PHPStan 自定义规则,检测对可变静态属性的写入。
协程适配的第一步不是改代码,而是找出所有可能被请求共享的可变状态。
第二步:为请求创建 Scope Context
每个请求进入时,创建独立的请求 Scope,并将请求级数据写入其中。
典型数据包括:
current_context()->set(ScopedService::REQUEST, $request);
current_context()->set(ScopedService::SESSION, $session);
current_context()->set(ScopedService::AUTH, $auth);
这样,Controller、Middleware、事件监听器和嵌套协程都可以通过当前上下文读取请求数据。
第三步:处理 Facade 缓存
对高风险 Facade 使用代理对象,例如:
Auth;Session;Cookie;- 其他持有请求状态的服务。
核心原则是:
Facade 可以缓存代理,但不能缓存请求级真实服务。
第四步:对深层状态使用 Trait 局部替换
对于无法简单拆分的框架内部类,可以使用 Trait 替换少量关键方法。
例如数据库事务计数器:
$this->transactions
可以迁移到:
coroutine_context()
这样既能避免重写整个类,又能隔离协程状态。
第五步:用并发测试验证状态隔离
协程适配不能只靠单元测试,还需要构造并发场景。
重点测试:
- 请求 A 的用户不会泄漏到请求 B;
- 请求 A 的 Session 不会影响请求 B;
- 并发数据库事务互不干扰;
- Locale、路由、Cookie、事件队列不会串请求;
- 嵌套协程能正确继承父请求上下文;
- 请求结束后上下文能被释放。
协程问题通常不是“功能不可用”,而是“高并发下偶发串状态”。测试必须围绕状态隔离设计。
十四、异步化的真正收益
协程异步化并不会让 PHP 代码本身执行得更快。
它提升吞吐量的关键在于:
当一个请求正在等待数据库、Redis、HTTP API 或文件 I/O 时,当前进程可以切换去处理其他请求。
在传统同步模型中,一个请求的执行时间可能大部分都消耗在等待 I/O 上。
例如某个请求总耗时 28 毫秒,其中 23 毫秒都在等待数据库响应。同步模型下,这 23 毫秒内 worker 基本处于空转状态。
在协程模型中,这段等待时间可以用来处理其他请求。
同步模型:
worker 等待 I/O → 空转
协程模型:
协程 A 等待 I/O → worker 切换处理协程 B
因此,异步化带来的收益主要体现在:
- 更少的 worker;
- 更低的内存占用;
- 更高的并发承载能力;
- 更少的服务器资源;
- I/O 密集型场景下更高的吞吐量。
异步化优化的不是单个 PHP 函数的执行速度,而是整个进程在 I/O 等待期间的利用率。
十五、结论
Laravel 虽然基于同步的 request-per-process 模型设计,但这并不意味着它无法适配协程运行环境。
真正需要解决的问题,是将请求级可变状态从进程级共享对象中剥离出来。
laravel-spawn 展示了一条现实路径:
- 对部分服务使用请求级实例;
- 对部分服务保留单例,但将状态移入 Scope Context;
- 使用 Enum 作为上下文键,减少键冲突并提升可维护性;
- 使用代理对象解决 Facade 静态缓存问题;
- 使用 Trait 局部替换深层状态逻辑;
- 使用 Coroutine Context 隔离协程级状态,例如数据库事务计数器;
- 使用静态分析和并发测试发现潜在泄漏点。
这说明,Laravel 协程化并不一定意味着重写框架,也不一定需要大规模修改业务代码。
更准确地说,它是一项明确的工程适配工作。
当运行时提供合适的原语,例如 Coroutine Context、Scope Context、Scope 继承和原生连接池时,Laravel 的协程异步化就不再是架构重写问题,而是状态隔离问题。
TrueAsync 0.7.0 计划内置开箱即用的 request_context() 支持,用于提升相关代码路径性能,并进一步简化框架适配。
如果这些基础能力逐步成熟,Laravel 与异步运行时之间的主要障碍,将不再是框架本身,而是工具链、适配层和状态管理规范。