找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2102

积分

0

好友

298

主题
发表于 昨天 13:15 | 查看: 10| 回复: 0

在 RFC TrueAsync 1.7 的背景下,一个随之而来的问题是:这项提案将如何与 PHP 内核未来可能的变更进行互动?能够对未来几年的语言发展方向做出基本预判,是设计好语言的关键。这正是本文存在的意义。

TrueAsync 项目不仅仅是为异步而修改 PHP 内核,它还包含一系列研究,旨在回答以下问题:

  • PHP 在多线程方向上能走多远?
  • 是否存在根本性的限制?
  • 要让多线程成为现实,内核可能需要哪些改动?
  • 可以实现哪些语言抽象?

本文无意覆盖多线程在 PHP 中的所有细节,也不保证每一处都技术完备或人人易懂。但希望它能对广大 PHP 开发者有所启发,并为后续的讨论定下一个大致的方向。

历史

几年前,当我们需要在 PHP 应用中集成大规模的遥测数据时,我曾直接断言“这不可能”。但在研究了 Swoole 的架构后,我决定亲自验证这个结论。能否构建一个既能生成、又能处理海量数据,同时不拖慢客户端交互的 API?

我们为 PHP 开发了一个优化版的 OpenTelemetry:它分片写入数据,将它们聚合成大块后发送到中间遥测服务器。数据会被压缩,JSON 结构通过 MessagePack 进行序列化。

核心假设是:如果使用单线程的协程,就能逐步构建遥测数据,然后根据定时或体积阈值定期发送到服务器。代码应该很快,因为没有跨线程的交互。事实果真如此吗?

实验结果令人意外:遥测功能让 API 的吞吐量下降了一半。假设被推翻了。为什么?在_概念_层面,一切都显得很合理。Swoole 已经使 PHP 函数变得非阻塞,协程理应高效。问题出在哪里?

在第二版方案中,遥测数据只在单个请求期间收集,然后立即被丢进一个专门的作业进程,由它负责聚合、压缩并发送到服务器。这个方案效果好得多。但从理论上讲,这真的有必要吗?进程间的数据通过管道传递,一边序列化,一边反序列化。即便管道在内存中,系统调用的开销也不容小觑。

后来我们找到了原因:遥测数据量太大,压缩工作相对于 API 处理耗费了大量的 CPU。这意味着,Swoole 协程对 I/O 操作非常有效,但对 CPU 密集型任务却无能为力。

这只是众多案例中的一个:单线程协程并非万能;多线程可以成为协程的有力补充,共同构成一个能覆盖更广泛问题域的工具集。

Single-threaded + offload 模型

将 CPU 密集型工作卸载到单独的进程,这并不是什么“新发明”。它属于一种更通用的模型,该模型在不同语言与框架中独立出现,被称为 Single-threaded + offload

想象一下,有一个人飞快地分拣信件(每小时上千封),而沉重的包裹则由其他员工装车运走。如果分拣员自己去搬包裹,会怎么样?信件队伍会一直堆到天花板。

Single-threaded + offload 模型将任务分为两类:

  1. I/O-bound 任务 —— 读取文件、网络调用、数据库访问。这些操作的大部分时间都在等待外部世界。成千上万此类操作可以通过并发异步(协程、await)机制塞进一个线程。
  2. CPU-bound 任务 —— 压缩、加密、解析、计算。这类任务会让 CPU 满负荷运转,仅靠并发不够,需要更多的核心。

模型在物理上分隔这些任务:主线程(Event Loop)只管 I/OCPU 密集型任务则被分发到单独的线程或进程(Workers)中。

Node.js 以单线程 Event Loop 著称,非常适合网络应用。但当开发者试图在请求处理过程中进行图像处理或视频压缩时,服务器性能就会骤降。解决方案是 Worker Threads —— 使用独立线程来处理 CPU 繁重任务。

