即将到来的低延迟、并发与并行特性¶
标题:Interesting Upcoming Low-Latency, Concurrency, and Parallelism Features
日期:2026/03/03
作者:Paul E. McKenney, Maged Michael, Michael Wong
链接:https://www.youtube.com/watch?v=M1pqI1B9Zjs
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:并发队列和 zap(啥玩意)我不敢评论,但是 std::simd 我敢说它只是玩具罢了。
备注二:看了眼标准,终于提供基本的 shuffle 操作了,不算过于玩具。
好的,我想时间到了。欢迎来到这个关于“有趣的即将到来的低延迟、并发与并行特性”的专场,特别是过去三次 C++ 标准会议中讨论的内容。我们三个人自从在 IBM 共事以来,一直非常紧密地在低延迟、并发和并行领域合作,这种合作一直延续至今。我叫 Michael Wong,将和我的同事 Paul McKenney 以及 Maged Michael 一起探讨这三个主题。
我们之所以挑选这些特性,是因为它们非常有趣。其中一些很有可能在未来进入标准,而且它们的体量刚好适合放在一次演讲中介绍。所以我将讨论 SIMD。Maged 将讨论并发队列(Concurrent Queues),然后 Paul 会上台讨论“生命周期结束指针失效(Lifetime End Pointer Zap)”。
需要说明的是,第一个特性(SIMD)已经在 C++26 中了,目前正在进行投票。第二个特性,并发队列,在停滞了许多年之后取得了长足的进步,也许它会在未来的某个时间进入标准。我们认为并发队列还有很大的发展空间,因为并发队列可能会变得非常混乱——你知道,有许多不同的种类,我们欢迎任何人提出改进建议,甚至分享他们自己的并发队列设计。至于“生命周期结束指针失效”,我知道你们从 2019 年就开始听这个故事了。从 2019 年到现在,我们终于要迎来结局了,而且这个结局听起来是切实可行的,也是大多数人能够理解的。好了,让我们进入正题。
SIMD (Michael Wong)¶
好的,接下来我来谈谈 SIMD。你们当中有多少人编写过特定平台的 SIMD 代码?有人写过特定平台的 SIMD 代码吗?请继续举手。如果你“享受”在许多不同的架构上维护它……关键词是“你享受维护它”。好吧,很多手迅速放下了。
多年来,我们的 CPU 拥有这些强大的并行处理能力,但以一种标准的、可移植的方式访问它们一直很困难。随着 C++26 的到来,这一切即将改变。今天,我们将探讨 C++26 的 SIMD 如何最终将我们从 #ifdef(条件编译)的泥潭中解放出来。
为了这页 PPT,我不得不极力压缩内容。我已经使用 SIMD 很多年了,因为我曾在 IBM 工作,后来在 Intel 工作,然后再做了一段时间的 RISC-V。所以,从 1997 年的 MMX、SSE,到 1999 年的 AltiVec,那就是我开始接触 SIMD 的时候。是的,我就是那么老。SSE 是 PowerPC 世代的产物。然后有了 SSE 2、3、4,接着 ARM 推出了 Neon,然后 Intel 和 AMD 联合创造了 AVX、AVX2,最后是最新的 AVX-512。当然,它最新的变体是来自 RISC-V 的“可伸缩向量扩展(Scalable Vector Extension, SVE)”,它的宽度可以从 128 位扩展到 2048 位。
但从根本上说,SIMD 的概念在过去几十年里有了显著的演变。随着每一代新产品的出现,向量寄存器都在不断变宽,并增加了更强大的操作。我们将要讨论的进入标准的 SIMD 库,其设计初衷就是为了在这些不同的硬件指令集之上提供一个可移植的抽象。
如果你曾经盯着一个关键循环,只是祈祷编译器能将其向量化,你就会懂那种痛苦。自动向量化是一项极好的技术,但它很脆弱,而且不提供任何保证。另一方面,一头扎进特定平台的内建函数(intrinsics)会导致不可读、不可移植的 #ifdef 混乱。我们一直需要一种更好的方法。
随着 C++26 的到来,这个方法终于来了。std::simd 承诺让你写出干净、标准、可移植的 C++ 代码,让你能够显式控制每个现代 CPU 内部强大的 SIMD 硬件。我懂的,在过去 10 到 15 年里,我上台演讲大多是谈论软件并行性。这是我第一次真正向大家强调:你们的 CPU 中存在硬件并行性,而大多数人并没有使用它。
核心原则:逐元素操作 (Element-wise Operations)¶
SIMD 的核心原则很简单,就是逐元素操作。你创建一个 SIMD 对象,它容纳多个值。当你应用像 + 或 * 这样的操作符时,该操作会并行地应用于每个对应的元素。例如这里我们做乘法。所以从根本上说,SIMD 类型的行为就像一个标量(scalar),但每个操作都是同时作用于所有元素上的。这些通常被称为“通道(lanes)”。
我们有 std::simd<T>,它为你提供了一个包含 N 个 T 类型元素的向量。
我们有 std::simd_mask,它为你提供了一个包含 N 个布尔值的向量,通常来自比较操作。
// 概念代码:同时将所有四个价格乘以税率
prices = prices * tax_rate;
这里的关键要点是:你编写的代码看起来很熟悉,但它表达的是数据并行性。
比较操作也是逐元素的,它们不返回单个布尔值,而是返回一个 SIMD 掩码(mask),我们将用它来进行条件逻辑。在这个例子中,我们看看哪些商品是昂贵的。前两个是 false(不昂贵)。最后两个 $39.99 和 $49.99 则是非常昂贵,特别是在当今的世界。
类型转换与宽度一致性¶
处理不同的数据类型是很常见的。最重要的规则是:保持向量宽度(即通道数)一致。 而做到这一点的关键工具,女士们先生们,就是 rebind_simd。
它是你用来实现这一目标的工具。例如,它允许你创建一个与浮点数向量通道数完全相同的整数向量。这至关重要,因为在不同大小的向量之间进行操作会导致编译期错误。通过使用 rebind_simd,你能确保你的类型是兼容的,从而允许高效的转换和操作,而不会受到性能惩罚。
所以,宽度一致性(Width coherence)是 SIMD 性能的关键。利用类型萃取(traits)来创建兼容的 SIMD 类型:
你可以使用
simd<T>,或者使用native_simd<T>,它会自动使用编译器针对类型T推荐的原生宽度。你可以进一步说
simd<T, N>,它允许你指定一个固定宽度 N。或者你可以进一步说
rebind_simd_t<U, V>,它将向量V的元素类型更改为U,但保持相同的宽度。这才是真正的秘制酱料。
无分支计算与条件选择 (simd_select)¶
现在进入我最喜欢的话题之一:无分支计算,即使用 SIMD select 进行无分支的控制流。当我还是一名年轻的程序员时,我们被告知的第一件事就是:不要在 SIMD 类中使用 if 语句。好吧,你也可以用,但如果你真的这么做了,后果会很糟。你不能使用它,因为它会迫使你所有的并行通道走上单一的代码路径。
因此,解决方案就是所谓的无分支计算。传统的 if 语句会产生分支。我们使用掩码(mask)来伪造条件逻辑而不产生分支。这就是 simd_select 语句。
模式是这样的:首先,通过比较创建一个掩码。然后使用 simd_select。它是 SIMD 版本的原生三元运算符(?: 运算符)。它接收一个掩码和两个值,对于每个通道,如果掩码为真则从第一个值中选择,否则从第二个值(假值)中选择。这在发生时没有任何代码分支,保持硬件完全并行,一路狂飙到底。
归约操作 (Reductions)¶
接下来我们需要考虑的是归约操作。这非常非常常见。即使在我是 OpenMP CEO 的日子里,我们也在想方设法搞清楚如何创建归约操作符,而它的语法完全相同。
在你的工作流中会发生什么?在完成并行工作后,你通常需要将结果聚合(累加)成一个单一的数字。这些被称为归约或水平操作(horizontal operations)。std::reduce 将是你执行此操作的主要工具。
这里有一个经典的例子:点积(Dot product)。你逐元素地将两个向量相乘,这是垂直操作,对吧。然后在结果上调用 reduce,将所有的乘积求和成一个标量。这就是横向跨越的水平操作。
库中还提供了其他常见的归约操作,如 reduce_min 和 reduce_max,以及用于掩码的辅助函数,比如 any_of 用于检查是否有任何通道为真。所以在这里你可以看到所有典型的归约函数:用于求和/求积的通用 reduce 工具、用于寻找最大最小值的 reduce_min/max,以及用于分析掩码的 all_of、any_of、reduce_count。语法其实非常简单和精简。
内存交互 (Load & Store)¶
倒数第二件事是内存。现在你必须弄清楚如何将数据移入和移出。我们这里讨论的是基础的计算机科学问题。
你的数据通常开始于像 std::vector 或 std::span 这样的标准容器中。典型的 SIMD 工作流是以等于 SIMD 宽度的块为单位,对数据进行循环。该库提供了加载(load)和存储(store)函数,用于在内存和 SIMD 对象之间移动数据。
部分加载(Partial load)非常重要,因为它能安全地处理当你处于数组末尾时发生的情况——此时你可能没有凑够一个完整向量宽度的数据。你需要部分加载。明白了吗?
这一切都是关于高效地以向量大小的块从内存中加载和存储数据。你拥有:
用于 SIMD 完整块的
simd_unchecked_load。用于当你数据不够时的部分加载(Partial loads)。
对应的存储:
simd_unchecked_store和部分存储。以及为编译器提供性能提示(如:是否对齐?可以转换吗?)的标志(Flags)。
超能力:数学函数支持 (CMath)¶
这是最后一件事,也是一种超能力。这是该库的杀手级特性之一:你不需要去寻找特殊版本的 SIMD 数学操作函数。整个 <cmath> 库都被重载了!
正弦(sin)、余弦(cos)、平方根(sqrt)。它们开箱即用地支持 SIMD 类型。这很神奇,这是一种超能力,因为它意味着你可以将复杂的数学公式直接转化为可读的、高性能的 C++ 代码。
在这里,我们正在一次性验证一整个向量数字的三角恒等式(如 $sin^2(x) + cos^2(x) = 1$)。结果是一个全为 1 的向量,证明该恒等式对我们所有的数据点都成立。
性能基准与过热降频陷阱 (Thermal Throttling)¶
这有用吗?绝对有用。基准测试表明,使用完全相同的代码,在不同的领域和硬件平台上都能实现一致且显著的加速。以下数据来自提案论文:
图像模糊:快 5 倍
矩阵乘法:快约 5 倍
光线追踪:快 6 倍
音频处理:快 4 倍
但有一个重要的注意事项:功耗(Power)。
SIMD 单元,尤其是像 AVX-512 这样较宽的单元,是耗电大户。全速运行它们会导致 CPU 升温,并自动降低时钟频率(降速)以保持在热力学限制范围内。这被称为“过热降频(thermal throttling)”。
这非常糟糕,因为这不仅降低了你的 SIMD 单元频率,同时也降低了你所有其他指令的运行频率。你可以管理这一点。然而,如果你发现你的应用程序正在降频(如果你满负载运行 SIMD 一段时间就会发生这种情况,这不是那种只跑一下就完事的玩具测试用例),你必须进行控制。
你确实拥有控制权,即请求一个较小的向量宽度。这就是答案,这就是解决方案。这让你用峰值指令性能去换取更高的持续时钟频率,这有时候反而是净收益。
CPU SIMD vs GPU SIMD¶
人们经常问的一个大问题是:在 SIMD 领域,你如何比较 CPU SIMD 和 GPU SIMD?由于我来自为 GPU 编写异构计算代码的背景,我绝对可以给你们一个关于它们之间异同的视角。
一个快速但重要的澄清:std::simd 是一项 CPU 技术。虽然 GPU 在硬件上也使用 SIMD 原理,但它们的规模以及被称为“SIMD 之上的 SIMD”的软件编程模式是完全不同的(在 GPU 中,它被称为 SIMT:Single Instruction Multiple Thread,单指令多线程)。
它们有着根本的区别。CPU 是关于使用少数智能核心来降低延迟。GPU 则使用一支由简单核心组成的庞大军队来最大化吞吐量。
关键要点是:对于加速 CPU 上的任务,SIMD 是正确的工具。对于 GPU,你将不得不求助于专用的框架。我们称之为 CUDA、SYCL(我以前是 SYCL 的主席)、HIP。这就是为什么你需要这些庞大框架的原因。在硬件层面,你说的没错,GPU 核心的计算单元确实是 SIMD 单元,原理上与 CPU 中的非常相似。而在 GPU 的这层底层 SIMD 架构之上,构建的是 SIMT 编程接口。
让我们打个比方。CPU SIMD 让快速的核心变得更快;GPU SIMD 使用许多慢速核心来实现大规模的并行吞吐量。这取决于你的使用场景。在计算机科学中,答案几乎总是“看情况(it depends)”。
它们不是竞争技术,而是互补的工具,旨在解决不同类型的计算问题。
这里我尝试用一个 CPU 的比喻:想象一下厨房里的一位主厨。这位主厨技术极其高超。他基本上相当于一个强大的 CPU 核心,能够用一把特殊的刀(SIMD 指令刀)一次切四根胡萝卜。我希望有一天能在亚马逊上看到卖这种刀。一次切四根胡萝卜。他们可以在复杂的任务之间快速切换,比如烧开水与做千层面。如果有一根胡萝卜需要不同处理,主厨也不会被拖慢。
然而,GPU 就像一条有数千名工人的装配线。主管可能会喊出一条命令:“转动螺丝”。然后所有工人同时行动。如果每个人都做完全相同的事情,这条装配线就极其高效。但是,如果有一个工人需要遵循不同的指令,整个团队都必须停下来等他,这会显著拖慢整个流程。
这个提案是专门为 CPU SIMD 设计和优化的,并不打算作为 GPU 的直接编程接口,尽管这两种架构都利用了数据并行性,但它们的执行模型差异太大,无法用像 simd 这样的单一抽象来高效定位两者。
最佳实践与完整示例¶
如果你曾尝试过 Parallelism TS,你会发现最终的 C++26 版本是一个显著的改进。API 被简化了。复杂的 ABI 标签现在隐藏在易用的别名后面。令人困惑的 where 子句去掉了,取而代之的是我刚才提到的更清晰的 simd_select 函数。命名更加一致,类型转换默认也是安全的。这是一个经过打磨的、强大的最终产品。
这里有一些最佳实践:
构建你的主循环,使其以向量宽度的块为单位进行迭代。
使用
rebind_simd创建无缝协作的类型集合。始终优先使用
simd_select而不是分支(即使我觉得我已经强调得够多了,但我还是要反复强调)。反过来说,小心不要混合不兼容的宽度。编译器会阻止你的。
注意前置条件(preconditions),特别是像
reduce_min_index这样的函数,它要求掩码中至少有一个为真(true)的元素才有效。
让我们把一切整合起来。这个函数对图像应用伽马校正(Gamma correction)。它遵循一个经典的模式:
/* 生成代码,仔细甄别 */
void gamma_correct(std::span<float> pixels, float gamma) {
// 1. 类型别名与广播
using float_v = std::simd<float>;
const float_v v_gamma = gamma;
// 2. 主循环:以 SIMD 宽度进行递增
for (size_t i = 0; i < pixels.size(); i += float_v::size) {
auto pixel_subspan = pixels.subspan(i);
// 3. 部分加载数据
float_v pixel_chunk;
pixel_chunk.copy_from(pixel_subspan, std::simd_flag::partial);
// 4. 并行计算:CMath 魔法
float_v corrected_chunk = std::pow(pixel_chunk, v_gamma);
// 5. 部分存储数据
corrected_chunk.copy_to(pixel_subspan, std::simd_flag::partial);
}
}
这就是真实的 SIMD 代码的样子。它干净、可读且快速。让我们稍微逐行解析一下。
函数签名:
gamma_correct接收一个std::span<float> pixels(一个包含浮点像素值的连续内存块的非拥有、可变视图)和一个单一的标量gamma。类型别名与广播:
using float_v = std::simd<float>;创建了一个方便的别名。它所容纳的确切浮点数数量(即float_v::size)由编译器基于目标硬件上最高效的 SIMD 寄存器大小(通常是 4、8 或 16)来决定。const float_v v_gamma = gamma;创建了一个常量 SIMD 对象,单一的浮点值被广播(broadcast)到该向量的所有元素中(例如[gamma, gamma, gamma, gamma])。这对于进行逐元素操作是必需的。主循环:
for (size_t i = 0; i < pixels.size(); i += float_v::size)。最后这一部分很重要,循环不是一次前进一个元素(i++),而是步进一整个 SIMD 向量宽度。这是并行处理大块数据的秘诀。加载数据:
pixels.subspan(i)创建一个从当前索引i开始的内存视图。然后通过 partial load 函数将内存中的值读入pixel_chunk。函数名中的“部分(partial)”至关重要,因为如果循环末尾的剩余元素少于 SIMD 宽度,该函数将安全地仅加载可用元素,并将剩余元素初始化为零,从而防止数组越界读取。并行计算的魔法:在一行代码中,我没有写
std::thread,没有写 OpenMP,啥都没写,我只是写了pow。pow是<cmath>中为 SIMD 类型重载的众多函数之一。它执行逐元素的计算,同时使用对应的广播值v_gamma,对块中的每个像素同时应用伽马校正公式。存储数据:非常常见的模式,使用 partial store 拿走计算结果
corrected_chunk,并写回给内存指针。就像 load 一样,它也会安全处理数组末尾,确保每个 chunk 只写入有效的像素数量。
我讲完了。大总结就是:只要你了解“过热降频”的警告,SIMD 绝对是性能关键的 C++ 低延迟代码的颠覆者。它已正式纳入 C++26。它赋予了你能力,以一种可移植、类型安全且高可读性的方式,最终掌控你 CPU 的硬件。非常感谢。
接下来我要邀请我的同事 Maged Michael 上台,他将谈谈并发队列的进展。队列种类繁多,有一对多、多对多、多对一……
并发队列 (Maged Michael)¶
大家好,能听到吗?好。那么我来谈谈委员会中关于并发队列(Concurrent Queues)的进展。
并发队列的提案大约在 10 年前就开始了。一路走来经历了许多波折。但在过去的一年里,发生了如此多的进展,一年内就修改了九次(9 个版本)。该提案已经推进到了 SG1(并发研究组)和 LEWG(库演进工作组)进行接口审查。下一步将是措辞审查(wording review)。所以在过去一年里它取得了长足的进步。我将介绍提议的并发队列的接口是什么,支持哪些功能,以及在这一过程中舍弃了哪些功能。
基础并发队列概念 (Basic Concurrent Queue Concept)¶
这是基础并发队列的概念。这些基本操作包含了你所期望的 push 的各种风格:拷贝(copy)、移动(move)和原位构造(emplace)。
如果成功,它们都返回 true;如果队列已关闭,则返回 false。如果队列已满,它们就会阻塞(block)。稍后我会更详细地谈谈关闭的队列对于等待中的 push 操作会发生什么。
pop 操作返回一个 std::optional<T>。T 是队列元素的类型。如果它返回 std::nullopt,这意味着队列已关闭。如果它返回 T 类型的元素,则表示 pop 操作成功。这些是基本操作。
对于更复杂的并发队列概念,需要更复杂的状态信息。因此,该提案的重要部分之一是提供一个队列状态枚举(Status Enum of Queue States)。
当然,其中之一将是 success(成功)。这是预期的正常情况。然后还有你意料之中的 empty(空)、full(满)和 closed(关闭)。
除此之外,还有两种状态:busy(忙碌)。这些状态可能与内部同步机制有关。比如,某些操作可能阻塞在一个空队列上,这时一个元素被推入,但完成该 push 还需要发生内部同步。如果你有一个非等待(non-waiting)操作,它不能等待,这个时候我们就可以返回像 busy 这样的状态。
还有一个状态是 busy_async(异步忙碌)。我不会讲太多细节,但随着 Executor 接口的改进,它以后可能会被废弃。它解决的问题是:当你执行一个非等待操作时,它会解锁一个异步操作,但这会涉及到调度器(scheduling),而你此时不知道调度会不会被阻塞。你不希望一个非阻塞操作被意外阻塞。因此它返回 busy_async,其含义是“我可以保证这是一个非等待操作状态”。我们会随着接口再次遇到这些状态。
第二个概念:并发队列概念 (Concurrent Queue Concept - Non-waiting)¶
第二个概念是并发队列增加了“非等待(non-waiting)”接口。为此,你有不同风格的 try_push 或 try_emplace,以及一种风格的 try_pop。
对于 try_push 操作,它们返回一个状态枚举。如果成功推入元素,则返回 success;如果队列满了、忙碌或异步忙碌,则返回对应的不同状态。
try_pop 操作返回一个 std::expected。如果成功,它要么是 T 类型的 expected;如果失败,则 unexpected 部分是一个队列状态。这就不仅仅是包括 success 了,它涵盖了其他状态:empty、closed、busy 或 busy_async。这就是非等待接口。
异步接口 (Asynchronous Interface)¶
还有异步接口。它包含用于推送的 async_push 和 async_emplace,以及用于弹出的 async_pop。它们都返回发送者(senders)。
我不会讲得太细,但如果你熟悉发送者/接收者模型(sender/receiver model),它们都会返回 senders,并在最终调用完成信号或完成回调。
如果成功,它会调用 set_value。因此,push 会调用 set_value(void),意思是说,好的它成功了,除了这个成功状态没有什么别的东西要返回。而对于 pop sender,当成功时,它会调用 set_value(T),返回元素 T。
如果队列被关闭了,这些操作会调用 set_error 加上状态(status)。稍后我马上讲到队列的关闭。为了区分不同的情况,它们也支持取消操作(cancellation),它们会调用 set_stopped。
队列关闭操作 (Closed Queues)¶
有一个 close 函数,它用于关闭队列。提案支持的队列关闭模型是“只有一次”,并且永远不会再重新打开。一路走来,我们曾考虑过重新打开队列的选项,但发现那实在太复杂了。
一旦队列关闭,你就不能再往里面 push。但如果里面不是空的,你仍然可以从中 pop 元素。如果有阻塞在空队列或满队列上的等待操作,它们会立即被唤醒解除阻塞。对于 push 操作,它会像我们在基本接口中看到的那样返回 false;对于 pop 操作,它会返回 std::nullopt。
失败处理 (Failure Handling)¶
关于失败处理。提案 P0260 采取的方法是:没有什么真正算作“错误(error)”。
因为很难区分什么是异常问题,比如面对一个关闭的队列,说它关闭了算是一个 error 吗?那只是一次 push 或 pop 的失败(failure),但不一定是一个程序错误。这是提案和这个接口所采取的态度。
发现队列既空又被关闭的
pop操作,返回空的optional。发现队列关闭的
push操作返回false。try_push如果不成功,返回队列状态。try_pop如果不成功,将队列状态作为unexpected返回。async_push和async_pop返回 senders,调用带有状态的set_error。
异常处理 (Exceptions)¶
队列可能会抛出由元素类型 T 本身在拷贝或移动时抛出的异常(如果该类型有异常的话)。
如果它分配内存,也可能抛出存储分配异常(bad_alloc)。正如我们将看到的,目前支持的唯一类型将是“有界队列(bounded queue)”,所以只会在构造时分配内存抛出异常。
此外,还可能有同步异常,这取决于底层使用的同步技术。假设底层用了互斥锁并检测到了死锁,它可能会抛出异常。除此之外,它不会抛出任何其他东西。
唯一支持的类模板:有界队列 (Bounded Queue)¶
其实,除了我们将要支持的具体类型之外,我所谈到的所有“概念(concepts)”目前都不会被强制约束。
对于初始版本,将只提供一个队列模板,那就是有界队列(bounded queue)。它将支持我提到的所有三个概念。构造函数接受最大元素数量,这就是队列的边界。它可能在构造时需要分配内存,这也是唯一可能抛出 bad_alloc 异常的时候。队列中的元素类型是可移动、可复制的,但队列本身将是不可移动、不可复制的。
内存序辩论 (Memory Order)¶
关于队列操作的内存序,之前有过一场辩论。委员会决定,对于第一个支持的标准并发队列,直观性(intuitive)比极致性能(high performance)更重要。
这里的理由是,你可能会得到反直觉的结果。设想你有两个线程的情况。它们都在向两个不同的队列推送数据。主线程可能先往队列推入 1,然后记录日志说“我推了数字 1”,然后另一个线程会说“我要推数字 2”,然后它将 2 推入主队列。如果你使用 acquire/release 内存序,你可能会得到与日志不一致的结果。
这是基于论文 P0387 中的一个例子,基本上它的结论是,为了直观起见,我们必须支持顺序一致性(Sequential Consistency)。这也是委员会目前采取的方法。就个人而言,我认为只要它没有关死未来追求高性能的大门,这就完全是一个合理的决定。
被放弃的特性¶
一路走来,有很多特性被放弃了。它们会使事情变得非常复杂,而且可能会阻碍后续的高性能之路。这些特性包括:
允许前端推入(
push_front),以及尾部弹出(不仅是后端推入和前端弹出)。重新打开已关闭的队列(这会导致并发时的巨大混乱)。
严格理论意义上的无锁(Lock-free)。这是一个非常小众的需求,我们不想这么早就强加这个限制。
流式迭代器(Streaming iterators,允许你将元素从一个队列移动到另一个队列)。
存储迭代器(在静止状态的队列中迭代元素)。
给队列命名。
所有这些都达成了共识并被缩减成了我现在展示的内容。
总结一下,提案 P0260 在过去一年取得了很大进展,如果顺利被采纳,它将引入:队列状态的 status enum、三个仅用于说明的概念(exposition only concepts),以及一个具体满足这三个概念的并发队列类型。它支持顺序一致性,并为未来追求高性能敞开了大门。
我马上把时间交给 Paul。谢谢大家。
生命周期结束指针失效 / 幽灵指针 (Paul McKenney)¶
谢谢,谢谢。
我有件事想请大家帮忙,这非常重要:请大家千万别告诉我妻子,我们在台上花了好几分钟冲着观众“闪现”(指刚才屏幕出问题一直闪烁)。
总之,我要讨论的是“生命周期结束指针失效(Lifetime End Pointer Zap)”,我想大家刚才在这也体验过“失效/闪烁(zapping)”了。我需要找个翻页键……噢,在这。它需要点时间反应……没关系,会出来的。(注:现场屏幕似乎坏了)
我们有三篇论文要讨论:2414 是最初的解决方案论文,它涉及原子操作和 volatile。我们将讨论收紧无效指针的行为。我能看到内容因为我正在看 Maged 的笔记本屏幕。我们凑合一下吧,有人带望远镜了吗?你能把它广播到公共媒体让大家看吗?不能是吧。很抱歉,如果你看回放视频,PPT 都会在视频里的。
刚好这也非常切合我们今天的主题。我们将讨论那些在有效和失效之间闪烁的指针。所以你们刚才算是在进行实践演练了。显然有人忘了在这里调用 new,所以我们被卡住了一会儿。不管怎样,我们继续。
什么是生命周期结束指针失效?¶
第一个问题是,什么是生命周期结束指针失效(Lifetime End Pointer Zap)?你在标准中其实找不到这个词,他们称之为无效指针(invalid pointers)。为了促进多样性,C 语言称之为不确定指针(indeterminate pointers)。概念是一样的,可能有一些细微的差别。
但关键的一点是(这对我来说是一个巨大的震惊,我从 1973 年就开始用汇编语言编程了,所以我对“编译器让指针失效”这种事毫无心理准备):一个指针,它里面确实有位(bits),那些 0 和 1 是真实存在的。但是……
如果你有一个程序(我想展示给你们看但你们看不到),里面有一个指针 P。
你给
P分配内存:P = new Something();。然后我们把
P传给一个可能会删除(delete)它的函数。然后我们再分配内存,并把它放进指针
Q:Q = new Something();。
所以我们有一个指向 new 出来的内存的 P,一个中间的(可能引发 delete 的)函数,然后又有一个指向 new 出来内存的 Q。
如果在中间的那个函数真的执行了 delete,那么这两个对象的地址完全有可能是相同的!明白吗?系统可能会把我们刚刚释放的内存又原封不动地返回给我们。这很正常。
但问题在于,指针包含的信息不仅仅是内存中的那些二进制位(bits)。
在标准周围的社区里,这通常被称为来源追踪/溯源(Provenance)。这两个指针来自于两个不同的 new 调用,这个事实意味着,编译器可以在它的“大脑”里(而不是在指针的内存位里)保留额外的信息,并说:“嘿,这两个指针是不同的。”
因此,如果你比较这两个指针(即 if (P == Q)),编译器完全有权直接回答:False。编译器甚至不会去加载那些指针的位,也不会比较它们。编译器会认为:“它们不可能指向同一个对象,它们来自两个不同的 new 调用。”
编译器在内部保留的关于“哪个 new 调用生成了哪个指针”的额外信息,就是这个“Provenance”。
而最诡异的技巧在于:当你 delete 一个对象时,指向它的所有指针会瞬间变成“无效(invalid)”的。也就是被“抹杀(zapped)”了。 并且这发生在一瞬间。这对于像我这种处理物理定律的并发程序员来说,是另一个巨大的震撼——原来有些东西可以在你的机器里超光速传播!(笑)这是编译器为你实现的魔法。如果你能想办法让诺贝尔奖委员会相信这是真实的,那你就发达了。
为什么存在指针失效?¶
对我来说这是一个巨大的震撼。但它之所以存在,是因为它能启用一系列的编译器优化。
在刚才的情况下,如果你比较两个指针,而其中一个指针是“无效”的,那么结果最好的情况也是“实现定义的(implementation defined)”。你将一个有效指针与一个无效指针进行比较,意味着即使它们的位(bits)完全相同,编译器也完全有权说这俩不可能相等。
一个指针失效的事实,意味着对一个无效指针执行任何操作(包括读取和存储它),最好的情况也是实现定义的。这为编译器提供了一些别名分析(aliasing analysis)和指向分析(points-to analysis)及其他类似优化的空间。
导致的问题:并发 LIFO 栈的无锁推送 (Concurrent LIFO Push)¶
这会带来挑战。我现在要展示的是用于无锁(non-blocking)原子栈的并发 LIFO(后进先出)推送算法。(这里应该有图,你能帮我和后排观众进行心灵感应吗?)
这是一个非常简单但非常强大的算法。它基本上有两个操作。
你可以
push一个元素。你有一个不知从哪得来的元素,你原子地将它推入栈。你可以
pop_all弹出栈中的所有元素,让你拥有这个栈的一份私有拷贝,此时原始栈变为空(包含一个空指针)。我们在这里讨论的是老派的原始指针。
这意味着 push 操作只需要一个“比较并交换(Compare-And-Swap, CAS)”循环:
它获取指向链表头部的指针。
它将该头指针放入你要入栈的新元素的
next指针中。执行 CAS 操作。如果现在内存中的头指针依然等于我们刚才拿到的头指针,那么成功,元素挂上去了。
如果失败,说明有人在此期间对链表做了改动,我们就重试(再次加载头指针并绕一圈)。
pop_all 也很直接。你只做一个原子交换(atomic exchange),把 null 换进头指针。然后你拿到了整个链表。
灾难是如何发生的?(ABA/幽灵指针问题)¶
我们要举个例子(同样你们可能看不清,但看我手舞足蹈可能比看那堆图还好点)。
我们假设这是一个简单的编译器(可能只开了 -O1 或者是手写汇编)。
假设我们从一个包含元素 A 和 B 的栈开始(Top -> A -> B)。
任务 1 要推送一个新元素 C。
任务 2 要执行一次
pop_all,然后紧接着推送一个新元素 D。
让我们看看执行顺序:
任务 1 执行第一阶段。它拿到了
Top指针(指向 A),并将 A 存入 C 的next中。所以现在 C 指向 A。C 还没有上栈,但它已经准备好了。(状态:Top -> A -> B,同时脱机的C -> A)。此时,任务 2 执行
pop_all。现在Top是null了,任务 2 拿到了完整的链表 A 和 B。任务 2 可能天真地以为它私有拥有这个链表了,但就像我们看到的,任务 1 手里的 C 依然指向 A!这看起来很有问题对吧?但其实没关系。如果 任务 1 现在去执行它的下一步 CAS 操作,CAS 会失败!因为它手里的旧头指针是 A,而现在栈顶
Top是null。CAS 会说“不对”,然后失败,循环回去重新读取Top拿到null,一切正常。比较并交换操作拯救了我们。除非……任务 2 此时去处理 A 和 B,并且把它们
delete了,释放回内存池。
现在,C 里面的那个 next 指针,指向的是一个内存空闲链表(free list)。正如你们所知,这是个非常糟糕的主意。我们在图上把它标成红色,它变成了一个无效指针。因为它所指向的对象的生命周期已经结束了,它完蛋了。
但是,指针还在那儿!
接下来,任务 2 还要推送元素 D。它去分配内存,结果从空闲链表中拿到了刚刚释放的 A!A 重生成了 D。它的地址和 A 一模一样。
这就意味着,我们产生了一个我们称之为**“幽灵指针(Zombie Pointer)”**的东西。指向一个某种意义上死而复生的元素的指针。
现在,D 是栈上唯一的元素(Top -> D)。但我们手里那个 C 的 next 指针(也就是我们的“幽灵指针”)依然指向这个地址。
关键问题来了:CAS(比较并交换)被定义为只对指针的位(bits)进行操作。它对 Provenance 一无所知。
所以,当 任务 1 带着那个幽灵指针(旧的 A,其实和新 D 地址一样的位)去执行 CAS 操作时,它会成功!
一旦 D 被推入(其实是 C 被推入,C 强行挂在了 D 的头上),我们就在栈里面放进了一个幽灵指针!这意味着下一个进行 pop_all 操作的可怜进程,会找到元素 C,并沿着 next 获取到一个幽灵指针、一个无效指针!实现有权对它做任何事,解引用一个无效指针很可能是未定义行为(Undefined Behavior, UB)!
所以这非常糟糕。但如果你用汇编语言写,它是完全合法的。而且从“位和字节”的纯内存视角来看,这也是一种完全合理的行为。这个并发算法具有极好的前进状态(forward progress properties),在实践中被大量使用。如果我们能以合理的形式在 C++ 中表达它,那该多好。这是我的观点。你也可以不同意,随便你,我只会笑话你。
我们是怎么陷入这种境地的?¶
我们怎么会走到这一步?我们有一个被大量使用的好东西,却无法在 C++ 中合理地表达它。
1989 年,C 标准诞生时,由于他们想要进行编译器优化,本质上就带有了这种“指针失效”的概念。而 Bjarne 明确希望 C++ 能够像 C,所以 C++ 也继承了这一包袱。
在那时这是没问题的,因为这两种语言都是顺序执行的。语言里根本没有并发。所以你根本无法在标准内表达并发算法,更不用说并发 LIFO 推送了。除了像我这样乱搞 C 语言去写并发的人之外,大家的生活都很美好(那是我的错,不是他们的错)。
2001 年,缺陷报告(Defect Report 260)重申了指针失效及其行为方式。 然后在 2011 年,C 和 C++ 引入了并发。突然之间,我们都有麻烦了,不只是我。
麻烦在于,我们甚至不知道 LIFO 推送算法是什么时候发明的。Maged Michael 做了非常令人印象深刻的软件考古学工作,发现它的引用可以追溯到 1973 年提交的一项专利中。有趣的是,那正是我写下第一行代码的几个月前。
所以 LIFO 推送存在了很长时间。它不是什么新的并发算法。不仅如此,它是一个经常被人重复发明的东西。人们通常不是发明一个漂亮的模板库,而是经常将其硬编码(open-coded)在其他业务代码的中间。因为他们甚至不认为自己在做这么抽象的事情。可能这是他们的失误我不争辩。另一方面,我们根本没有办法找到代码库里的所有这些写法去修复它们。
所以如果标准能自然地支持这种写法,那就太好了。
提议的解决方案¶
我们正在跟踪进展,我们依赖于 Davis Herring 的一篇提案论文(P2414)。
你一直可以做的一个技巧是:获取一个指针,将其转换为整数,然后再将其转换回指针。编译器应该(除了那些目前还存在的少数 Bug 外)去挑选此时此刻该指针可能指向的对象,并使其成为指向该对象的一个有效指针。不管在将其转换为整数和转换回指针之间发生了什么。
问题正如你所见,由于同一时间发生了许多并发的事情,我们没有真正的办法在那段时间内保证安全。因此 Davis 提议:编译器不仅要从当前存在的对象中选择,还要从与该转换操作并发创建的对象中选择。如果对象肯定是在转换回指针之后才创建的,那编译器就有权做它想做的事。
我们去年尝试了更激进的方法,但在今年初被否决了。所以我们再次尝试!耶!感谢支持!这就是我要说的,激动人心。正如我说的,我们甚至在 PPT 幻灯片上给大家演示了“指针闪烁”。
我们基于 Davis 提案做了一些事情:
原子或 volatile 操作的作用应当类似于转换为整数再转换回来。 换句话说,原子变量在某种程度上就像是以整数形式存储的。它进出具有相同的语义。顺便说一句,这也包括通过引用传递给成功的
compare_exchange的旧指针参数。成功的 CAS 其实不需要改变那个指针。但我们的要求是:编译器应该忘记它所知道的关于那个通过引用传递给成功的 CAS 的指针的任何 Provenance 信息。但这不能解决全部问题,因为最初的一篇论文由于各种原因不得不拆分。它没有解决的问题是:你甚至不能加载(load)或存储(store)一个无效指针。所以,即使它到了 CAS 那一步没问题也没用,因为你必须将它作为参数传递给 CAS,这在概念上涉及到一次读取和存储。
所以我们还有另一个提议:我们要收紧对无效指针的实现定义行为。 如果你执行的操作不是比较、不是指针算术、也不是解引用,我们的要求是:它必须是良好定义的。 换句话说,如果你有一个无效指针,你去
load它和store它,它可能仍然是无效的,但你必须能得到相同的内存位(bits)。 这意味着我们可以将无效指针作为参数传递给像atomic_load或atomic_store这样的函数。
如果我们能让这三篇论文(Davis 的和我们的这两篇)通过,那么并发 LIFO 推送算法就变成了合法的 C++ 代码,不需要修改任何源代码!这非常棒,因为我们不知道如何找出所有写得乱七八糟的旧代码去修改。
非并发调试的救星:launder_ptr_bits¶
最后还有一个提案,是针对非并发应用程序的。
比如在有些调试场景中,你需要使用指针作为哈希表(hash map)中的键(key),因为你在试着弄清楚“当你期望这个对象还在的时候,是谁释放了它”。现在有一些工具(如 Address Sanitizer 等)会自动做这件事,但有时这些工具的性能损失太大,你必须在程序的一小块地方手动做这事。
为了帮助那些在半夜试图调试这种愚蠢问题的人,让他们不被“指针失效”规则当头一棒,我们需要在标准中支持这种用法。这在经济上是一个巨大的帮助。
我们不能直接使用源代码层面自动支持。我们提议添加一个 std::launder_ptr_bits 和一个 ptr_bits 模板类。它们的效果是维护一个指针的位,并忽略失效原则(invalidity)。你只需获取指针的二进制位,没有任何 Provenance 会介入。
有了这个,如果我们能让前三个通过,LIFO push 将成为标准 C++。最后一个提议允许我们通过一些源码级的修改来支持上述调试用例,但这种修改是比较自然的(不需要将东西存成整数从而失去类型检查等等)。
运气好的话,也许我们能在 C++29 得到这些东西。我们保持乐观。这是一段漫长的旅程。我们在 2019 年第一次在这里提出这个。
说到这里,如果 Maged 和 Michael 愿意上台,我们可以回答一些问题。
Q&A 问答环节¶
Michael Wong: 我们已经讨论了 SIMD、并发队列和生命周期结束指针失效。天哪,从 2019 到 2029,我们中有些人都换了三次工作了!好的,总结一下:第一点将在 C++26,马上投票;第二点和第三点可能会在 C++26 之后,也许是 C++29,或者更久,看情况。有问题吗?Khalil?
观众 1: 我想了解关于 SIMD 的问题。对于那些可能没有那么复杂的 SIMD 支持的平台(比如不能对 sin、cos 执行 SIMD 操作),我们可以从标准库里期待什么?它是只会在编译时失败,还是会有其他的行为回退?
Michael Wong: 你是问,如果平台不支持这些运算(比如硬件没这指令)会怎样,对吧?
观众 1: 对,比如不支持,标准规定会怎么回退?
Michael Wong: 标准操作会被编译器翻译成本地的原生平台指令。如果它不存在,它将直接导致编译期错误(compile-time error)。
观众 1: 所以不会有任何软件模拟(emulation)之类的对吗?
Michael Wong: 没有。绝对没有。
观众 1: 明白了,谢谢。
观众 2: 我有一个关于并发队列的问题。我已经关注并发队列一段时间了,有个地方我不太明白。close(关闭)操作的价值是什么?所有的 API 都有 bool 返回值让我们检查队列是否被关闭,为什么不全变成返回 void 然后彻底干掉“关闭”的能力?我们又不能重新打开队列对吧,那 close 有啥用?
Maged Michael: 在某些情况下,close 是非常有用的,因为你希望发送一个“我们已经结束了”的信号。通常人们的做法是在队列里塞一个包含特定代码的“毒丸(poison pill)”元素。而 close 使其变得更加正式。比如,当你正在流式传输数据并不断提取元素时,说一句“这事儿完了,别再试了”,你能安全地关闭操作,保证后面不会再有任何东西进来。
观众 2: 明白了,谢谢。
观众 3 (Fiat): 我有个 SIMD 的问题。
Michael Wong: 每年你都会问我一个不可能回答的问题,今年是什么?(笑)
观众 3: 你开始问大家维护跨平台 SIMD 有多痛苦,对我来说其实还好,直到我遇到了 ARM 的 SVE(可伸缩向量扩展)。我看着你的 PPT 我就想,这东西怎么在 SVE 上跑?你的代码里好像有一个静态断言,类似有个编译期写死的 size。但 SVE 的类型实际上是不完整的类型(incomplete types),你做不了这些操作。
Michael Wong: 我就知道你要问这个!这确实是因为可伸缩向量扩展的特殊性质。我之所以知道,是因为我在 RISC-V 参与设计了类似的机制,但我老实说,我不知道。我认为目前的提案还未能准备好支持 SVE。 诚实地说,SVE 最大的特点是它是可扩展、可变的,同一份编译好的代码,跑在不同的机器上可能会有不同的宽度。你注意到 SVE 位于我那一长串清单的最后一个,这是个完全不同的怪物。我可能弄错了需要再查一下,但你是对的。
观众 3: 我在遇到 RISC-V 之前在 Grace 芯片上遇到了 SVE。
Michael Wong: 是的,SVE 和所有其他的扩展不同,因为其他的几十年来都一直是固定宽度(fixed width)。它们在运行中不会改变宽度,但 SVE 会。这在社区里是个大麻烦。
观众 4: 我有两个关于 SIMD 的问题。第一,如果我设置了一个架构不支持的 size,我会得到编译错误吗?
Michael Wong: 是的,你可以显式设置。如果你设的不是原生支持的……我在脑海里快速回忆标准规范。我很确定它会给你一个编译错误。之所以加个免责声明是因为我得去查标准确认,但你如果不写,它会自动给你最优尺寸。如果你非要硬写,那尺寸必须能被原生最大尺寸整除才行。如果不行,从编译器生成器的角度来看报错是合理的。
观众 4: 第二个问题,部分加载(partial_load)和部分存储(partial_store)的性能惩罚是多少?编译器会把最后一次操作的循环展开(unroll),还是每次都会执行掩码加载(masked load)?
Michael Wong: 我相信它会进行循环展开。
观众 4: 这是标准强制要求的吗?
Michael Wong: 不,标准无法强制要求以那种方式展开循环。这取决于你的编译器,可能会有不同的性能表现。
观众 5: 快速问一个关于并发队列的问题。我自己实现了并发队列因为我等不到 C++29 了。但我发现,我很高兴你们提议了 close 这个词,因为我也在用它。但我感到遗憾的是,我没看到 abort(中止)操作。在 close 状态下,pop 如果发现还有元素是可以继续弹出的;但如果是 abort,我认为被中止的队列即使里面还有元素,也不应该再允许被 pop 出去。当然 push 也是不行的。为什么没有 abort?
Maged Michael: 从某种程度上说,如果你使用的是异步接口(async interface),你可以调用 set_stopped 来实现类似 abort 的效果。你可以向完成信号传入 std::stop_token 等等,告诉它停下来。
观众 5: 明白了。
(另一位专家通过麦克风补充): 就像刚说的,这是 Sender/Receiver 框架的一部分。异步操作会与取消信号协作,所以这个功能来自于那边。
观众 6: 我的嗓子哑了不好意思。我有个关于 SIMD 的问题。partial_load 在元素不足时填充 0,这会有副作用吗?举个例子,假设我有一个大小为 3 的向量。我打算执行 A / B(A 和 B 是两个不同的向量)。因为部分加载把剩余(不存在的第 4 个)元素填成了 0,那就会发生 0 / 0 这样的问题吧?
Michael Wong: 我复述一下你的问题,A / B,A 和 B 都是只加载了 3 个元素的 SIMD 向量。
观众 6: 是的,最后一个元素被填充为 0,这导致 A / B 的那一通道发生了 0 / 0,原本我的业务数据都是合法的,结果被 SIMD 搞出错误了?
Michael Wong: 啊,0 除以 0 会产生 NaN(Not a Number)。标准通常会把这些无效部分的结果导向 NaN,不会有 infinity。你是不可能执行 0 / 0 还能正常的。这非常深入标准规范细节了,我的专业直觉告诉我,只要遇到末尾填充 0 执行除法,它最后在那一位必须返回 NaN。
观众 6: 好的,谢谢。
Michael Wong: 看来工作人员举牌示意我们没时间了,剩下的问题我们私下交流。非常感谢大家!