使用 PHP TrueAsync 改造 Laravel 协程异步化的可行路径

使用 PHP TrueAsync 改造 Laravel 协程异步化的可行路径

Laravel 的核心设计建立在传统的 request-per-process 模型之上:一个请求由一个独立进程处理。这个模型下,框架内部大量使用单例、静态变量和对象属性保存运行时状态,通常不会产生明显问题。

但在 PHP TrueAsync 这类协程运行时中,情况发生了变化。

TrueAsync 允许单个进程同时承载数百个请求。如果缺少适配,Laravel 原本安全的"进程级状态"就可能变成"所有请求共享的状态",进而导致请求之间发生数据泄漏。

核心问题不是让 Laravel 跑在协程环境中,而是确保每个请求只能看到属于自己的状态。

社区中常见的判断是:将 Laravel 适配到并发运行环境,需要投入大量工作,并伴随大范围代码修改。事实是否如此?

本文以 laravel-spawn 为观察对象,分析核心包适配所需的改动量,并讨论 Laravel 实现协程异步化的可能路径。

一、Laravel 在协程环境中的核心风险

在传统同步模型中,一个请求结束后,进程也会被释放或进入干净状态。即使服务对象内部保存了当前请求的状态,也不会影响下一个请求。

但在协程模型中,一个进程会同时处理多个请求。这意味着以下状态如果继续存放在共享对象、静态属性或单例中,就可能在请求之间互相污染:

  • 当前请求对象;
  • 当前 Session;
  • 当前认证用户;
  • 当前路由;
  • 当前 Locale;
  • 延迟事件队列;
  • 数据库事务计数器;
  • Facade 已解析实例缓存;
  • 服务对象内部的可变属性。

因此,Laravel 协程化的关键不是重写框架,而是解决一个问题:

如何把"请求级状态"从"进程级共享对象"中剥离出来。

二、两条改造路径

处理这个问题,大致有两条思路。

路径一:为每个请求创建新的服务实例

第一种方式最直接: 不再让所有请求共享同一个服务对象,而是让容器在每个请求内重新创建服务实例。

服务仍然通过依赖注入容器解析,只是容器不再跨请求复用同一个对象。

text 复制代码
请求 A → AuthManager A
请求 B → AuthManager B
请求 C → AuthManager C

这种方式的优点非常明显:

优点:无需大规模修改服务代码。

原有服务仍然使用对象属性保存状态,只要这些对象是请求级实例,就不会发生跨请求泄漏。

但它也有明显缺点:

缺点:如果服务内部缓存了大量跨请求数据,内存占用会膨胀。

例如某些服务原本作为单例存在,是为了缓存配置、解析结果或元数据。如果每个请求都重新创建这些服务,内存使用和初始化成本都会上升,异步化带来的收益也会被部分抵消。

因此,这条路径适合用来快速完成初步适配,尤其适合那些状态复杂、短期内难以拆分的服务。

路径二:保留共享服务,将可变状态移入上下文

第二种方式更精细,也更符合协程运行时的设计。

服务对象仍然可以作为所有请求共享的单例存在,但它的可变状态不再放在对象属性中,而是放入请求级上下文。

text 复制代码
共享服务对象
  ├── 不变逻辑:继续保留在服务实例中
  └── 可变状态:移入请求 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:

php 复制代码
$requestScope = Scope::inherit($serverScope);

这样,请求 Scope 会继承服务器层级设置的共享内容,同时又可以保存只对当前请求可见的数据。

例如:

php 复制代码
current_context()->set(ScopedService::REQUEST, $request);
current_context()->set(ScopedService::SESSION, $session);
current_context()->set(ScopedService::AUTH, $auth);

在 Controller、Middleware 或嵌套协程中,都可以通过当前上下文读取这些数据:

php 复制代码
$request = current_context()->find(ScopedService::REQUEST);

这意味着,如果某个请求内部启动了多个嵌套协程,例如并行查询数据库,这些协程仍然可以访问父请求 Scope Context 中的 request、session、auth 等数据。

同时,它们又与其他请求的 Scope Context 完全隔离。

text 复制代码
Server Scope
  ├── Request Scope A
  │     ├── Coroutine A1
  │     └── Coroutine A2
  │
  └── Request Scope B
        ├── Coroutine B1
        └── Coroutine B2

Scope Context 解决的是"不同请求之间的状态隔离问题"。

四、laravel-spawn 的整体策略

