
为什么要写这篇文章?
在 RFC TrueAsync 1.7 中,一个问题浮现出来:这个提议中的 RFC 将如何与 PHP 核心未来可能的变更相互作用?至少了解 PHP 可能的发展方向,对于多年来良好地设计语言至关重要。这就是本文存在的原因。
TrueAsync 项目不仅仅是 PHP 核心针对异步的变更,还包括回答以下问题所需的其他研究:
- PHP 能在多线程方向上走多远?
- 是否存在根本性的限制?
- 实现真正多线程可能需要哪些核心变更?
- 哪些语言抽象可以实现?
我并没有试图将本文写成对 PHP 多线程各个方面的详尽综述,也不追求在每一个技术细节上精确无误,或让广大读者都能理解。不过,我希望本文能对许多 PHP 开发者有所帮助,并为后续讨论指明方向。
历史
几年前,当我们需要向一个 PHP 应用添加高容量遥测功能时,我说这是不可能的。但在看到 Swoole 的架构后,我想测试一下这个说法。我们能否创建一个生成并处理大量数据而又不拖慢客户端的 API?
我们为 PHP 构建了一个优化的 OpenTelemetry,它分块写入数据,收集成大块后发送到中间遥测服务器。数据被压缩,JSON 结构用 MessagePack 序列化。
主要的假设是:如果使用单线程协程,我们可以逐步构建遥测数据,并定时或在达到大小阈值时将其发送到服务器。代码应该很快,因为没有跨线程交互。这是真的吗?
实验结果显示,遥测使 API 吞吐量下降了一半。假设失败了。但为什么呢?在 概念 层面,一切似乎都合情合理。Swoole 已经让 PHP 函数变成非阻塞,所以协程应该是高效的。我们在某处犯了错。
在第二个版本中,遥测数据仅在单个请求期间收集,然后立即丢进一个任务进程,由它聚合、压缩并发送到服务器。这个方案性能要好得多。但这不应该啊!还是说,理应如此?进程之间的数据通过 pipe 发送,在一端序列化,在另一端反序列化。即使管道驻留在内存中,操作系统调用也是昂贵的。
后来我们找到了原因:遥测数据量很大,因此相对于处理 API 请求,压缩消耗了大量 CPU 时间。因此,即使 Swoole 协程对 I/O 高效,它们也无助于 CPU 密集型任务。
这个案例是众多表明单线程协程并不能解决所有问题的例子之一。它也展示了多线程如何能补充协程,为广泛的问题域创建一套工具集。
单线程 + 卸载
将 CPU 密集型工作卸载到单独的进程并不是什么“新发明”。它是一种更广泛模型的一部分,该模型独立地出现在不同的语言和框架中,被称为 单线程 + 卸载。
想象一个人快速分拣信件(每小时数千封),而沉重的包裹则由其他员工装上卡车运走。如果分拣员开始搬运包裹,会发生什么?信件队列会堆积到天花板。
单线程 + 卸载 模型将任务分为两类:
-
I/O 密集型任务——读取文件、网络调用、数据库访问。大部分时间进程都在等待外部世界。通过并发异步(
协程、await),数千个此类操作可以塞进一个线程。 -
CPU 密集型任务——压缩、加密、解析、计算。此时 CPU 满负荷运行,仅靠并发是不够的——你需要更多内核。
该模型将这些任务物理上分开:主线程(事件循环)只处理 I/O,而 CPU 任务被发送到单独的线程或进程(工作线程)。
Node.js 曾以其单线程 事件循环 闻名,非常适合网络应用。但当开发者试图在请求处理程序中直接处理图片或压缩视频时,服务器就变成了南瓜。解决方案是 Worker Threads——用于 CPU 密集型操作的独立线程。
Python 走了类似的路。随着 asyncio 的出现,Python 获得了处理 I/O 密集型代码的强有力工具,但内置的 GIL(全局解释器锁)阻止了单进程内的真正 CPU 并行(该问题在本文写作时已部分解决)。对于阻塞操作,出现了 loop.run_in_executor() 和 asyncio.to_thread()(自 Python 3.9 起),用于将繁重的工作卸载到线程池或进程池。事件循环保持响应,而计算并行进行。
PHP/Swoole 基于相同的架构:请求工作线程 使用协程处理 HTTP 请求,任务工作线程 执行繁重计算。通过 UnixSocket 或 pipe 进行通信,一个进程可以处理大约每秒 10 万次操作。
该模型的优势
1. 资源效率
单线程事件循环可以以最小的开销服务数千个并发 I/O 操作。协程任务之间的切换比操作系统级别的线程上下文切换更廉价。CPU 密集型 任务在多个内核上获得真正的并行——每个工作线程加载自己的内核,不会干扰其他线程。
2. 开发简单性
事件循环中的代码不需要互斥锁、信号量或其他多线程编程的乐趣。在单线程模型中,同一时间恰好只有一个任务在运行,因此竞态条件是不可能的。工作线程 并行运行,但如果你遵循 共享无物 原则,同步问题就不会出现。
多线程代码与单线程异步代码在复杂性上的差异是巨大的。现代语言和框架不出意外地倾向于单线程异步,而不是经典的多线程。
3. 更简单的编译器/运行时
单线程模型中的异步函数对编译器和运行时来说要简单得多。一个好的支持多线程的语言需要自己的代码生成管道。PHP 有一个严重的限制:部分代码是用 C 写的。这阻止了针对线程、内存管理、参数传递的高效字节码级优化。Go 的设计以复杂著称:专有栈、精密的垃圾回收器。这些对于高效的 goroutine 和 channel 都是必需的。我们稍后会讨论 PHP 的 GC,所以现在还不能放松!
4. 手动负载分配
开发者可以有意识地将负载分配给请求处理代码和工作者池代码。手动控制可以让你从硬件中榨取理论最大值。另一方面,这也是一个缺点。
该模型的劣势
1. 手动负载分配
手动分配是一把双刃剑。开发者可以针对特定任务进行优化,也可能误判什么属于 I/O 代码、什么属于工作线程。I/O 代码可能因繁重工作而过载,降低响应性并增加延迟。
该模型要求 PHP 开发者具备足够的技能,或者依赖技能娴熟的框架作者及其准备好的解决方案。
2. 并非适用于所有任务
单线程 + 卸载 非常适合 Web 服务器、API、微服务,这些场景的主要负载是与数据库、文件系统和网络调用相关的 I/O。但对于每一步都需要密集计算的工作负载——科学计算、渲染、机器学习——该模型可能效率较低。全多线程在那里更合适。
你可能会说:我们能接受!我们已经准备好了!但 PHP 本身准备好成为多线程了吗?
PHP 准备好迎接多线程了吗?
在开发 TrueAsync 的过程中,最艰难的讨论之一是“为什么 PHP 没有异步”。解释为什么 PHP 没准备好迎接多线程可能会遇到类似的困难。不过,首先让我们谈谈多线程。为什么我们需要它,或者更准确地说:为什么我们不需要它?
多线程并非并行执行代码所必需。
多线程是并行性所必需的想法很久以前就在程序员心中扎根,就像“黑洞会‘吸入’物质”的想法在流行文化中扎根一样。
并行执行由进程处理得很好,进程之间相互隔离(自 80386 架构起)。进程可以通过 IPC 通信,它们的完成可以通过信号(操作系统事件)跟踪。那么,我们为什么需要线程呢?
要诚实回答,我们得回到过去,邀请那些做决策的人,让他们解释:Edsger Dijkstra、Fernando Corbató、Barbara Liskov、Richard Rashid。我们可以办一个很棒的脱口秀。但即使他们同意,我们可能仍然得不到一个直接的答案。
说下面这样的话是不正确的:
需要线程是为了让并行代码无需额外工具就能共享内存。
进程也可以共享内存,但你需要将一段内存映射到地址空间(额外工具)。线程默认共享所有内存。字面上,如果线程 A 中有一个可访问的变量 x,那么在线程 B 中它就在相同的地址,无需任何技巧…… 但是不!多个线程不能安全地处理共享变量而无需额外工具。
更诚实的说法是:
需要线程是为了在任务之间传递内存而无需额外开销。
如果线程使用内存来传递消息,使得在任意时刻只有一个线程保证对给定内存区域的访问权,那么这在内存和 CPU 上都是最高效的。线程会刻意避免会被共享的内存。这种模型称为 共享无物。
线程用于任务之间的高效数据传输。就像“黑洞不吸”一样真实。
PHP 内存模型
PHP 如何处理内存?一个简化的抽象模型:
- 代码
- 数据
- PHP VM 状态
在线程之间共享 PHP 代码的能力已经存在(通过 PHP JIT 解决)。其他组件紧密耦合,无法轻易拆分。例如,PHP 使用一个全局的 object_store,它持有对所有已创建对象的引用。PHP 的内存管理器被设计为与单个 PHP VM 中的对象协同工作,并不面向多线程。PHP 垃圾回收器无法处理来自不同线程的数据,甚至需要完全停止 VM,因为它直接修改对象的 refcount。
所以 PHP 是一个严格的单线程模型,带有 stop-the-world GC。
在线程之间移动 PHP VM
PHP 使用线程本地存储(TLS) 来保持每个线程的 VM 状态。这对于在 ZTS(Zend 线程安全)模式下的线程隔离至关重要。
在现代 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 共享的。类、函数、常量、ini 指令、可执行代码。 -
PHP VM 可移动的。需要移动的 VM 部分。
*PHP 内存模型*
某些结构可以标记为 shared,其他标记为 movable;甚至 Executor Globals 也可以拆分为共享和可移动部分,以实现 VM 状态在线程之间的高效移动。扩展的全局结构不会因为额外的间接层而损失性能,因为它们已经在使用间接层了。
与代码编译相关的结构会带来问题,因为 PHP 通过 include/require、eval 和自动加载是动态的。这些特性使得很难将 VM 状态高效地拆分为共享和可移动部分。如果我们解决了这个问题,PHP 可以以最小的开销在线程之间移动部分 VM 状态。
在线程之间传递对象
PHP 需要做哪些改变才能安全地在线程之间传递对象?这如何实现?
让我们从语言层面着手。假设我们在 $obj 中有一个 SomeObject 实例,需要将其发送到另一个线程。这可能吗?
$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 未定义
$thread->join();
上面的代码是移动语义的 100% 模拟,该语义最近出现在 C++ 中,并存在于 Rust 和其他语言中。这种在线程之间传递内存的方式提供了:
- 安全性。只有一个线程拥有该对象。
- 无复制或序列化开销。
为了使行为可预测并且可被静态分析器读取,我们应该添加特殊的移动语法,例如:
$obj = new SomeObject();
// consume $obj 表示移动对象
$thread = new Thread(function () use (consume $obj) {
echo $obj->someMethod();
});
// 此处 $obj 未定义。在 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] 现在指向另一个线程中的内存
// 悬挂指针!
安全移动需要:
-
完整图遍历→ 检查所有嵌套对象。
-
引用计数检查→ 针对图中的每个对象。
-
标识保留→ 图中的重复项必须保持为重复项。
我们可以为这种操作设计算法;称之为深拷贝。一个简单的实现可能像这样:
// 深拷贝伪代码
// 线程 A 中的源图
$node = new Node('A'); // addr: 0x1000
$tree->left = $node; // addr: 0x1000
$tree->right = $node; // addr: 0x1000 (相同引用)
// 深拷贝到线程 B(使用 MM 的伪代码)
$copied_map = []; // 哈希表:源地址 -> 目标地址
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)
// 标识保留!
深拷贝的时间复杂度:O(N + E),其中 N 是对象数量,E 是引用数量。空间复杂度:O(N)——哈希表 + 新对象 + 递归栈。
与序列化相比,这可能是更快的拷贝,因为它避免了与传输格式之间的转换,但收益取决于数据形状和图的大小。你还可以构建混合方案:移动 refcount = 1 的数据,对其他情况运行 深拷贝。
结果:
- PHP 开发者无需思考对象如何传递给另一个线程。
- 最佳情况→内存被移动(
refcount = 1)。 - 最坏情况→内存通过
深拷贝 复制,同时保留标识(refcount > 1)。
看起来不错:
但在核心层面,并非一切乐观。要使对象移动成为现实,PHP 需要某种跨线程的内存管理机制。目前这是不可能的。
多线程 PHP 内存管理器
PHP 的内存管理器类似于现代分配器,如 jemalloc 或 tcmalloc。不同之处在于:它缺乏正确的从另一个线程释放内存的算法。
考虑:
- 一个对象在线程
A 中创建。 - 它通过移动(原样)传递给线程
B。 - 在
B 中,该对象不再需要,应该被释放。
每个 PHP 线程都有自己的 内存管理器 (MM)。当 B 试图释放 A 中分配的内存时,就会出现问题。B 的 MM 对 A 的内存一无所知,释放它会导致错误。从 B 直接访问 A 的 MM 结构是个坏主意,因为它需要同步。现代高性能多线程分配器通过延迟释放解决这个问题。
延迟释放 的一般思路:
-
B 的 MM 看到一个未知的指针。 - 它找到哪个 MM 拥有该指针,并向该 MM 的队列发送一条消息,说明该指针可以被释放。
-
A 的 MM 处理队列并在自己的上下文中释放指针。
*跨线程延迟释放*
使用现代的无锁结构,该算法具有高吞吐量,允许不同线程并行释放内存,并且几乎避免了锁。
多线程 PHP 内存管理器为其他以前不可能的变更打开了大门。
共享对象
能够以最少的操作将内存从一个线程传递到另一个线程是很棒的,但如果我们能创建从一开始就设计为跨线程共享的对象呢?
许多服务可以构建为不可变对象,因此它们应该在进程之间共享,以节省内存并加速工作线程启动。
不幸的是,refcount 成了障碍,因为它实际上使得所有 PHP 对象都是可变的!我们能避免吗?
代理对象
第一种方法是代理对象,它们引用存储在共享内存池中的真实对象,所有线程都可以访问该内存池。代理只持有真实对象的标识符或指针,以及访问其数据的方法。缺点:
- 访问数据/属性的时间增加
- 为
Reflection API 和类型检查增加了复杂性
另一方面,PHP 已经有一个强大的创建代理的机制。在某些情况下,代理共享对象是一个不错的选择,例如用于计数器表或像 Swoole/Table 这样的数据表。
带有 GC_SHARE 标志的共享对象
PHP 有一个内置的不可变元素机制,通过 GC_IMMUTABLE 标志实现。它用于:
-
驻留字符串 (
IS_STR_INTERNED) —— 在整个 PHP 进程生命周期内存在的字符串常量 -
不可变数组 (
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 并不理想,因为当许多线程访问同一个对象时可能导致减速。延迟释放算法可能更合适。
基于区域的内存
基于区域的内存目前在面向 Web 的语言中很流行,用于内存管理。
其思路是:为特定任务或线程分配内存到单独的区域,当不再需要时可以整体(或几乎整体)释放。这避免了每个对象的管理复杂性,并简化了 GC。
例如,PHP MM 可以保证对象在绑定到特定 PHP 对象的区域中创建。该区域的生命周期等于对象的生命周期。
当对象被销毁时,整个区域可以被释放而无需遍历子对象。如果这样的对象需要被“移动”到另一个线程,你可以避免深拷贝。
PHP VM 在实现基于区域的内存方面存在问题:例如全局对象列表、opcode 缓存。但高效实现的可能性并非为零,值得进一步研究。
一个可工作的基于区域的内存算法为实现 actor 打开了大门——actor 是具有隔离内存的特殊对象。
Actor 是多线程编程中最方便、最强大、最安全的工具。
协程与线程并存
从协程的角度来看,Thread 是一个 Awaitable 对象。协程可以等待一个 Thread 的结果而不阻塞其他协程。因此,一个线程可以托管许多等待繁重任务的协程。服务于它们的线程可以快速响应新请求,因为等待 Thread 不会阻塞事件循环。
use Async\await;
use Async\Thread;
$thread = new Thread(function() {
// 此处进行硬件密集型任务
return 42;
});
$result = await($thread);
// 协程在此暂停,直到线程完成
这种方法可以实现一个包含 CPU 密集型任务和简单业务逻辑的聊天场景。
*线程 + 协程架构*
图表展示了一个示例架构。应用有两个线程池:具有并发多任务处理的请求处理线程,以及用于 CPU 密集型任务的工作线程。一个协程处理请求,可以在工作线程执行繁重任务时完全暂停,然后继续。
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 跨越线程,结果字符串不会在内存中被复制两次。
垃圾回收器与有状态模式
为了在多线程环境中改进语言,现代化 PHP 的内存管理器并非唯一需要的改变。如果没有高效的 GC,多线程 PHP 将遭受性能问题和循环导致的内存泄漏。
PHP 的 GC 使用两种算法:引用计数作为主要的内存管理机制,以及并发循环收集(Bacon-Rajan, 2001)用于处理循环。引用计数在每次赋值时递增/递减,这使得它对于没有同步的多线程不安全。每次赋值都使用原子操作将是巨大的开销;没有同步会导致竞态和泄漏。循环收集器虽然被称为“并发”,但它只在一个单线程内工作,并使用颜色标记(紫色 → 灰色 → 白色/黑色)来查找循环,这也不是线程安全的。
好消息是:当前的 GC 实现在多线程设置中可以工作,因为它与内存管理器分离,并且不依赖于内存分配在哪里。
但如果 PHP 想要迎来有状态应用的多线程时代,GC 必须适应:
- 在单独的线程中并行运行,而不影响业务代码。
- 尽快释放资源。
- 为泄漏检测、日志记录和遥测提供额外工具(特别是对于长时间运行的应用!)。
循环收集器可以通过在单独的线程中处理引用来修改为在多线程环境中工作,从而提高整体响应能力。这或许足以作为开始!
Actor
ThreadPool 和在线程之间传递对象是有用的,但它们需要开发者投入注意力、技能和努力。有一种更好的多线程编程抽象,它隐藏了线程/内存的复杂性,并能完美适应业务逻辑:actor。
Actor 是一种并发并行编程模型,其中计算的基本单元是一个 actor。
每个 actor:
- 拥有自己的隔离状态
- 顺序处理消息
- 仅通过消息与其他 actor 交互
- 可以在单独的线程中运行
你可以将 actor 视为一个对象,这使得在多线程 PHP 中使用熟悉的 OOP 模式成为可能。
想象一个包含许多房间的聊天服务器。每个房间都是一个独立的对象。
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 对象可以在单独的线程中安全运行。类类型不仅存储方法和属性的信息,还存储 MM 和 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,并且可以跨线程交互:
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 直接关联的区域自动绑定到它。
方法通过由 调度器 服务的 MPMC 消息队列执行。调度器 在 actor 之间分配 CPU 时间,提供并发和并行执行。
*Actor 方法调用流程*
结论
这一切听起来很棒,但什么时候才能真正看到呢?你可能会问。
单线程 + 卸载 模型可能在不远的将来出现,因为许多组件已经就绪。TrueAsync:单线程协程已达到 beta 阶段。实验性的多线程内存管理器以及用于创建线程的 API 已经实现。
Actor 需要更多的开发时间,因为它们触及 PHP 核心的许多部分,但它们仍然是 PHP 9 的一个现实目标,为市场提供一种安全的多线程编程语言。