在 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 模型将任务分为两类:
- I/O-bound 任务 —— 读取文件、网络调用、数据库访问。这些操作的大部分时间都在等待外部世界。成千上万此类操作可以通过并发异步(协程、await)机制塞进一个线程。
- CPU-bound 任务 —— 压缩、加密、解析、计算。这类任务会让 CPU 满负荷运转,仅靠并发不够,需要更多的核心。
模型在物理上分隔这些任务:主线程(Event Loop)只管 I/O,CPU 密集型任务则被分发到单独的线程或进程(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 的内存是如何工作的?一个简化的抽象模型包含:
- 代码
- 数据
- 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 架构上就是从 FS 或 GS 寄存器基址加上一个固定的偏移量来读取地址。
; 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 在概念上拆成两大块:
PHP VM shared:类、函数、常量、ini 指令、可执行代码。
PHP VM movable:需要被移动的那部分 VM 状态。

部分结构可以标记为 shared,部分标记为 movable;甚至 Executor Globals 也可以拆分成 shared/movable 部分,这样就能高效地在多线程间移动 VM 状态。扩展的全局结构不会因为额外的间接访问而损失性能,因为它们本就这么做。
问题在于与代码编译相关的结构,因为 PHP 具有 include/require、eval、自动加载等动态特性。正是这些特性使得高效地拆分 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 概念完全等价。这种在多线程间传递内存的方式有两大优点:
- 安全性:仅一个线程拥有对象。
- 无复制/序列化开销。
为了让行为可预测并便于静态分析,可以加上专门的移动语法,例如:
$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] 现在指向另一线程的内存
// 悬垂指针!
要保证安全移动,需要:
- 完整遍历图 —— 检查所有子对象。
- 检查 refcount —— 图中每个对象都要查。
- 保持身份 —— 图内的重复引用必须保持重复。
我们可以设计多个算法来做这件事,可以称之为 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。
最终结果是:
- PHP 开发者无需关心对象是如何被送到其他线程的。
- 最佳情况:内存被直接移动(
refcount = 1)。
- 最差情况:使用
deep copy 复制内存并保持对象身份(refcount > 1)。
看起来不错:
- PHP 语法改动最小。
- 变更可以逐步进行。
- 多线程变得可用。
但在内核层面,并非一片光明。要让对象移动成为现实,PHP 需要跨线程的内存管理机制,而目前尚不可行。
多线程版 PHP 内存管理器
PHP 的内存管理器类似于现代分配器,如 jemalloc 或 tcmalloc。区别在于,它缺少从其他线程正确释放内存的算法。
考虑以下场景:
- 对象在线程
A 中创建。
- 通过移动(原样)传递给线程
B。
- 在
B 中不再需要,应该被释放。
每个 PHP 线程都有自己的 Memory Manager (MM)。当 B 试图释放 A 分配的内存时,问题就来了:B 的 MM 不认识 A 的内存,直接释放会导致错误。从 B 直接访问 A 的 MM 结构也不可行,这需要同步机制。现代高性能多线程分配器使用延迟释放(deferred free)来解决这个问题。
deferred free 的思路是:
B 的 MM 发现一个不认识的指针。
- 它找出该指针属于哪个 MM,然后向那个 MM 的队列发送一条“可释放”消息。
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)用于处理循环引用。引用计数在每次赋值时增减,这在无同步的情况下无法安全地用于多线程。每次赋值都使用原子操作,开销巨大;不使用同步又会产生竞争和泄漏。循环收集器虽然名为“并发”,却只在单线程内工作,它使用颜色标记(PURPLE → GREY → WHITE/BLACK)来寻找循环,这同样不是线程安全的。
好消息是,当前的 GC 实现与内存管理器是解耦的,不依赖内存分配的位置,因此在多线程环境中,其核心算法原理上可以工作。
但如果 PHP 想进入 stateful 多线程时代,GC 需要进一步适配,以解决:
- 能在单独线程中并行工作,不影响业务代码。
- 尽可能快地释放资源。
- 提供额外工具来发现/记录内存泄漏、进行性能遥测(对长时间运行的应用尤为重要!)。
循环收集器可以被修改为在单独的线程中处理引用图,从而提高整体响应性。作为一个开始,这可能已经足够了!
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 哲学。
上面的示例看起来像是在协程里运行的普通顺序代码。但因为 postMessage 和 getMessages 实际上在另一个线程执行,协程会向 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);
}
}
enqueueActorMethod 把 postMessage 调用放入 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 时间,从而提供并发和并行执行的能力。

结语
这些听起来都很美好,但何时能看到它们在现实中落地呢?你或许会这样问。
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/