laravel-spawn 并没有选择单一方案,而是结合了两条路径:

  1. 对部分服务使用请求级实例,避免共享可变状态;
  2. 对部分服务保留单例,但将可变状态移动到 Scope Context;
  3. 对 Laravel Facade 使用代理对象,避免静态缓存真实服务实例;
  4. 对隐藏较深的状态,通过 Trait 局部替换相关行为;
  5. 对数据库事务计数器等协程级状态,使用 Coroutine Context 隔离。

这种方式的核心优势在于:

不需要重写 Laravel,也不需要推翻现有服务体系,而是针对状态泄漏点做局部适配。

五、使用枚举作为上下文键

Scope Context 本质上是一个键值存储。为了避免字符串键冲突,可以使用对象作为键。

PHP 的枚举,也就是 Enum,本身是对象,非常适合作为上下文键。

php 复制代码
enum ScopedService: string
{
    case REQUEST     = 'request';
    case SESSION     = 'session';
    case AUTH        = 'auth';
    case AUTH_DRIVER = 'auth.driver';
    case COOKIE      = 'cookie';
}

写入当前请求对象:

php 复制代码
current_context()->set(ScopedService::REQUEST, $request);

在代码任意位置读取:

php 复制代码
$request = current_context()->find(ScopedService::REQUEST);

这种设计有三个好处。

1. 避免字符串键冲突

如果使用字符串键,不同模块可能不小心使用同一个键名:

php 复制代码
'request'
'session'
'auth'

但使用 Enum 后,即使两个枚举的 backed value 相同,它们仍然是不同的对象键。

php 复制代码
ScopedService::REQUEST
SomeOtherEnum::REQUEST

即使二者的值都是 'request',也不会互相覆盖。

不同 Enum case 是不同对象,不会因为字符串值相同而冲突。

2. 提高访问边界清晰度

使用公开 Enum 并不是安全边界,但它能显著减少误读、误写和误覆盖。

如果上下文键是普通字符串,任何代码都可以随手写入:

php 复制代码
current_context()->set('session', $value);

而使用 Enum 后,代码必须显式依赖对应的枚举定义:

php 复制代码
current_context()->set(ScopedService::SESSION, $session);

这让上下文访问变得更加可控,也更容易审查。

3. 对静态分析更友好

Enum 键可以被 IDE、PHPStan 或 Psalm 追踪。

例如搜索:

php 复制代码
ScopedService::AUTH

就可以直接找到所有读取或写入认证状态的位置。

相比字符串键,Enum 键更适合长期维护。

对于协程适配来说,可追踪性非常重要。因为状态泄漏问题往往不是语法错误,而是运行时行为错误。

六、Facade 静态缓存带来的问题

Laravel Facade 有一个重要优化:它会把已经解析过的实例缓存到静态数组中。

例如调用:

php 复制代码
Auth::user();

Laravel 并不会每次都执行:

php 复制代码
$app->make('auth');

第一次访问时,Facade 会把解析结果保存到静态属性中:

php 复制代码
static::$resolvedInstance['auth']

后续调用会直接复用这个实例。

在传统同步模型中,这是合理优化。因为一个进程只处理一个请求,请求结束后状态自然清理。

但在协程模型中,这会变成严重问题。

text 复制代码
请求 A 首次调用 Auth::user()
  → Facade 缓存 AuthManager A

请求 B 调用 Auth::user()
  → Facade 直接复用 AuthManager A

如果 AuthManager 内部保存了当前用户、Guard 或 Session 状态,请求 B 就可能拿到请求 A 的认证信息。

Facade 的问题不在于静态调用本身,而在于它会静态缓存真实服务实例。

七、使用 ScopedServiceProxy 屏蔽 Facade 缓存

laravel-spawn 使用代理对象解决这个问题。

Facade 可以继续缓存对象,但缓存的不再是真实服务,而是一个代理。

php 复制代码
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 中取出真正的服务实例。

text 复制代码
Facade 缓存的对象:ScopedServiceProxy
真实服务实例:每次从当前 Scope Context 解析

这样一来,Facade 的缓存机制仍然存在,但它缓存的是无状态代理,而不是带有请求状态的服务对象。

八、AsyncApplication 中的替换逻辑

在 Laravel 中,Facade 通过容器读取服务。laravel-spawn 在 AsyncApplication::offsetGet() 中加入了替换逻辑。

php 复制代码
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 只包含需要代理的服务,例如:

php 复制代码
'auth'
'session'

也就是说,只有通过 Auth::Session:: 这类 Facade 访问的高风险服务才会被代理。

这是一个低侵入改造:不修改业务代码,也不修改 Facade 调用方式,只替换 Facade 最终拿到的对象。

九、Auth::user() 的完整调用链