Python 的路线类似。asyncio 为 I/O 代码提供了强大的工具,但内置的 GIL(全局解释器锁)阻止了单进程内的真正 CPU 并行。因此出现了 loop.run_in_executor()asyncio.to_thread()(自 Python 3.9 起),它们将繁重任务丢进线程池或进程池。事件循环依然保持响应灵敏,而计算则在并行进行。

PHP/Swoole 的架构也是如此:Request Workers 使用协程处理 HTTP 请求,Task Workers 则负责繁重的计算。通过 UnixSocket 或管道进行通信,一个进程每秒可以处理约 10 万次操作。

模型的优点

1. 资源利用高效

单线程的事件循环能够以极低的开销处理成千上万的并发 I/O 操作。协程之间的切换成本远低于操作系统层面的线程上下文切换。CPU-bound 任务则在多核上获得真正的并行加速——每个 worker 占用自己的核心,互不干扰。

2. 开发更简单

事件循环里的代码不需要处理互斥锁、信号量等多线程编程的“乐趣”。单线程模型意味着任意时刻只有一个任务在运行,数据竞争 问题自然不存在。Workers 虽然并行运行,但如果遵守 Shared Nothing(无共享)原则,同步问题就不会出现。

多线程代码与单线程异步代码的复杂度差距巨大。现代语言与框架都更倾向于单线程异步,而非传统多线程,这并不奇怪。

3. 编译器/运行时更简单

单线程模型下的异步函数对编译器和运行时来说简单得多。一个良好支持多线程的语言需要专门的代码生成流程。PHP 面临一个严峻限制:部分核心代码是用 C 写成的。这使得基于线程进行高效的字节码优化、内存管理和参数传递变得困难。Go 语言的设计为何复杂?自研栈、复杂的 GC——所有这些都是为高效的 goroutine 与 channel 服务的。我们稍后还会谈到 PHP 的 GC,先别放松!

4. 手动分配负载

开发者可以有意识地在请求处理代码与 worker 池代码之间分配负载。手动控制能将硬件潜力榨取到极限。但反过来,这也算是一个缺点。

模型的缺点

1. 手动分配负载(双刃剑)

手动分配是把双刃剑。开发者可能针对特定场景做出优化,也可能误判哪些任务该留在 I/O 代码中,哪些该交给 workers。结果可能导致 I/O 代码被繁重任务拖垮,响应性下降,延迟上升。

该模型要求 PHP 程序员具备足够的能力,或者依赖框架作者提供的成熟方案。

2. 并非万能

Single-threaded + offload 非常适合 Web 服务器、API、微服务——其主要负载是对数据库、文件系统、网络的 I/O。但如果应用程序的每一步都需要繁重的计算(如科学计算、渲染、机器学习),此模型可能效率不足;完整的 多线程 支持可能更为合适。

你可能会说:这些我们都能接受!我们准备好了!但 PHP 自身准备好迎接多线程了吗?

PHP 准备好多线程了吗?

在开发 TrueAsync 时,最困难的讨论之一是“为什么 PHP 没有原生的异步支持”。解释 PHP 为什么(至少目前)不适合多线程,可能同样棘手。不过,让我们先聊聊多线程本身:我们为什么需要它——或者更准确地说:为什么我们需要它?

多线程不是为了让代码并行执行。

“并行必须用多线程”这个想法很早就扎根在程序员脑中,正如“黑洞会把东西吸进去”扎根在大众认知中一样。

并行执行完全可以由进程很好地完成,而且进程之间互相隔离。进程可以使用 IPC(进程间通信)进行交互,完成状态可以通过信号(操作系统事件)来跟踪。那么,为什么还需要线程呢?

要诚实地回答这个问题,恐怕得回到过去,请当时的决策者们来说明:Edsger Dijkstra、Fernando Corbató、Barbara Liskov、Richard Rashid……我们能办一场很棒的访谈。但即便他们应邀,我们也未必能得到明确的答案。

一个不太准确但流行的说法是:

线程是为了让并行代码无需额外工具就能共享内存。

