
“共享无物” 是现代系统背后的架构原则,由 Go 的最佳实践所倡导,并在 PHP 的 parallel 扩展中强制执行。但为什么它不是默认的呢?为什么我们仍然将共享内存线程作为编写并发代码的“正常”方式来教授?
我们对正确性和效率的看法深受我们使用的 API 影响;但当 API 的发明与其(广泛)部署之间相隔 10 或 15 年时,会发生什么?——摩尔定律如何改变我们对正确性的认知或对效率的衡量?
我们使用针对配备 8 MB RAM 和 100 MHz 处理器的计算机而设计的 API 来教授并发编程。
如今,我们将这个 API 部署到配备 64 GB RAM 和多 GHz 多核处理器的系统上。这比 API 设计时的内存容量多出 8000 倍。
POSIX 线程 API (pthreads) 于 1995 年标准化。它直到 2007 年才达到市场饱和度。在 1995 年至 2007 年间,摩尔定律彻底改变了这一切。塑造该 API 的那些约束条件已经消失了。但 API 及其所创造的心理模型却留存了下来。
这就是我们如何将并发编程搞反的故事。
首批对称多处理 (SMP) 系统很难精确界定,这取决于你如何定义“首批”。
首批系统采用多处理器配置——即,每个封装内有一个核心。
大约在 1989 年:https://en.wikipedia.org/wiki/Compaq_SystemPro
这台机器可以运行两个 33 MHz 的 Intel 386/486 处理器,通常配置有 4–32 MB 的高速旋转 RAM;这是一种可识别的服务器架构(这种架构不久后将被部署来支撑互联网)。
这台机器早于 POSIX 线程 API 的标准化:IEEE Std 1003.1c-1995。
1995 年,我们使用的处理器(即占据市场多数份额的)是 P5/Pentium Pro 处理器——仍然是单核,时钟速度约为 60–200 MHz,RAM 仍然相当昂贵,因此常见的配置是 8 MB–32 MB。
这就是 POSIX 线程 API 及其相关知识进入软件工程世界的背景:处理器仅有几百万个晶体管,指令周期极其缓慢,内存昂贵且极其有限。在这样一个世界中,线程必须是轻量级的(即共享地址空间),API 就是在这样一个高度受限的环境中形成的,并非因为它是实现线程的最佳方式供程序员使用,而是直接源于当时硬件现实的严重限制。
让我们将“饱和点”定义为单封装 SMP 系统占据市场多数份额的时刻——即,主导市场。
大约是 2007 年;首批主导市场的处理器是 Intel Core 2 Duo:拥有几亿个晶体管,时钟速度约为 1.8 GHz(或更高),可用 RAM 有几 GB,配备 2 个核心。
如果我们今天来编写一个 12 年前就已标准化的 API,它会是什么样子?在内存和处理能力增加几个数量级的机器上共享地址空间,是否真的是你会做出的选择?
当我们部署首批 SMP 系统(标准化之前)时,线程代码在每个层面都非常困难——从程序员的角度来看,你必须编写代码来针对并行处理器,并非所有任务都能卸载到主处理器之外的并行处理器上。
当 API 于 1995 年标准化,从而共享地址空间和安全执行所需的基本原语变得人人可用时,线程代码变得更容易编写了,但我们仍在谈论一个严重受限的硬件景观。由于这些限制,它没有空间被广泛部署——最多你可能期望同时执行两个线程,虽然 API 在表面上减少了一些复杂性,但成本本质上并未改变。
在饱和点,我们拥有远更强大的机器,每一个能够利用并行并发的程序都被期望这样做。API 以及随之而来的关于“什么才是正确”的知识来自一个不同的世界。
编写正确的线程代码需要互斥锁、信号量、仔细的锁排序。程序员学会了用临界区和竞态条件来思考。这种心理模型变得如此根深蒂固,以至于我们仍然将其教授为思考并发的唯一方式——尽管硬件已经发生了如此剧烈的变化,以至于一种完全不同的模型(共享无物的消息传递)现在在程序员生产力、错误率以及实际性能的任何衡量标准下都更“正确”。
直到 2012 年,我首次发布 pthreads(PHP 扩展)时,“PHP 无法进行线程化”是一个公认的事实。
这种对可能性的简单化观点源于 PHP 的架构,我们称之为“共享无物”。这种架构源于需求以及 PHP 开发时的硬件现实——每个解释器实例与其他实例隔离只是合情合理的;这就是 Web 服务器的工作方式,这就是 CGI 接口(高度依赖环境变量)所假设的。
在首次发布 pthreads 之前,PHP 长期以来就有定义的(并广泛部署在 Windows 上)线程模型,而 pthreads 必须在该架构内工作——因此,虽然看起来你是在共享内存,但你实际上无法共享内存,因为这会违反假设和架构不变量,从而导致进程不稳定和崩溃。尽管如此,你编写的代码仍需要部署并发原语(互斥锁、同步)来确保正确性,因为虽然扩展确保了架构正确性,但代码级正确性仍是程序员的责任。
pthreads 并未被广泛部署。我已经证明了“PHP 无法进行线程化”的技术观点是错误的。然而,API 笨拙——被你正在共享内存的印象所负担,而实际上并没有共享内存。
关于并行并发真正含义的空气中也弥漫着很多困惑。
一个必要的解释性旁白:
PHP 程序员熟悉的并发范式是异步并发,虽然一般来说并行并发与异步并发之间的区别是理解的,但我们仍然发现“异步”和“并行”这两个词被互换使用——它们当然不是一回事。
要理解区别,想象你有三个离散任务要执行,这些任务的性质对解释无关紧要:
同步执行
我们都熟悉这种模型。任务线性执行(按调用顺序),在单个线程(列)中。
异步执行
任务相互交错(在 PHP 的情况下是合作性的),仍在单个线程(列)中。
区别特征是任务相对于彼此并发执行。
并行执行
区别特征是任务相对于时间在单独的线程(列)中并发执行。
我从 pthreads 中犯的错误中学到了教训;当并发主题(尽管是异步形式)在内部讨论中被严肃提出时,我被激励创建了 parallel。
parallel(2019)借鉴了 Go 的并发模型哲学,它实现了基于 CSP(通信顺序进程)的 API(通道)。
Go 中经常引用的习语(并被 parallel 借鉴):
不要通过共享内存来通信;相反,通过通信来共享内存。
这完全颠倒了代码“正确”的含义。在 Go 中,可以共享内存,goroutine 共享地址空间,但 Go 程序员仍努力遵守这个习语,即这是编写并行代码的错误方式。
在 parallel 中,由于架构约束和不变量,不可能共享内存——线程读取另一个线程拥有的内存的唯一时间是在非缓冲通道读取上(并且此读取是排他性的)——这消除了并行代码中的整个bug 类:
代码通过构造而正确,而非通过纪律。
敏锐的读者会立即注意到,PHP 长期以来似乎通过 APC/Opcache 共享代码和数据,并可能好奇我们如何称其为“共享无物”。
共享无物是架构设计中的原则,而非规范。
多年来,实现细节发生了剧烈变化:在 APC 时代,当 PHP 将代码或数据复制到共享内存缓存时,数据在缓存中保持不可变。在请求时,它被复制出缓存并进入使用请求绑定内存分配的符号表,作为可变数据。
即使 Opcache/Zend Optimizer 被合并到 PHP 中,它仍然需要将代码从共享内存复制到请求绑定内存。
pthreads 必须执行相同的复杂且昂贵的复制,当线程启动时,它复制每个符号。
直到 PHP 7.4,Zend 才引入不可变符号——这意味着存在于共享内存中的代码可以就地执行,避免复制到请求绑定内存——代码在缓存中仍是不可变的,但在请求中是可变的,我们使用指针映射来实现这一点。
parallel 依赖这些进步,并避免了复制整个符号表的陷阱:相反,在加载 opcache 的地方,它从其共享内存提供不可变共享代码,在未加载 opcache 的地方,parallel 将提供相同的不可变性,在两种情况下符号不再为每个线程复制。
共享无物的架构原则实际上并不禁止实现安全并行并发所需的共享,而且一直如此。
此外,如果我们在饱和点编写底层 API,我们很可能已经拥抱了这个架构原则(Go 做到了,尽管有点晚)。
我们已经反过来教授并发 30 年了。
心理模型——互斥锁、锁、临界区、竞态条件——源于 1995 年的硬件约束。
这些约束在 API 甚至达到市场饱和度之前就消失了。Go 社区意识到了这一点,尽管语言允许共享内存,但仍将共享无物作为最佳实践。
但为什么良好的并发需要纪律?
共享无物应该是默认的,而不是例外或理想。
硬件支持它。它防止的 bug 是真实的。唯一阻碍我们的是一个化石化的 API 及其周围构建的知识。
是时候将我们的默认更新以匹配我们实际拥有的硬件了。
parallel 拥抱这些默认,因为 PHP(意外地)使其必要,后来又高效:当你使用 parallel 编写代码时,你不是在绕过任何东西,或执行任何技巧因为 PHP 是共享无物,正相反:你正在部署专为当前硬件现实设计和适合的尖端 API。
文档:https://php.net/parallel