CppCon 2014 学习:C++ Memory Model Meets High-Update-Rate Data Structures
这段内容是对一个主题的概览(Overview),涉及并行更新的问题,特别是“Issaquah Challenge”这个具体案例。详细解读如下:
Overview(概览)
- The Issaquah Challenge
这是一个特定的挑战或问题,名称来自“Issaquah”,可能是某个项目、代码库或案例的名字,专注于并行更新的难点。 - Aren’t parallel updates a solved problem?
这句话在提出一个疑问:并行更新(parallel updates)是不是早已解决了?暗示并行更新其实依然复杂,有挑战。 - Special case for parallel updates
提到一些针对并行更新的特殊场景或方法,包括:- Per-CPU/thread processing
每个CPU核心或线程独立处理部分数据,减少冲突。 - Read-only traversal to location being updated
在更新之前先做只读遍历定位,确保不会破坏数据一致性。 - Existence-based updates
更新基于数据存在与否的判断,可能减少不必要的写操作。
- Per-CPU/thread processing
- The Issaquah Challenge: One Solution
提供了针对这个挑战的一个解决方案。
这段内容强调了Issaquah Challenge的核心难点和限制条件:
核心内容总结
-
两个操作:
- 把元素1原子地从左树移动到右树。
- 把元素4原子地从右树移动到左树。
-
无争用:
这两个操作应同时执行且互不干扰,也就是说没有锁竞争(contention)。 -
挑战:
因为无争用的要求,传统的大范围锁(比如全树锁或粗粒度锁)不适用,也就是说:Most locking solutions “need not apply”
— 大多数基于锁的方案都不适合此场景。
深层含义
- 为什么大多数锁方案不适用?
锁通常会导致操作串行化,尤其当多个操作涉及相同资源时。即使这两个操作本身没有冲突,锁机制可能导致不必要的等待,从而降低性能。 - 所以,要求设计一种机制,允许多结构的并发原子更新,且彼此间无锁争用。
这说明Issaquah Challenge是:
- 一个测试多结构并发原子更新设计的难题,
- 需要非常细致的同步策略,或者无锁设计,
- 以最大限度地利用多核并行性能。
这张图和文字强调了哈希表(hash tables)在并行处理中的作用,特别是利用锁机制对哈希表的分区来提升性能和扩展性:
核心内容总结
- 哈希表分区
哈希表内部通常被划分成多个“桶”或者“分区”,每个分区配备一个锁(Lock),以控制对该分区的并发访问。 - 锁的分布
每个桶或分区对应一个锁,比如图中的Lock,分布在不同元素或键值组上。 - 完美分区
如果数据和访问完全均匀分布到各个锁的分区上,
→ 就可以避免不同线程之间的锁争用(lock contention),
→ 达到完美的性能和极高的可扩展性。
深层含义
- 为什么分区锁重要?
因为传统单一全局锁限制了并行度,导致线程争抢同一个锁,成为性能瓶颈。
细分锁使得多线程能同时操作哈希表的不同部分。 - “Perfect partitioning” 是关键
如果分区不均匀,部分锁会成为热点,降低整体效率。
结论
- 利用哈希表的分区锁(lock striping),配合良好的散列函数,能极大提升并行处理能力,适合多核环境。
强调了并行系统在不同负载类型下的表现差异:
核心含义
- Read-Mostly Workloads(以读为主的工作负载)
读操作占多数的情况下,系统扩展性(scalability)很好。
因为读操作往往是无锁或者只需轻量同步,多个线程能并行读取数据,不互相阻塞。 - Update-Heavy Workloads(更新频繁的工作负载)
频繁的写/更新操作需要锁来保证数据一致性。
所有更新都涉及“locking operations”(加锁操作),导致线程竞争资源,严重限制扩展性。 - “And the horrible thing?”
指出写锁的存在是性能瓶颈,更新密集型负载下,锁竞争导致效率大幅下降。
直观理解
- 多线程环境中,读操作几乎可以并发执行,不会阻塞彼此。
- 写操作必须排他执行(加锁),多个线程写时会产生争用,拖慢整体性能。
结论
- 并行架构需要针对“更新重”的场景设计更加巧妙的同步机制(比如无锁算法、细粒度锁、事务内存等),避免写锁成为瓶颈。
理解。
核心点
- “But Hash Tables Are Partitionable!”
哈希表可以被划分成多个桶(buckets),每个桶可以单独加锁或处理,理论上可以减少锁竞争。 - “# of Buckets?”
这里提出了问题:哈希表桶的数量有限,能分多少区间?桶数越多,锁粒度越细,理论上性能越好。 - “Some improvement, but…”
尽管分桶减少了锁竞争,带来了性能提升,但它并不能完全解决更新重负载下的锁竞争问题。
因为:- 仍然存在桶内部锁竞争。
- 桶数有限,热点桶仍可能成为瓶颈。
- 复杂性增加。
总结
哈希表的分桶是提升并发性能的有效手段,但不能彻底消除写锁带来的扩展性限制,尤其是在更新频繁且访问集中于部分桶的情况下。
这段内容核心讲的是硬件结构和物理规律对计算性能的限制,特别强调:
- 电子在晶体管中的移动速度只有光速的 3% 到 30%(0.03C 到 0.3C),所以数据访问必须注意“局部性(locality of reference)”,即数据和指令尽量靠近CPU,减少传输距离和延迟。
- 频率为2 GHz的CPU中,电信号传输约能覆盖7.5厘米的距离,这就限制了CPU设计的规模和内部结构层次。
- 图示部分用多个CPU核、缓存($符号代表cache)和互联结构说明了现代处理器如何层次化组织缓存和核心,减小延迟,缓解物理传输的限制。
- **Store Buffer(存储缓冲区)和Interconnect(互联)**等硬件组件也在帮助协调数据访问和缓存一致性。
总结:
硬件的物理限制(电子速度、信号传播距离)决定了计算机设计必须注重局部性优化和层次化缓存结构,程序设计也需要考虑数据和代码的访问局部性,才能实现更高性能。
这段内容讨论了物理学带来的计算机系统的根本限制,引用了史蒂芬·霍金(Stephen Hawking)的观察,重点强调:
问题 #1:有限的光速
- 光速是有限的,这意味着芯片内部信号传播有最大速度限制(在CPU中电子运动大约是光速的3%到30%)。
- CPU内核之间、缓存层级之间、内存和CPU之间的数据传输都受制于这个物理极限。
- 这导致即使频率很高,远距离的数据访问也存在不可避免的延迟。
问题 #2:物质的原子性质
- 物质是由原子组成的,这也限制了数据的复制、缓存一致性维护和并发访问。
- 具体表现为数据更新操作的复杂性和成本高昂,尤其在多核环境中。
读取与更新的区别
- 读取操作(Read-Mostly)
- 只读数据可以被复制和缓存到所有CPU的缓存中,无需频繁同步。
- 因此,读取操作在物理限制下仍然可以高效扩展,称作“Read-Mostly Access Dodges The Laws of Physics”——只读访问“绕过”了物理规律限制。
- 更新操作(Writes/Updates)
- 当某个CPU修改了缓存中的数据,其它CPU的缓存中对应的副本必须被作废(缓存一致性协议维护)。
- 这会导致大量缓存失效、总线锁定、频繁通信,严重影响性能。
- 更新操作因此不能像只读操作那样“无代价”地扩展,受限于物理和硬件架构。
图示说明
- 多个CPU核和缓存层级,通过互联结构连接内存和其他CPU缓存。
- 只读数据能保持多份副本,各CPU快速访问。
- 更新数据时必须协调缓存,破坏缓存副本,导致性能下降。
总结:
物理定律限制了多核CPU共享数据的效率,特别是写操作导致缓存一致性带来性能瓶颈。只读操作则因可以在各核缓存中复制,性能表现更好。
这段话用幽默的比喻表达了多核系统中更新(写操作)带来的性能痛点:
- “Doctor, it hurts when I do updates!”
—— 就像病人抱怨“医生,我做更新操作时性能很痛苦”。 - “Then don’t do updates!”
—— 医生建议“那就别做更新!”(但这显然不现实)。 - “But if I don’t do updates, I run out of registers!”
—— 病人反驳“可如果不做更新,我的寄存器(资源)用完了!”
—— 意思是:更新是必须的,因为不更新就没法继续处理新数据或管理资源。 - 结论:
更新操作不可避免,但我们必须非常小心地设计和执行更新操作,以最大程度减少对性能的影响。
简而言之:
更新操作是系统性能的痛点,不能避免,只能优化策略、减少锁竞争、减少缓存失效等,才能“止痛”。
这部分讲的是在并行和多线程更新时,有些特殊情况可以大大简化问题和提升性能:
1. Per-CPU/thread processing(每个CPU/线程独立处理)
- 每个CPU或线程维护自己的数据区,做到完美分区,互不干扰。
- 这种方式避免了锁和争用。
- 经典例子:每个线程有自己的栈(stack)。
- 具体案例会讨论“split counters”(拆分计数器),即每个线程维护局部计数,最后合并。
2. Read-only traversal to location being updated(只读遍历更新位置)
- 更新操作之前,对目标位置的访问是只读的。
- 这使得并发访问时读写之间冲突减少,简化同步。
- 这也是解决Issaquah Challenge的关键(之前讨论的多结构原子更新问题)。
总结:
某些特殊场景(如每线程独立数据或读写分离)能显著降低并发更新的复杂度和冲突,提升性能。
这个“Split Counters”示意图描述了拆分计数器的基本思想:
Split Counters(拆分计数器)原理:
- 有多个计数器,每个计数器对应一个CPU核心或线程(Counter 0, Counter 1, … Counter 5)。
- 每个线程只更新自己对应的计数器,避免跨线程竞争和锁。
- 计算总计数时,将所有计数器的值加起来(Sum all counters)。
- 即使这些计数器在累加时仍在不断变化,读取它们的值仍能得到一个“足够准确”的总和(因为允许一定程度的读取不一致)。
- 使用C++11/C11的
memory_order_relaxed
内存顺序,避免读写时的“load tearing”(读取半更新状态的数据),同时不强制同步,性能更好。
优势:
- 避免了单个计数器的锁竞争瓶颈。
- 每个线程自由更新本地计数器。
- 读取总和时可以容忍轻微的不一致,满足大多数统计需求。
这个“Split Counters Lesson”总结了拆分计数器的设计哲学和适用场景:
主要要点:
- 更新操作不一定会降低性能,关键是要保持良好的局部性(locality)。
- 在拆分计数器的常见情况下,每个线程只更新它自己的计数器,因此几乎没有跨线程竞争。
- 读取所有计数器的操作应该很少见,因为读取时需要聚合多个计数器的值,可能带来性能损耗。
- 如果读取变得频繁,拆分计数器就不适用了,这时需要采用其他计数算法。
- 有很多计数算法可供选择,推荐参考Paul McKenney的《Is Parallel Programming Hard, And, If So, What Can You Do About It?》中“Counting”章节。
(perfbook链接)
总结
拆分计数器是一种在多核环境下,针对写操作频繁、读操作稀少的计数问题的高效解决方案。保持线程局部更新避免锁争用,提升性能。但如果读取需求高,需用其他方案。
这里讲的是在多线程环境下对二叉搜索树(BST)做更新时,传统的锁机制存在的问题:
传统锁方法步骤:
- 锁住根节点(root)
- 通过关键字比较选择子节点
- 锁住该子节点
- 解锁上一个节点
- 重复步骤2到4,直到定位到需要更新的位置
问题:
- 根节点会成为热点锁(热点争用点),多个线程都会频繁尝试锁住根节点,导致严重的锁竞争。
- 这使得无法实现对独立元素的无锁或无争用移动(操作),不符合Issaquah Challenge对并行更新的要求。
核心结论:
- 这种“逐层锁定-解锁”的策略虽然能保证正确性,但无法保证高性能和扩展性,尤其在高并发环境下。
- 需要更先进的机制,比如无锁数据结构或专门针对并行更新设计的算法,来实现高效的并发移动。
这里介绍了为什么要用 RCU(Read-Copy-Update) 机制,解决传统锁方法在并发读写时的性能瓶颈。
RCU的核心理念:
- 避免读操作中的昂贵开销
传统锁导致读者也要等待,影响性能。RCU设计的目标是让读操作尽可能“轻量级”,甚至接近无锁,减少读者阻塞。 - 适用场景
多线程环境下读操作频繁,写操作相对少,或者写操作可以延迟处理。
其他类似方案:
- 垃圾回收 (Garbage Collectors)
- Hazard Pointers
- 引用计数 (Reference Counters)
这些都是为了在并发读写中,安全地管理对象生命周期,避免使用传统锁。
代码片段说明:
/* 假设运行环境是不可抢占的(非抢占式调度,运行到阻塞) */
#define rcu_read_lock()
#define rcu_read_unlock()
- 在这种假设下,读操作的“加锁”和“解锁”宏实际上是空操作,几乎无开销。
- 说明读者代码部分是非常轻量的,几乎没有性能损失。
总结:
RCU通过读者无需锁,大幅提升读操作的性能,避免了读写争用,从而解决了传统锁在读多写少情况下的性能瓶颈。
这段内容总结了RCU(Read-Copy-Update)设计的核心思想和优势,重点如下:
为什么用RCU?
- 其他方案:垃圾回收(GC)、hazard pointers、引用计数等也能解决并发访问的问题,但它们可能带来较高开销。
- 设计原则:避免读操作中的昂贵同步操作,让读路径尽可能轻量。
- 最轻量的读侧原语:
假设在不可抢占环境中,读锁根本不需要任何指令(空操作),读者完全无阻塞。#define rcu_read_lock() #define rcu_read_unlock()
- 优势:
- 最高性能
- 极佳的可扩展性
- 实时响应(实时系统友好)
- 无等待(wait-free)
- 节能效率
关键问题
“既然这两个宏不改变机器状态,怎么可能成为同步原语呢?”
- 这是RCU设计的神奇之处,它利用了写者的延迟更新和版本管理来保证一致性。
- 读者无需加锁,但写者会保证在读者完成读取之前,不回收或修改数据。
- 具体机制包括版本标记、内存屏障、延迟回收等复杂手段。
后续
- 这页只是快速概述,详细实现和理论可以参考后续幻灯片或相关文献。
- 例如Linux内核中的RCU实现就是RCU思想的经典应用。
这段内容是关于如何在一个链表(或类似链式数据结构)中,使用 RCU 技术安全地进行新增(添加)操作,并且涉及到读写并发的安全性问题。
主要点总结:
1. 危险阶段:所有读者都能访问的阶段
- 当新节点还没有被链入(链接)到结构中,或者已经被释放时,读者访问这个节点是危险的。
- 因为读者可能访问已经被修改或销毁的内存。
2. 危险阶段:已有的读者还在访问的阶段
- 即使新节点已经加入链表,但旧的指针仍被某些读者持有,写者如果立即修改或释放节点,也会导致问题。
- 这就是RCU需要“等待旧的读者完成”后才能安全回收数据的原因。
3. 安全阶段:新节点对于所有读者都不可见
- 在初始化新节点并准备好之前,新节点对任何读者都不可见。
- 这保证读者不会访问不完整或未初始化的节点。
关键操作解析:
kmalloc()-> a=? -> b=? -> c=?
分配内存,新节点尚未初始化。- 初始化节点成员:
a=1 -> b=2 -> c=3
。 rcu_assign_pointer(cptr, p)
RCU写者操作:使用rcu_assign_pointer
安全地将新节点指针写入共享指针cptr
,并确保内存顺序正确(避免乱序)。- 读者通过
p = rcu_dereference(cptr)
安全地读取当前指针,获得有效的已初始化节点。
说明:
rcu_assign_pointer
和rcu_dereference
保证了写者和读者之间的内存可见性同步,避免数据访问冲突。- 读者通过
rcu_dereference
获取指针时,会正确同步,看到已经完全初始化的节点。
这段内容讲的是使用 RCU(Read-Copy-Update) 技术进行链表元素的安全删除操作,关键在于如何在多线程读写环境下确保删除操作的正确性和安全性。
主要步骤解析:
1. 写者删除节点(list_del_rcu())
- 写者调用
list_del_rcu()
从链表中“逻辑删除”某个节点(比如“cat”)。 - 这个操作只是不让新读者访问这个节点,但之前已经读取到这个节点的读者仍然可以继续访问。
2. 等待所有读者完成(synchronize_rcu())
- 写者调用
synchronize_rcu()
,这会等待直到所有在删除操作开始前进入的读者线程都执行完毕(退出它们的 RCU 读取临界区)。 - 这确保没有任何读者再访问被删除的节点。
3. 释放节点内存(kfree())
- 在确认所有读者都不再访问该节点后,写者可以安全地调用
kfree()
释放节点内存。
图示说明:
- One Version(一版本)
初始链表:boa -> B -> cat -> C -> gnu
读者正在访问节点,节点尚未删除。 - Two Versions(两版本)
list_del_rcu(cat)
:把节点“cat”从链表中摘除,但还未释放。链表变为boa -> B -> gnu
,但“cat”节点仍存在,因为仍有读者访问它。- 读者可能仍旧持有指向“cat”的指针。
- 等待
调用synchronize_rcu()
等待所有读者完成。此时读者离开RCU读区后,写者就知道安全释放内存。 - 释放
最终kfree(cat)
,节点内存被回收。
关键问题:
“But if readers leave no trace in memory, how can we possibly tell when they are done???”
(读者在内存中不留痕迹,我们怎么知道它们什么时候结束访问呢?)
这是 RCU 的核心难题。它通过记录读者进入和退出临界区的时间点(例如使用CPU上的状态,内核的调度机制,或epoch机制)来间接推断所有之前进入读区的线程都已经完成,确保安全释放。
如果你想,我可以帮你:
- 详细讲解
list_del_rcu()
,synchronize_rcu()
, 和kfree()
的内部实现原理 - 写示例代码
- 说明 Linux 内核中 RCU 的具体机制
这部分内容介绍的是 RCU 中**等待之前存在的读者完成(pre-existing readers)的机制,特别是在非抢占环境(non-preemptive environment)**下的实现方法——QSBR(Quiescent State Based Reclamation)。
主要要点:
1. 非抢占环境(CONFIG_PREEMPT=n)
- 在非抢占环境中,RCU 读者代码不能阻塞,也就是说一旦进入 RCU 读临界区,线程会一直运行到完成,不会被强制中断。
- 这个规则类似于保持自旋锁(spinlock)时的限制,保证读者代码执行连续。
2. CPU 上下文切换表示所有读者已完成
- 当某个 CPU 发生上下文切换(context switch)时,意味着该 CPU 上所有在 RCU 读临界区中的任务都已经离开临界区。
- 换句话说,CPU 切换时,相当于告诉 RCU:该 CPU 上所有之前的读者都已完成。
3. 宽限期(Grace Period)
- RCU 的“宽限期”就是等待所有 CPU 至少进行一次上下文切换的时间段。
- 当所有 CPU 都执行了上下文切换,RCU 就能确定所有之前存在的读者都已经退出读临界区,安全地进行删除和释放操作。
流程示意:
- 调用 synchronize_rcu() 开始宽限期
- RCU 追踪所有 CPU 的上下文切换事件
- 等待所有 CPU 都经历一次上下文切换
- 确认所有先前的读者都完成访问
- 执行回收(如释放被删除节点的内存)
解释小结:
- 由于非抢占环境下读者不阻塞,且上下文切换是确定点,RCU 可以利用上下文切换事件来判断读者是否完成,从而保证安全释放。
- 这种方式非常轻量,避免在读路径加入额外开销,提升性能。
这部分内容在讨论一种看起来有点“神奇”的同步机制——RCU的读侧同步操作(rcu_read_lock()
和 rcu_read_unlock()
)本身并不改变机器状态,却能实现同步效果。这种设计带来的挑战和思考点如下:
主要内容:
1. rcu_read_lock()
和 rcu_read_unlock()
不改变机器状态
- 这两个宏实际上在非抢占环境下通常定义为空操作(
#define rcu_read_lock()
和#define rcu_read_unlock()
什么也不做)。 - 它们不执行诸如内存屏障、锁操作或其它硬件指令,不影响 CPU 寄存器或内存状态。
2. 同步靠“社会工程”
- 这里的同步依赖的是开发者的行为约束:开发者必须保证在 RCU 读临界区内不阻塞,不做可能导致上下文切换或其它中断读临界区连续性的操作。
- 换句话说,同步依赖开发者的“守规矩”和代码的设计,保证读者不会长时间占用读临界区,避免影响写者的进度。
3. 其他同步机制也是社会工程
- 比如普通的锁同步:你必须记得在访问共享数据时加锁。
- 事务内存:你必须把共享访问包裹在事务中。
- RCU 特别之处在于,读侧几乎没有开销,完全依赖代码书写的规范性。
4. 预抢占环境下的 RCU
- 在支持抢占的系统中,RCU 会加入一些轻量级的代码来辅助跟踪读者状态,避免完全依赖开发者自觉。
- 但核心理念依旧是尽量让读操作轻量快捷,减少同步开销。
总结
RCU 的同步方式很特别,和常见的锁、屏障不同,读侧代码“看起来”没有同步指令,但同步的正确性依赖开发者的配合和运行时系统的调度约束。这种“纯社会工程”的方法,是它能在高性能场景下胜出的关键。
理解!这段是一个玩具版(简化版)RCU实现示例,通过极简代码体现了RCU的核心设计理念,尤其是读侧操作几乎“零开销”的特性。
代码说明与核心思想
1. 读侧操作:几乎空操作
#define rcu_read_lock()
#define rcu_read_unlock()
- 读锁和读解锁没有任何操作,体现了RCU读操作极致的轻量化。
- 开发者必须保证读侧代码不会阻塞、不会中断。
2. 安全读取指针:rcu_dereference(p)
#define rcu_dereference(p) \({ \typeof(p) _p1 = (*(volatile typeof(p)*)&(p)); \smp_read_barrier_depends(); \_p1; \})
- 使用
volatile
确保编译器不会对指针p
的访问做优化或重排序。 smp_read_barrier_depends()
是一个轻量的内存屏障,用来防止 CPU 对依赖关系的错误优化,保证读取的数据的正确顺序。- 这样读者看到的是最新的、有效的数据指针,避免出现读取“半更新”状态。
3. 写侧更新指针:rcu_assign_pointer(p, v)
#define rcu_assign_pointer(p, v) \({ \smp_wmb(); \(p) = (v); \})
smp_wmb()
是写屏障,确保指针赋值之前的数据已经被写入内存。- 这样,写操作在将新指针
v
赋值给p
之前,确保相关数据已经完全准备好。 - 保证读者不会读取到尚未初始化完成的数据。
4. 等待所有读者完成的同步函数:synchronize_rcu()
void synchronize_rcu(void)
{int cpu;for_each_online_cpu(cpu)run_on(cpu);
}
- 该函数会确保所有CPU都运行一次(模拟等待读临界区结束),即等待所有在读临界区的线程完成。
- 是写者等待读者完成,确保写者可以安全回收或修改数据。
5. 简洁而强大
- 在顺序一致性(sequential consistency)模型下,这个玩具版实现只需要9行代码就能完成RCU核心功能。
- 这也展示了RCU的核心优势:读侧几乎零开销,写侧通过轻量同步保证安全。
总结
这段玩具代码浓缩了RCU的精华:
- 读操作无锁无阻塞,无额外指令开销
- 写操作通过内存屏障和同步函数保证数据安全
- 同步由开发者和硬件内存模型共同协作完成
这也是为什么尽管看起来“简单”,但是RCU在多核并发环境下却非常高效且不容易出错。
理解!这一部分讲的是RCU读者(读侧)如何安全使用被RCU保护的指针,核心点如下:
1. RCU读侧代码流程示意
rcu_read_lock(); // 进入RCU读临界区(轻量级,不阻塞)
p = rcu_dereference(cptr); // 安全读取指针,确保读到正确的数据版本
/* *p guaranteed to exist. */ // 这时*p所指的对象在整个临界区期间是有效存在的
do_something_with(p); // 使用数据
rcu_read_unlock(); // 离开RCU读临界区
/* *p might be freed!!! */ // 离开临界区后数据可能已经被更新者释放
2. 关键点
rcu_read_lock()
和rcu_read_unlock()
几乎无开销,读者访问时不加锁,不阻塞,不修改机器状态。rcu_dereference()
确保读取到最新、有效的指针,避免编译器/CPU重排序导致数据不一致。- 在读临界区内,读者访问的数据是安全的,不会被释放或修改。
- 一旦离开读临界区,数据可能被更新者释放,因此不能继续访问。
3. 写者(更新者)需要更谨慎
- 写者必须保证在删除旧数据前,所有正在进行的读者都已完成(通过
synchronize_rcu()
)。 - 写者更新指针时,需要内存屏障来保证顺序,确保读者不会看到不完整的数据。
这一部分讲的是RCU更新者(写侧)如何安全更新和释放被RCU保护的数据,关键点如下:
RCU 更新者的步骤:
- 加锁保护写操作(可选,根据具体实现)
spin_lock(&updater_lock);
- 保存旧指针(可以用 relaxed 加载)
q = cptr;
- 用
rcu_assign_pointer
更新指针,保证内存顺序
rcu_assign_pointer(cptr, newp);
- 释放锁
spin_unlock(&updater_lock);
- 等待“RCU宽限期”结束
synchronize_rcu();
- 这会确保所有在更新之前进入的读者都已经完成读临界区,不会再访问旧数据。
- 安全释放旧数据
kfree(q);
关键点总结:
- RCU允许读者无锁快速访问,更新者通过等待宽限期保证旧数据不被正在访问的读者使用时释放。
synchronize_rcu()
确保所有旧的读临界区结束,保证安全回收内存。- 更新操作期间通过指针赋值和锁保护保证数据一致性和原子性。
这一段讲的是利用RCU进行“更好”的只读遍历以实现高效更新,核心思想是:
改进的遍历和更新方法:
- 读者遍历时:
- 通过
rcu_read_lock()
保护读临界区,表示“我现在在读”。 - 从根节点开始,无需锁直接遍历(利用RCU确保节点不会被回收)。
- 用key比较选择子节点,继续往下直到找到需要更新的位置。
- 通过
- 更新时:
- 到达目标节点后,才加锁(局部锁),防止更新冲突。
- 加锁后做一致性检查(例如检测节点是否被标记为“已删除”或结构是否变化)。
- 如果检测到不一致,放弃当前操作,从头开始重新遍历。
- 更新完毕后,释放锁,结束读临界区(
rcu_read_unlock()
).
优点:
- 消除了根节点锁的争用,提升并发性能。
- 利用RCU保证读者不会访问已释放的节点。
- 通过“移除标志”(removed flags)实现更细粒度的一致性检查,防止在更新时读取错误的数据。
总结:
这种方法结合了:
- RCU的快速读保护和
- 传统的局部加锁+一致性检查机制
从而在保持高性能读访问的同时,实现安全、低争用的更新。
这段讲的是带“删除标志”(deletion flag)的只读遍历更新流程,具体步骤:
删除标记的只读遍历 + 更新流程:
- 循环重试(for (;😉):
- 确保遇到不一致时能重新开始遍历。
rcu_read_lock()
:- 开启RCU读临界区,保证遍历期间数据不会被释放。
- 遍历树结构:
- 从根开始,无需加锁。
- 通过key比较,选择合适子节点,一直走到更新目标节点。
- 对目标节点加锁(局部锁):
- 防止并发冲突。
- 检查目标节点的“removed”标志:
- 如果节点没有被标记为删除(removed flag没设),就跳出循环,进行后续更新。
- 如果标记了删除,说明节点状态不一致,释放锁和读锁,重新开始遍历。
- 执行更新操作。
- 释放局部锁和调用
rcu_read_unlock()
结束读临界区。
核心意义:
- “删除标志”充当一致性检查机制,确保操作的是有效节点。
- 结合RCU保证节点不会在遍历时被释放。
- 循环保证遇到不一致时能安全重试。
- 通过减少加锁范围和频率,大大降低锁争用,提高并发性能。
这段重点强调了只读遍历到更新位置的好处和相关文献:
- 核心思想:只在即将更新的节点部分产生锁竞争,减少全局锁争用,同时保留对结构不同部分的局部性引用,提升性能和扩展性。
- 理想状态:完全划分(partitioning)是更优的方案。
- 经典论文/案例参考:
- Arbel & Attiya,PODC 2014:使用RCU实现并发更新的搜索树示例。
- McKenney等人,Linux Journal 2004:用RCU提升dcache可扩展性。
- Pugh,1990:跳表的并发维护。
- Kung & Lehman,1980:二叉搜索树的并发操作。
最后调侃说:“34年过去了,或许是时候认真对待这类方法了”。
在**二叉搜索树(BST)**的操作中,采用了带有“存在性”相关注解的锁定区域(Locking Regions)。
也就是说,除了传统的锁定节点以保证并发安全外,还加入了对节点“是否存在”状态的检查或标记,用于辅助判断和协调更新操作,防止竞态和不一致。
这样能在保证并发安全的同时,减少不必要的锁争用,提高性能。
在多线程环境中,尝试获取锁时可能遇到不一致的情况(Upsets),导致锁定的数据状态在锁定之前或过程中发生了变化。某个节点的值或者状态在操作过程中被改变了。
面对这种情况,正确的做法是放弃已经获得的锁(drop locks),然后重新尝试整个操作(retry)。这样可以确保操作是在最新、正确的数据状态下进行,避免脏数据或竞态条件。
总结就是:
- 获取锁时,数据可能已被其他线程改变
- 发现不一致时,释放锁,重试整个流程
- 保证数据一致性和操作正确性
系统梳理一下 存在结构(Existence Structures) 结合 分区锁和重试机制 的实现原理和典型应用场景。
一、实现原理
1. 问题背景
- 并发环境下,数据结构的读写冲突严重影响性能。
- 传统全局锁竞争激烈,影响扩展性。
- 多线程读者常常不需要等待,但写者必须保证更新的原子性和一致性。
2. 解决方案核心思想
- 分区锁(Locking Regions):将数据结构划分为多个锁区域,只锁定要修改的部分,降低锁争用。
- 存在结构(Existence Structures):增加一个“存在”层,通过间接引用实现数据结构的动态切换。
- 重试机制:更新时,如果发现数据状态不一致(如节点已被删除),放弃当前操作,释放锁并重新遍历。
3. 存在结构工作流程
- 存在结构定义:一个“Switch”节点(或数组),包含指向两个(或多个)底层数据结构的指针以及偏移量标记。
- 切换过程:
- 读操作根据偏移量访问当前生效的数据结构。
- 写操作切换偏移量指向新的数据结构(新版本),完成更新。
- 老版本结构依然保持,供当前仍在访问它的读者使用,避免悬挂指针。
- 旧版本释放在确保没有读者访问之后进行。
4. 分区锁和重试机制
- 分区锁:读操作通常无锁或轻量级锁,写操作锁定局部节点,避免全局锁竞争。
- 重试机制:
- 写者在加锁时检查节点状态,发现节点已经“removed”或状态不符时,立即释放锁。
- 重新执行查找和锁定过程,保证操作的正确性和一致性。
5. RCU(Read-Copy Update)配合使用
- 存在结构和分区锁结合了 RCU 的思想:
- 读者无锁快速访问,保证高并发读。
- 写者创建新版本数据结构,更新 Switch 指针。
- 旧版本延迟回收,等待所有读者完成。
二、应用场景
1. 操作系统内核
- 内核中的路由表、文件系统目录树、缓存管理结构。
- 高并发访问且更新较少,要求快速无锁读访问。
2. 数据库索引结构
- B树、红黑树等索引需要高效并发访问和更新。
- 使用存在结构保证索引切换和版本控制。
3. 网络协议栈
- 路由表、连接跟踪表的动态更新。
- 保证读路径快速且无阻塞,更新路径安全且原子。
4. 高性能缓存系统
- 分布式缓存的元数据管理。
- 快速读,多版本更新,避免全局锁。
5. 并发容器实现
- 并发哈希表、跳表、二叉搜索树等数据结构实现。
- 结合分区锁、存在结构和重试机制,实现高扩展性。
三、总结
技术点 | 作用 | 好处 |
---|---|---|
分区锁 | 局部锁定减少争用 | 提升锁的并发度 |
重试机制 | 解决锁获取冲突导致的数据不一致 | 保证数据一致性 |
存在结构 | 通过指针间接控制数据版本切换 | 实现原子切换,支持多版本访问 |
RCU | 无锁快速读,延迟回收旧版本 | 高效的读写分离,提高性能 |
你给出的这部分内容详细介绍了“存在结构(Existence Structure)”的具体C语言实现和设计思路,以及其性能优化的权衡。下面我帮你系统分析和梳理:
一、存在结构的定义与实现原理
1. 关键数据结构
/* Existence-switch array. */
const int existence_array[4] = { 1, 0, 0, 1 };
/* Existence structure associated with each moving structure. */
struct existence {const int **existence_switch;int offset;
};
/* Existence-group structure associated with multi-structure change. */
struct existence_group {struct existence outgoing;struct existence incoming;const int *existence_switch;struct rcu_head rh; /* Used by RCU asynchronous free. */
};
- existence_array:一个整型数组,表示“存在”与否的标志位(通常为0/1),用来做存在状态的切换判定。
- struct existence:表示与某个动态可切换的数据结构相关的存在结构,保存指向存在切换数组的指针和当前偏移量。
- struct existence_group:用于管理一组相关联的存在结构,方便同时对多个数据结构进行切换和同步回收(配合 RCU)。
2. 工作机制
- 通过
existence_switch
指针指向不同的存在标志数组,实现对数据元素是否“存在”的间接判断。 - 通过
offset
选择当前生效的存在标志,切换数据结构的有效状态。 - 这样设计允许动态切换多个版本的数据结构而不破坏现有访问者的安全性。
- RCU 结构
rcu_head
用于异步回收已经被替换、且确认无读者访问的旧数据。
二、设计中的性能权衡
1. 多层间接寻址的成本
- “Levels of Indirection Are Expensive!”
- 该设计添加了多达三层的指针间接寻址,导致访问路径变长,可能引发多次缓存未命中(cache miss)。
- 这会增加访问延迟,影响性能,尤其是频繁访问时。
2. 优化:利用 NULL 指针表示“存在”
- 绝大多数情况下,元素是存在且没有被移动的(普通情况)。
- 设计中采用一个“反向”的技巧:如果元素的
existence_switch
指针是NULL
,则默认该元素存在,直接访问,无需间接寻址。 - 这样对“普通访问”做了大幅优化,避免了不必要的指针跳转和缓存缺失。
- 只有在“不常见的情况”——元素正在被移动或切换时,才真正访问存在结构,承担较高的性能开销。
3. 内存屏障(Memory Ordering)
- 由于需要保证访问的正确顺序,加载
existence_switch
时需要使用memory_order_acquire
来防止乱序执行。 - 这比
memory_order_relaxed
或memory_order_consume
更严格,带来一定的同步成本。
三、总结理解
特点 | 说明 | 性能影响 |
---|---|---|
多级间接寻址 | 三层指针跳转判断元素“存在”状态 | 访问延迟,缓存缺失风险 |
NULL 指针优化 | 常见情况下元素直接存在,指针为 NULL | 大幅减少普通访问路径的开销 |
RCU 异步回收 | 旧数据结构在无读者时安全释放 | 保证安全性,维护版本一致性 |
内存屏障(Acquire) | 保证访问顺序一致 | 轻微同步开销 |
四、应用场景举例
- 在复杂并发数据结构切换时,保证数据元素的动态“存在”状态可控。
- 需要支持热切换数据结构版本,且旧版本必须继续支持正在进行的读取。
- 使用 RCU 的系统,确保读者无阻塞访问的同时,安全管理数据结构生命周期。
- 典型于操作系统内核、数据库索引、高性能缓存系统等场景。
1. Abbreviated Existence Switch Operation (1/6)
- 初始状态:有两个树结构,第一棵树包含节点 1, 2, 3,第二棵树包含节点 2, 3, 4。
- 特点:所有存在指针(existence pointers)初始为 NULL。
- 意义:展示了两个独立树结构的起点,准备进行切换操作。
2. Abbreviated Existence Switch Operation (2/6)
- 状态变化:第一棵树仍包含 1, 2, 3,第二棵树包含 2, 3, 4。
- 新增:引入一个切换结构(Switch),包含两个位(0 和 1),分别指向第一棵树和第二棵树。
- 意义:切换结构开始管理两个树的访问,当前位配置(1 0)表示第一棵树(1, 2, 3)被使用。
3. Abbreviated Existence Switch Operation (3/6)
- 状态变化:在第二棵树(2, 3, 4)中插入节点 1,树变为 1, 2, 3, 4。
- 切换结构:位配置保持 (1 0),但第二棵树已更新。
- 意义:展示了在切换前,第二棵树可以独立更新,而第一棵树仍被使用。
4. Abbreviated Existence Switch Operation (4/6)
- 状态变化:执行存在切换,切换结构位配置变为 (0 1),表示第二棵树(2, 3, 4)现在被使用,第一棵树(1, 2, 3)变为备用。
- 特点:切换是单步操作(single store),但需要屏障(barriers)确保一致性。
- 意义:完成了从第一棵树到第二棵树的原子切换。
5. Abbreviated Existence Switch Operation (5/6)
- 状态变化:切换后,第一棵树(1, 2, 3, 4)变为第二棵树(2, 3, 4),旧节点(1, 2, 3)被解除链接。
- 操作:解除旧节点和切换结构的关联。
- 意义:清理旧数据,准备释放资源。
6. Abbreviated Existence Switch Operation (6/6) & Allegiance Switch Operation (6/6)
- 状态变化:第二棵树(1, 2, 3)继续使用,旧节点和结构被移除。
- 特点:等待一段时间后,可以释放旧存在结构和节点;数据结构保持引用局部性。
- 意义:完成整个切换周期,确保资源安全释放,同时优化内存使用。
总体理解
- 存在切换操作:通过切换结构(Switch)实现两个树结构间的原子切换,允许动态更新数据(例如插入节点),并在切换后清理旧数据。
- 效忠切换操作:强调在切换后保持数据结构的局部性(locality of reference),并在安全时间点释放资源。
- 应用:这种机制适用于并发环境(如 RCU),支持无锁或低锁冲突的数据结构管理。
#这段内容强调了“存在结构(Existence Structures)”的核心设计理念和实现细节,重点总结如下:
Existence Structures 核心要点
1. 每个数据元素拥有一个 存在指针(existence pointer)
- NULL 指针表示该元素是当前数据结构的一部分,即“存在”
这是一种反向使用 NULL 指针的技巧,通常 NULL 表示“无效”或“不存在”,这里恰好相反。 - 非 NULL 指针指向一个“存在结构(existence structure)”
该结构用来描述元素的存在状态,尤其是在跨多个数据结构切换时(比如切换两个树的版本),用于标记元素是否有效。
2. 多元素存在状态可以通过存在结构 原子地切换
- 这使得可以在运行时原子性地替换整个数据结构的视图,读者看到的始终是完整的一致视图。
- 避免了复杂的锁竞争,也确保了无锁读操作的正确性。
3. 设计难点:API的设计非常关键
- 因为 NULL 指针在这里意味着“存在”,容易引起误用或逻辑混乱。
- 需要一套清晰且安全的接口,帮助开发者正确管理和操作存在指针和存在结构。
- 好的 API 能避免由于指针语义反常带来的错误。
结合上下文的总结
- 存在结构 是一种用额外的间接层(indirection)实现版本切换的技术。
- 它允许程序快速切换数据结构的“有效”元素集合,而不需要停止读者线程。
- 读者通过检查存在指针(是否为 NULL)即可判断元素是否有效,简洁高效。
- 更新者则通过原子切换存在结构,实现新旧数据结构的无缝切换。
- 由于存在指针的特殊语义,设计和使用时必须非常小心,API 必须简洁明了。
这段伪代码描述了利用存在结构(Existence Structures)实现数据结构中元素的原子迁移操作,具体流程和意义如下:
伪代码流程解析:Atomic Tree Move(原子树元素迁移)
- 分配存在组结构(existence_group)
通过existence_alloc()
分配一个用于管理存在状态切换的结构体,准备开始迁移。 - 在源树中为待迁移元素添加“离开”存在结构
调用existence_set()
,将元素的存在指针指向“离开状态”存在结构,标记该元素即将被迁移。- 如果失败,报告错误给调用者,结束操作。
- 将新元素(使用源元素的数据指针)插入到目标树
使用目标树中的“进入”存在结构调用类似tree_insert()
的函数插入元素。- 如果失败,需要撤销第2步的操作(清除源树的存在结构),释放分配的存在组结构,并报告错误。
- 执行存在结构切换(existence_switch())
原子地交换“进入”和“离开”存在结构,实现元素在两个树之间的状态切换。 - 从源树删除元素
使用类似tree_delete()
的函数,从源树中移除该元素节点。 - 从目标树元素上清除存在结构
调用existence_clear()
,将目标树元素的存在指针恢复为 NULL,标记为正式存在。 - 释放存在组结构(existence_group)
通过existence_free()
清理管理结构,完成迁移流程。
设计意义和应用场景
- 原子迁移保证数据一致性
通过存在结构的原子切换,读者线程不会看到部分迁移的中间状态,避免并发读写冲突。 - 允许高并发环境下动态调整数据结构
例如热数据迁移、负载均衡、版本切换等场景。 - 减少锁竞争和阻塞
读操作无锁、无阻塞,更新操作通过存在结构切换实现同步,提升性能。