进程也可以共享内存,但需要把内存段映射进地址空间(需要额外工具)。线程默认共享全部内存。字面意义上,如果变量 x 在线程 A 中可用,那么在线程 B 就能用同样的地址访问它,无需任何技巧……但事情并非如此简单!多个线程不能在不借助同步工具的情况下安全地操作同一个变量。

更诚实的说法可能是:

线程是为了在任务间传递内存而不增加额外开销。

如果线程在传递消息时使用内存,并且保证在任一时刻只有一个线程能访问特定的内存区域,那么这在内存和 CPU 上都是最高效的。同时,线程会刻意避开那些可能会被共享的内存区。这种模型就叫 Shared Nothing

线程存在的意义,或许正是在于任务间高效地传递数据。这就像“黑洞不吸东西”一样,是个反直觉但更接近本质的认识。

PHP 的内存模型

PHP 的内存是如何工作的?一个简化的抽象模型包含:

  1. 代码
  2. 数据
  3. PHP VM 状态

在多个线程之间共享 PHP 代码已经是可行的(在 PHP JIT 出现时解决了这个问题)。其余部分则紧密耦合,不能轻易拆开。例如,PHP 使用全局的 object_store 来存储所有对象的引用。PHP 的内存管理器是为单个 PHP VM 设计的,完全没有考虑过多线程场景。PHP 的垃圾回收 机制无法处理来自不同线程的数据,并且要求完全停止 PHP VM(即“Stop-the-World”),因为它会直接修改对象的 refcount

所以,PHP 是严格的单线程模型,其 GC 需要暂停整个世界。

在线程间移动 PHP VM

PHP 使用线程本地存储(TLS) 来按线程保存 VM 状态。这对 ZTS(Zend Thread Safety)模式下的线程隔离至关重要。

在现代 PHP 构建中,使用 C11 标准的“静态”变量 __thread(在 MSVC 下是 __declspec(thread))来获取 VM 状态指针。这种操作极快,在 x86_64 架构上就是从 FSGS 寄存器基址加上一个固定的偏移量来读取地址。

      ; offset - 编译期计算的固定偏移
      ; fs - 段基址
      mov rax, QWORD PTR fs:offset

因为 FS/GS 由操作系统保证每个线程唯一,从中读取总能得到正确的 VM 状态指针。

能够在线程间移动 VM 状态有助于实现类似 Go 语言的协程或 Actor 模型。现代 VM 通过定制的代码生成,使用 CPU 寄存器来传递 VM 上下文。PHP 做不到这一点,因为其底层是 C 函数,而 C 无法隐式地把上下文参数传递给所有函数。因此,在线程间移动 PHP VM 状态,会带来一定的性能损耗。

但如果只移动执行代码所需的一小部分 VM 状态呢?例如 PHP Fiber 在切换时,就会复制部分全局结构指针(如 zend_executor_globals)。

假设我们把 PHP VM 在概念上拆成两大块:

  1. PHP VM shared:类、函数、常量、ini 指令、可执行代码。
  2. PHP VM movable:需要被移动的那部分 VM 状态。

PHP线程本地存储结构示意图

部分结构可以标记为 shared,部分标记为 movable;甚至 Executor Globals 也可以拆分成 shared/movable 部分,这样就能高效地在多线程间移动 VM 状态。扩展的全局结构不会因为额外的间接访问而损失性能,因为它们本就这么做。

问题在于与代码编译相关的结构,因为 PHP 具有 include/requireeval、自动加载等动态特性。正是这些特性使得高效地拆分 shared/movable VM 状态变得困难。若能解决这一点,PHP 就能以最小的开销在线程间移动部分 VM 状态。

在线程间传递对象

要让 PHP 能安全地在线程间传递对象,需要改动什么?又该如何实现?

让我们先从语言层面进行尝试。假设我们有一个 SomeObject 实例存储在 $obj 里,需要把它送到另一个线程。这可能吗?

$obj = new SomeObject();