适配后,Auth::user() 的调用链变成:

text 复制代码
Auth::user()
  → Facade::__callStatic('user')
    → static::$resolvedInstance['auth']
      → ScopedServiceProxy
        → proxy->__call('user')
          → resolver()
            → tryResolveScoped('auth')
              → current_context()->find(...)
                → 当前请求的 AuthManager
                  → $authManager->user()

两个并行请求同时调用:

php 复制代码
Auth::user();

它们命中的可能是同一个 ScopedServiceProxy,但每次调用时,代理都会从当前协程所属的 Scope Context 中解析真实服务。

text 复制代码
请求 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 类会把嵌套事务计数器保存在对象属性中:

php 复制代码
$this->transactions

如果多个协程共享同一个 Connection 实例,就会出现问题。

text 复制代码
协程 A 开启事务
  → $this->transactions = 1

协程 B 查询 transactionLevel()
  → 也看到 1

但协程 B 实际上并没有开启事务。

这就是典型的共享对象状态泄漏。

十二、CoroutineTransactions 的处理方式

laravel-spawn 通过 CoroutineTransactions Trait 将事务计数器移入 Coroutine Context。

php 复制代码
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 用于请求级共享状态,而事务计数器与当前协程使用的物理数据库连接相关。

如果一个请求内部启动多个并行协程,每个协程都可能从连接池中获取不同的物理连接。此时事务状态应该跟随协程,而不是跟随整个请求。

text 复制代码
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,并将请求级数据写入其中。

典型数据包括:

php 复制代码
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;
  • 其他持有请求状态的服务。

核心原则是:

text 复制代码
Facade 可以缓存代理,但不能缓存请求级真实服务。

第四步:对深层状态使用 Trait 局部替换

对于无法简单拆分的框架内部类,可以使用 Trait 替换少量关键方法。

例如数据库事务计数器:

text 复制代码
$this->transactions

可以迁移到:

text 复制代码
coroutine_context()

这样既能避免重写整个类,又能隔离协程状态。

第五步:用并发测试验证状态隔离

协程适配不能只靠单元测试,还需要构造并发场景。

重点测试:

  • 请求 A 的用户不会泄漏到请求 B;
  • 请求 A 的 Session 不会影响请求 B;
  • 并发数据库事务互不干扰;
  • Locale、路由、Cookie、事件队列不会串请求;
  • 嵌套协程能正确继承父请求上下文;
  • 请求结束后上下文能被释放。

协程问题通常不是"功能不可用",而是"高并发下偶发串状态"。测试必须围绕状态隔离设计。

十四、异步化的真正收益

协程异步化并不会让 PHP 代码本身执行得更快。

它提升吞吐量的关键在于: 当一个请求正在等待数据库、Redis、HTTP API 或文件 I/O 时,当前进程可以切换去处理其他请求。

在传统同步模型中,一个请求的执行时间可能大部分都消耗在等待 I/O 上。

例如某个请求总耗时 28 毫秒,其中 23 毫秒都在等待数据库响应。同步模型下,这 23 毫秒内 worker 基本处于空转状态。

在协程模型中,这段等待时间可以用来处理其他请求。

text 复制代码
同步模型:
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 与异步运行时之间的主要障碍,将不再是框架本身,而是工具链、适配层和状态管理规范。

使用 PHP TrueAsync 改造 Laravel 协程异步化的可行路径

相关推荐
源远流长jerry1 天前
Linux 网络虚拟化深度解析:从 veth 设备对到容器网络实战
linux·运维·服务器·网络·性能优化·php
Java源头1 天前
PHP 身份证二要素检测
开发语言·php
yoyo_zzm1 天前
PHP vs Java:后端语言终极选择指南
java·spring boot·后端·架构·php
yoyo_zzm1 天前
五大编程语言对比:PHP、C、C++、C#、易语言
c语言·c++·php
不会摸鱼的小鱼2 天前
WSL 安装 Ubuntu 22.04 到指定磁盘
数据库·postgresql·php
淼淼爱喝水2 天前
DVWA和Pikachu命令注入漏洞检测实验
安全·web安全·php·pikachu·dvwa
专注VB编程开发20年2 天前
json和python元组,列表,字典对比
开发语言·python·json·php
怀旧,2 天前
【Linux网络编程】15. Reactor 反应堆模式
linux·网络·php
Dylan的码园2 天前
2026年免费远程控制软件哪个好?ToDesk向日葵UU远程免费版横评,不限次数不限时长
服务器·开发语言·php
dog2502 天前
解析几何的力量(1)
服务器·开发语言·网络·php