CatchAdmin PHP 后台管理框架 Logo CatchAdmin

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 的键。

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

读写方式如下:

php
// 服务器将 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 和服务代码。

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 只会缓存一次 ScopedServiceProxy,之后一直复用该代理对象。但每次方法调用都会经过 $resolver;$resolver 会访问 current_context(),并返回当前 Scope context 中的实例。

例如,在 AsyncApplication 中,替换发生在 offsetGet() 方法中,facade 正是通过该方法从容器获取服务:

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 只包含 'auth' 和 'session' 服务,它们通过 Auth:: 和 Session:: facade 访问。

Auth::user() 的调用链

text
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:

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() 以及所有错误处理方法。每个方法都使用 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() 支持,用于提升相关代码路径性能并简化框架适配。

本作品采用《CC 协议》,转载必须注明作者和本文链接