$thread = new Thread(function () use ($obj) {
    echo $obj->someMethod();
});

$thread->join();

因为 SomeObject 只属于 $obj,我们可以安全地把它的内存地址从一个线程移动到另一个。主线程中的 $obj 随后会被销毁:

$obj = new SomeObject();

$thread = new Thread(function () use ($obj) {
    echo $obj->someMethod();
});

// $obj is undefined here

$thread->join();

这与 C++ 新增的 move 语义、或 Rust 等语言里的 move 概念完全等价。这种在多线程间传递内存的方式有两大优点:

  1. 安全性:仅一个线程拥有对象。
  2. 无复制/序列化开销

为了让行为可预测并便于静态分析,可以加上专门的移动语法,例如:

$obj = new SomeObject();

// consume $obj 表示移动对象
$thread = new Thread(function () use (consume $obj) {
    echo $obj->someMethod();
});

// $obj is undefined here. Error should be reported here in PHP9.
echo $obj;

看起来很美,对吧?

然而,仅靠检查 refcount = 1 就进行移动会带来问题。来看一个分类树的例子:

$electronics = new CategoryNode('Electronics');

$categoriesTree = new Tree();
$categoriesTree->addToPath('/products/electronics', $electronics);
$categoriesTree->addToPath('/popular/electronics', $electronics);  // 同一个分类!

$electronics 在树里出现了两次(refcount = 2)。如果把 $categoriesTree 移到另一个线程,会怎样?

要安全移动,必须保证对象图中所有对象都没有外部引用:

$node = new CategoryNode('Electronics');
$categoriesTree = new Tree();
$categoriesTree->addToPath('/products/electronics', $node);

$favourites = [$node];  // 外部引用!

$thread = new Thread(function () use ($categoriesTree) {
    // $categoriesTree 已移动
});

// $favourites[0] 现在指向另一线程的内存
// 悬垂指针!

要保证安全移动,需要:

  1. 完整遍历图 —— 检查所有子对象。
  2. 检查 refcount —— 图中每个对象都要查。
  3. 保持身份 —— 图内的重复引用必须保持重复。

我们可以设计多个算法来做这件事,可以称之为 deep copy。一个简单的实现伪代码可能是:

// Deep copy 伪代码
// 线程 A 中的源图
$node = new Node('A');        // addr: 0x1000
$tree->left = $node;          // addr: 0x1000
$tree->right = $node;         // addr: 0x1000 (同一引用)

// 拷贝到线程 B(带 MM 的伪代码)
$copied_map = [];  // hash table: addr_source -> addr_target

function deepCopyToThread(object $obj, Thread $target_thread_mm) {
    $source_addr = get_object_address($obj);

    if (isset($copied_map[$source_addr])) {
        return $copied_map[$source_addr];  // 已复制!
    }

    // 在目标线程的 MM 中分配内存
    $new_addr = $target_thread_mm->allocate(sizeof($obj));
    $copied_map[$source_addr] = $new_addr;

    // 复制对象数据
    memcpy($new_addr, $source_addr, sizeof($obj));

    // 递归遍历属性
    foreach ($obj->properties as $prop) {
        if (is_object($prop)) {
            $new_prop_addr = deepCopyToThread($prop, $target_thread_mm);
            // 更新新对象里的指针
            update_property($new_addr, $prop, $new_prop_addr);
        }
    }

    return $new_addr;
}

// 线程 B 中的结果:
// $newTree->left (addr: 0x2500) === $newTree->right (addr: 0x2500)
// 身份保持!

Deep copy 的时间复杂度O(N + E),其中 N 为对象数量,E 为引用边的数量。
空间复杂度O(N) —— 哈希表 + 新对象 + 递归栈。

与序列化相比,这可能更快,因为它省去了转换为传输格式的步骤,但具体收益取决于数据的形状和图的大小。也可以采用混合策略:refcount = 1 的数据直接移动,其余情况使用 deep copy

