Laravel 协程异步化改造的核心路径
Laravel 的设计基于 request-per-process 模型:一个请求对应一个进程。框架内部存在大量单例和静态变量。TrueAsync 通过协程让单个进程同时承载数百个请求。缺少适配时,单例、静态属性和可变状态会在请求之间发生“泄漏”。
社区中常见的判断是,将 Laravel 适配到并发运行环境需要投入大量工作,并伴随大范围代码修改。事实是否如此?
本文观察核心包适配所需的改动量,并讨论整个框架实现异步化的可能路径。
可以从两条思路处理这一问题:
为每个请求创建新的服务实例,替代整个线程共享的全局服务。 改造服务代码,移除静态变量和共享状态依赖,转向使用请求级数据,让每个请求独立处理。
第一条思路最简单,代码改动也最少。服务仍通过 DI 容器解析;容器每次为请求创建新对象,避免复用同一个对象。
优点:无需修改服务代码。 缺点:当服务在内部缓存跨请求数据时,内存占用会膨胀,收益会随之下降。
第二条思路实施成本更高,效果也更好。服务仍作为所有请求共享的单例保留;它的可变状态(当前 locale、当前路由、defer 事件队列)由 Scope context 承载,脱离对象属性。
理解 TrueAsync 的关键,在于它提供了两层上下文。
Coroutine context:单个协程的私有存储。 Scope context:一组协程共享的存储。Scope 形成层级结构:父 Scope 中设置的值,可以被任意嵌套层级的所有子协程访问。
调用 current_context() 会返回当前 Scope 的上下文。服务器为一个请求创建子级 Scope::inherit($serverScope) 时,该 Scope 会继承服务器层级设置的全部内容,同时可以存储只对当前请求可见的值。这正是代码中使用的机制:request、session、auth 等内容都会写入请求的 Scope context。如果某个请求启动嵌套协程,例如并行数据库查询,这些协程会自动访问父请求 Scope context 中的服务,同时与其他请求保持隔离。
laravel-spawn 项目同时使用了这两条思路。
GitHub - YanGusik/laravel-spawn:Laravel Spawn 是一个由 PHP TrueAsync 驱动的 Laravel 异步运行时。它可以在 Laravel 中并发运行多个请求。
用 Enum 作为上下文键,从设计上保证隔离安全
Scope context 是一个键值存储,键可以是对象。对象键可以隔离应用程序不同部分的数据。如果某个对象是私有对象并被用作键,只有持有该对象引用的代码才能读写该键对应的数据。
PHP 中的 Enum 本身也是对象,非常适合作为 Scope context 的键。
enum ScopedService: string
{
case REQUEST = 'request';
case SESSION = 'session';
case AUTH = 'auth';
case AUTH_DRIVER = 'auth.driver';
case COOKIE = 'cookie';
}读写方式如下:
// 服务器将 request 写入请求 Scope context
current_context()->set(ScopedService::REQUEST, $request);
// 代码中的任何位置:controller、middleware、nested coroutine:
$request = current_context()->find(ScopedService::REQUEST);这种设计带来三个优势:
访问隔离。上下文中的数据事实上只对拥有该 enum 的代码开放。第三方包缺少 ScopedService::SESSION 引用,因此无法意外读取 session 对象或替换 request。 键空间隔离。两个 backed value 相同的不同 enum 仍然是不同的键。ScopedService::REQUEST 和 SomeOtherEnum::REQUEST 永远不会冲突,即使二者的 backed value 都是 'request'。 静态分析友好。IDE 和 PHPStan 可以追踪某个 enum case 的所有使用点。搜索 ScopedService::AUTH,会立即显示每个读取或写入 auth 状态的位置。字符串键无法提供这种保证。
Facade 代理屏蔽单例缓存
Laravel facade 会把已解析的实例缓存到一个静态数组中。当代码调用 Auth::user() 时,Laravel 并不会每次都执行 $app->make('auth')。第一次访问时,facade 会把结果保存到 static $resolvedInstance['auth'],后续调用直接复用该实例。在同步模型中,这是一种优化;在异步模型中,不同请求协程会拿到同一个 AuthManager,并可能携带其他请求的数据。
ScopedServiceProxy 代理对象
代理模式可以很好地解决这个问题,同时保留 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 只会缓存一次 ScopedServiceProxy,之后一直复用该代理对象。但每次方法调用都会经过 $resolver;$resolver 会访问 current_context(),并返回当前 Scope context 中的实例。
例如,在 AsyncApplication 中,替换发生在 offsetGet() 方法中,facade 正是通过该方法从容器获取服务:
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 访问。
Auth::user() 的调用链
Auth::user()
→ Facade::__callStatic('user')
→ static::$resolvedInstance['auth'] // ScopedServiceProxy (cached)
→ proxy->__call('user')
→ resolver() // tryResolveScoped('auth')
→ current_context()->find(...) // AuthManager for THIS coroutine
→ $authManager->user()两个并行请求调用 Auth::user()。二者都会命中同一个代理,但 resolver 会从不同的 Scope context 返回不同的 AuthManager 实例。
Traits 用于局部替换状态相关行为
有些服务需要代理或重新实例化之外的适配方式。它们会把可变状态隐藏在实例属性或静态数组深处。为了一个属性替换整个类会带来过高成本。
trait 可以只替换特定方法,同时保留其余代码。
CoroutineTransactions 事务计数器
问题在于:TrueAsync 中的 PDO Pool 会为每个协程提供自己的物理数据库连接。但 Laravel 的 Connection 类把嵌套事务计数器存储在 $this->transactions 中,而该属性在同一个 Connection 实例内共享。如果两个协程使用同一个 Connection,其中一个开启事务,另一个就会看到 transactionLevel() == 1,即使它自身没有事务。
加入 CoroutineTransactions 后,计数器会移入 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() 以及所有错误处理方法。每个方法都使用 coroutine_context(),避免依赖 $this->transactions。Connection 的其余逻辑保持不变。
注意:这里使用的是 coroutine_context(),区别于 current_context()。事务计数器绑定到特定协程,Scope 只承担请求级共享状态,因为每个协程都会从连接池中获得自己的物理连接。
结论
异步的核心在于减少 I/O 等待期间的空转。PHP 代码执行速度保持不变;关键变化发生在数据库处理查询时,进程可以继续处理其他请求。过去 CPU 会在 28 毫秒中的 23 毫秒里等待响应,现在这段时间可以用于处理其他请求。更少的 worker、更低的内存占用、更少的服务器,就能支撑相同甚至更高的吞吐量。
虽然 Laravel 基于同步的 request-per-process 模型设计,将其适配到协程环境依然具备可行性。框架无需从零重写或另建新框架。本文展示的方案可能尚未覆盖 Laravel 的全部组件,但已经说明代码可以较快适配协程。
整个适配只花了几天。为了发现潜在状态泄漏点,项目使用了静态分析(PHPStan 配合自定义规则检测对可变静态属性的写入),同时借助 AI 探索 Laravel 代码库,并识别带有可变状态的服务。
laravel-spawn 项目证明,Laravel 与异步之间的主要障碍来自工具和运行时原语。当运行时提供合适的能力,例如 coroutine context、Scope 层级和原生连接池,适配就会成为明确的工程任务。
TrueAsync 0.7.0 将内置开箱即用的 request_context() 支持,用于提升相关代码路径性能并简化框架适配。