最终结果是:

  1. PHP 开发者无需关心对象是如何被送到其他线程的。
  2. 最佳情况:内存被直接移动(refcount = 1)。
  3. 最差情况:使用 deep copy 复制内存并保持对象身份(refcount > 1)。

看起来不错:

  • PHP 语法改动最小。
  • 变更可以逐步进行。
  • 多线程变得可用。

但在内核层面,并非一片光明。要让对象移动成为现实,PHP 需要跨线程的内存管理机制,而目前尚不可行。

多线程版 PHP 内存管理器

PHP 的内存管理器类似于现代分配器,如 jemalloctcmalloc。区别在于,它缺少从其他线程正确释放内存的算法。

考虑以下场景:

  • 对象在线程 A 中创建。
  • 通过移动(原样)传递给线程 B
  • B 中不再需要,应该被释放。

每个 PHP 线程都有自己的 Memory Manager (MM)。当 B 试图释放 A 分配的内存时,问题就来了:B 的 MM 不认识 A 的内存,直接释放会导致错误。从 B 直接访问 A 的 MM 结构也不可行,这需要同步机制。现代高性能多线程分配器使用延迟释放(deferred free)来解决这个问题。

deferred free 的思路是:

  1. B 的 MM 发现一个不认识的指针。
  2. 它找出该指针属于哪个 MM,然后向那个 MM 的队列发送一条“可释放”消息。
  3. A 的 MM 处理队列,在自己的上下文中安全地释放这些指针。

跨线程内存释放(延迟释放)流程示意图

基于现代无锁数据结构,这个算法可以实现高吞吐量,允许不同线程并行地释放内存,几乎不需要锁。

一个多线程版的 PHP 内存管理器,将为此前不可能的其它内核改动打开大门。

共享对象

能够在不同线程间以最小操作传递内存固然很棒,但如果我们能直接创建为共享而设计的对象呢?

很多服务可以建模为不可变对象,这样就能在不同进程间安全共享,从而节省内存、加快 PHP worker 的启动速度。

可惜 refcount 成了阻碍,它实际上让所有 PHP 对象都变得可变!有没有办法绕过?

代理对象

一种方式是使用代理对象,它指向存储在所有线程都可访问的共享内存池中的真实对象。代理只保存标识符或指针以及访问真实对象数据的方法。缺点是:

  • 访问数据/属性的时间变长。
  • Reflection API 与类型计算会更复杂。

好的一面是,PHP 已有成熟的代理机制。在某些场景下,代理型共享对象是不错的选择,例如计数器表或类似 Swoole/Table 的数据表。

带 GC_SHARE 标志的共享对象

PHP 已有 GC_IMMUTABLE 标志来支持不可变元素,应用于:

  • 驻留字符串IS_STR_INTERNED)—— 整个进程生命周期内的字符串常量。
  • 不可变数组IS_ARRAY_IMMUTABLE)—— 例如 zend_empty_array
  • opcache 中的常量 —— 带常量数据的已编译代码。

GC_IMMUTABLE 让引擎可以跳过对这些结构进行 refcount 操作:

// Zend/zend_types.h
// 增加 zend_refcounted_h 引用计数的函数
static zend_always_inline void zend_gc_try_addref(zend_refcounted_h *p) {
    if (!(p->u.type_info & GC_IMMUTABLE)) {
        ZEND_RC_MOD_CHECK(p);
        ++p->refcount;
    }
}

类似的机制可用于 SharedObjects,例如定义一个 GC_SHARE 标志。

性能分析表明,检查 GC_SHARE 标志会让单独的 refcount++ 操作增加约 +34% 的开销(微基准测试)。在真实应用中,refcount 操作只占整体开销的一小部分,因此总体影响应微乎其微:

  • 贴近实际的操作(数组/对象):+3-9%
  • 真实应用:+0.05-0.5%

这解决了一半问题,另一半是为这类对象设计 GC。使用原子操作的 refcount 不是好主意,因为多个线程频繁访问同一对象可能会拖慢性能。延迟释放算法更可能适用。

Region-based memory

在面向 Web 的语言中,基于区域的内存管理近来很流行。

其思路是为特定任务或线程在独立的内存区域中分配内存,当该区域不再需要时可以整体(或近乎整体)释放,从而避免逐对象管理的复杂度,也简化了 GC。

例如,PHP MM 可以保证在某个特定区域中创建对象,并将该区域绑定到某个 PHP 对象。区域的生命周期等于对象的生命周期。当对象销毁时,整个区域可以直接释放,无需遍历其子元素。如果需要把这样的对象“移动”到另一个线程,就能避免 deep copy。

PHP VM 在实现 region-based memory 时会面临难点,比如全局对象列表、opcode 缓存等。但高效实现的可能性并非为零,值得继续研究。

一个可用的 region-based memory 算法,为实现 Actor(一种带有隔离内存的特殊对象)打开了可能性。Actor 是多线程编程中最方便、强大且安全的工具之一。

协程与线程的协同

对于协程而言,一个 Thread 可以被视为一个 Awaitable 对象。协程可以等待 Thread 的结果,而不会阻塞其他协程。也就是说,一个线程里可以有许多协程在等待繁重任务的结果。处理这些协程的线程依旧能快速响应新请求,因为等待 Thread 并不会阻塞事件循环。

use Async\await;
use Async\Thread;

$thread = new Thread(function() {
    // hardware-bound task here
    return 42;
});

$result = await($thread); // 协程在此暂停,直到 Thread 完成

这种方式可以实现既有 CPU 密集任务,又有简单业务逻辑的聊天等场景。

协程与线程池协作架构图

图中是一个示例架构:应用包含两个线程池——并发处理请求的线程池,以及处理 CPU 密集任务的 worker 池。协程在处理请求时,可以完全暂停,等待 worker 完成繁重任务后再继续。

use Async\await;
use Async\ThreadPool;

final readonly class ImageDto {
    public function __construct(
        public int $width,
        public int $height,
        public string $text,
    ) {}
}

$pool = new ThreadPool(2);
$dto = new ImageDto(
    width: 200,
    height: 200,
    text: 'Hello TrueAsync!'
);

$image = $pool->enqueue(function (ImageDto $dto) {
    $img = imagecreatetruecolor($dto->width, $dto->height);

    $white = imagecolorallocate($img, 255, 255, 255);
    $black = imagecolorallocate($img, 0, 0, 0);

    imagefill($img, 0, 0, $white);
    imagestring($img, 5, 20, 90, $dto->text, $black);

    ob_start();
    imagepng($img);
    imagedestroy($img);
    return ob_get_clean();
}, $dto);

$response->setHeader('Content-Type', 'image/png');
$response->write($image);
$response->end();

协程代码是顺序执行的,看起来就像 ThreadPool::enqueue 在同一线程里调用回调一样。DTO 被安全地跨线程传递,生成的 PNG 字符串也不会在内存中复制两次。

垃圾回收与 stateful 模式

升级 PHP 内存管理器只是改进多线程环境的一个方面。没有高效的 GC,多线程 PHP 会因循环引用而出现性能问题和内存泄漏。

PHP 的 GC 使用两种算法:引用计数 作为主要的内存管理手段,并发循环收集(Bacon-Rajan,2001)用于处理循环引用。引用计数在每次赋值时增减,这在无同步的情况下无法安全地用于多线程。每次赋值都使用原子操作,开销巨大;不使用同步又会产生竞争和泄漏。循环收集器虽然名为“并发”,却只在单线程内工作,它使用颜色标记(PURPLEGREYWHITE/BLACK)来寻找循环,这同样不是线程安全的。

好消息是,当前的 GC 实现与内存管理器是解耦的,不依赖内存分配的位置,因此在多线程环境中,其核心算法原理上可以工作。

但如果 PHP 想进入 stateful 多线程时代,GC 需要进一步适配,以解决:

  1. 能在单独线程中并行工作,不影响业务代码。
  2. 尽可能快地释放资源。
  3. 提供额外工具来发现/记录内存泄漏、进行性能遥测(对长时间运行的应用尤为重要!)。

循环收集器可以被修改为在单独的线程中处理引用图,从而提高整体响应性。作为一个开始,这可能已经足够了!

Actor

ThreadPool 和跨线程传递对象很有用,但它们需要开发者投入相当的注意力与经验。是否存在更好的抽象,能屏蔽线程与内存的复杂性,同时非常适合业务逻辑?答案是:Actor

Actor 是一种并发并行编程模型,其中基本的计算单元就是 actor

每个 actor 具有以下特征:

  • 拥有自己隔离的状态。
  • 顺序地处理接收到的消息。
  • 仅通过消息与其他 actor 进行交互。
  • 可以在单独的线程中运行。

我们可以把 actor 看作一种特殊的对象,因此可以在多线程 PHP 中沿用我们熟悉的 OOP 范式。

想象一个有众多房间的聊天服务器。每个房间可以是一个 actor 对象。

use Async\Actor;

class ChatRoom extends Actor {
    private array $messages = [];
    private string $name;

    public function __construct(string $name) {
        $this->name = $name;
    }

    public function postMessage(string $user, string $text): void {
        $this->messages[] = [
            'user' => $user,
            'text' => $text,
            'time' => time()
        ];
    }

    public function getMessages(): array {
        return $this->messages;
    }
}

spawn(function() {
   $room = new ChatRoom('general');
   $room->postMessage('Alice', 'Hello!');  // 在另一线程执行,会挂起协程!
   $messages = $room->getMessages();       // 在另一线程执行,会挂起协程!
   echo json_encode($messages);
});

ChatRoom 对象是特殊的:它们的数据与 PHP VM 状态被局部化,便于在线程间移动。每个方法调用都在独立的线程中运行,但同一时刻只有一个线程能执行某个特定 actor 的方法。

从语义上看,基类 Actor 定义了 PHP VM 和内存管理器的工作方式,使得 ChatRoom 可以安全地在独立线程中运行。类的类型信息不仅“存储”方法与属性,还存储了该类对象的内存管理与 GC 策略。类似的方法在其他语言(如 Rust、C++)中也存在。好处是无需修改语法,且自然地符合现有的 OOP 哲学。

上面的示例看起来像是在协程里运行的普通顺序代码。但因为 postMessagegetMessages 实际上在另一个线程执行,协程会向 actor 的消息队列发送消息,然后进入等待,直到 actor 在另一个线程执行完方法并返回结果时才恢复。

这一切都不违背 PHP 常见的 OOP 范式,因为 Actor 类可以重写 __call 魔术方法来实现这一机制:

class Actor {
    private $threadPool;

    public function __call(string $name, array $arguments): mixed {
        if(current_thread_id() === $this->threadPool->getThreadIdForActor($this)) {
            // 如果在同一线程,直接调用
            return $this->$name(...$arguments);
        }

        // 否则将调用加入 actor 的消息队列
        return $this->threadPool->enqueueActorMethod($this, $name, $arguments);
    }
}

enqueueActorMethodpostMessage 调用放入 actor 的专属队列,订阅结果事件,并调用 Async\suspend() 挂起当前协程。

Actor 的内部代码是顺序执行的,从而消除了数据竞争,使得多线程开发对程序员变得透明。

并行性来自于每个 ChatRoom actor 都能在不同的线程中运行:

spawn(function() {
   $room = new ChatRoom('room1');
   $room->postMessage('Alice', 'Hello!');
   $messages = $room->getMessages();
   echo json_encode($messages);
});

spawn(function() {
   $room = new ChatRoom('room2');
   $room->postMessage('Bob', 'Hi there!');
   $messages = $room->getMessages();
   echo json_encode($messages);
});

不同的 ChatRoom 可以在不同线程中并行运行,因为每个 actor 都有自己的执行线程、独立的 PHP VM 状态和内存。

创建100个聊天室

use Async\Actor;

$rooms = [
    'general' => new ChatRoom('general'),
    'random'  => new ChatRoom('random'),
    'tech'    => new ChatRoom('tech'),
    // ... 另外 97 个房间
];

// 处理请求的协程
HttpServer::onRequest(function(Request $request, Response $response) use ($rooms) {
   // 处理 HTTP 请求
   $roomName = $request->getQueryParam('room');
   $room = $rooms[$roomName] ?? null;

   if (!$room) {
      $response->setStatus(404);
      $response->write('Room not found');
      $response->end();
      return;
   }

   // 调用看似同步,实际在另一线程运行!
   $room->postMessage($request->getQueryParam('user'), $request->getQueryParam('text'));
   $messages = $room->getMessages();

   $response->setHeader('Content-Type',  'application/json');
   $response->write(json_encode($messages));
   $response->end();
});

每个聊天室都按顺序处理其内部消息,同时与其他聊天室并行运行。

Actor 模型不需要程序员处理互斥锁、条件变量、复杂的同步原语或手动管理线程池。它提供了一种现成的高级并行方案。

如果一个聊天室需要给另一个聊天室发消息,也可以做到,因为 actor 本身可以作为 SharedObject 在 actor 间传递:

class Rooms extends Actor {
    private array $rooms = [];

    public function __construct(string ...$roomNames) {
       foreach ($roomNames as $name) {
           $this->rooms[$name] = new ChatRoom($name);
       }
    }

    public function broadcastMessage(string $fromRoom, string $user, string $text): void {
        foreach ($this->rooms as $name => $room) {
            if ($name !== $fromRoom) {
                // 非阻塞调用
                $room->postMessageAsync($user, $text);
            }
        }
    }
}

spawn(function() {
   $rooms = new Rooms('general', 'room1', 'room2', 'room3');
   $rooms->broadcastMessage('general', 'Alice', 'Hello!');
});

Actor 的内部机制

PHP VM 需要保证 actor 内部创建的所有对象:

  • 要么只属于该 actor,并分配在它的专有内存区域中。
  • 要么是从其他区域或线程移动过来的(此时所有权已转移)。
  • 要么是另一个 SharedObject 或其他 actor 的引用。

Actor 要么拥有自己的独立内存区域,要么仅与明确共享的不可变对象一起工作——否则仍可能引发数据竞争。

内存管理器需要保证,在 actor 方法中执行的所有内存操作都会自动绑定到与该 actor 直接关联的内存区域。

Actor 的方法通过一个 MPMC(多生产者多消费者)消息队列来调度执行,由一个 Scheduler 负责管理。Scheduler 在多个 actor 之间分配 CPU 时间,从而提供并发和并行执行的能力。

Actor模型架构:主线程、消息队列与Actor线程交互图

结语

这些听起来都很美好,但何时能看到它们在现实中落地呢?你或许会这样问。

Single-threaded + offload 模型有望在不久后成为现实,因为它的许多组件已经就绪或正在开发中。就 TrueAsync 项目而言:单线程协程已进入 Beta 阶段;实验性的多线程内存管理器和创建线程的 API 也已初步实现。

Actor 模型则需要更多的开发时间,它们涉及 PHP 内核的诸多核心部分,但仍然是 PHP 未来版本(例如 PHP 9)的一个现实目标。它的实现能为市场带来一个既安全又强大的多线程 PHP 编程体验。

原文:https://github.com/true-async/multithreaded-php

引用链接

[1] RFC TrueAsync 1.7: https://wiki.php.net/rfc/true_async
[2] TrueAsync: https://github.com/true-async/




上一篇:深入理解C++20 Concepts:从零到一掌握模板类型约束
下一篇:12个嵌入式开源项目推荐:从无人机飞控到智能硬件实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-11 20:16 , Processed in 0.459123 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表