Putting It All Together
章节概述
第 13 章是《Is Parallel Programming Hard, And, If So, What Can You Do About It?》的综合性实践章节,标题”Putting It All Together”揭示了其核心目标:将前面章节介绍的计数器、引用计数、Hazard Pointers、Sequence Locking、RCU 等机制融会贯通,解决实际并发编程中的复杂问题。本章不仅展示了这些机制如何组合使用,还深入讨论了 Linux 内核中的真实实现,以及微优化(micro-optimization)的基本原则。
本章包含六个主要部分:
- Counter Conundrums - 计数难题的解决方案
- Refurbish Reference Counting - 引用计数的并发改造
- Hazard-Pointer Helpers - Hazard Pointer 的辅助应用
- Sequence-Locking Specials - Sequence Lock 的特殊用法
- RCU Rescues - RCU 在实际场景中的救援应用
- Micro-Optimization - 微优化的基本原则
1. Counter Conundrums(计数难题)
1.1 Counting Updates
在并发场景下,如果更新操作已经被某个 per-data-element lock 保护,那么最优的计数方式就是直接在数据元素内部放置一个计数器,并在持有该锁时递增它。这是最简单、最高效的方法,因为锁已经提供了所需的内存序和原子性保证。
如果读者需要无锁访问该计数器,更新者应使用 WRITE_ONCE(),读者应使用 READ_ONCE(),以防止编译器优化导致的数据竞争。
1.2 Counting Lookups
当查找操作由 RCU 保护时,计数变得复杂。如果直接对查找计数器加锁,会破坏 RCU 的可扩展性优势。本章提出了几种方案:
- “Just say no”:如果可能,完全避免计数(如 Linux 的
noatime挂载选项)。 - Statistical counters:使用第 5 章介绍的统计计数器,每个数据元素乘以线程数会导致较大的内存开销。
- Per-core/per-socket counters:减少内存开销,但需要使用原子操作(尤其是用户态,线程可能迁移 CPU)。
- Log-based batching:维护 per-thread 日志,合并同一元素的多次查找,批量更新。
C 代码实现:见 sref_demo.c 中简单计数示例,以及 count_end_rcu.c 中基于 RCU 的统计计数器实现。
2. Refurbish Reference Counting(改造引用计数)
引用计数在概念上简单,但并发实现充满陷阱:对象可能在引用获取过程中被释放。本章系统性地总结了五种保护引用获取的方法:
- 外部锁保护:操作引用计数时必须持有外部锁。
- 非零初始化:对象创建时引用计数非零,新引用只能在计数非零时获取。
- Hazard Pointers 替代:在某些场景下可直接替换引用计数。
- Existence Guarantee:保证对象在引用获取期间不被释放(如 GC、Hazard Pointers、RCU)。
- Type-Safety Guarantee:保证内存不会被重新分配为不同类型(如 Linux 的
SLAB_TYPESAFE_BY_RCU)。
2.1 同步机制组合表
本章提供了一张关键表格,展示了 Acquisition(获取端)与 Release(释放端)使用不同机制时的同步需求:
| Acquisition \ Release | Locks | Reference Counts | Hazard Pointers | RCU |
|---|---|---|---|---|
| Locks | - | CAM | M | CA |
| Reference Counts | A | AM | M | A |
| Hazard Pointers | M | M | M | M |
| RCU | CA | MCA | M | CA |
其中:
-:简单计数,无需原子操作或内存屏障A:原子计数,无需内存屏障AM:原子计数,仅需释放端内存屏障CAM:原子计数 + 获取端检查,仅需释放端内存屏障CA:原子计数 + 获取端检查M:简单计数 + 获取端检查 + 完整内存屏障MCA:原子计数 + 获取端检查 + 获取端内存屏障
2.2 Linux 内核中的实现对应
Simple Counting (“-“)
当引用计数的获取和释放都被同一个锁保护时,可以使用非原子操作。sref_demo.c 演示了这种最基础的引用计数 API。
Atomic Counting (“A”) - Linux kref
Linux 内核的 kref 是此类别的代表。kref_get() 要求调用者已经持有一个引用,因此可以直接原子递增。kref_put() 使用 refcount_dec_and_test(),在计数归零时调用释放函数。
内核源码 include/linux/kref.h(Linux v6.x)展示了现代实现:
struct kref {
refcount_t refcount;
};
static inline void kref_init(struct kref *kref)
{
refcount_set(&kref->refcount, 1);
}
static inline void kref_get(struct kref *kref)
{
refcount_inc(&kref->refcount);
}
static inline int kref_put(struct kref *kref,
void (*release)(struct kref *kref))
{
if (refcount_dec_and_test(&kref->refcount)) {
release(kref);
return 1;
}
return 0;
}
注意:现代内核已从 atomic_t 迁移到 refcount_t,后者增加了溢出/下溢保护,但在功能上等价。
Atomic Counting With Release Memory Barrier (“AM”) - dst_clone
Linux 网络层的 dst_clone() 和 dst_release() 属于此类。dst_clone() 的调用者已持有引用,因此无需内存屏障。dst_release() 的调用者可能在释放前访问 dst_entry,因此需要在 atomic_dec() 前插入 smp_mb__before_atomic_dec()(现代内核已整合到 dst_release() 实现中)。
在最新内核 include/net/dst.h 中,dst_clone() 简化为:
static inline struct dst_entry *dst_clone(struct dst_entry *dst)
{
if (dst)
dst_hold(dst);
return dst;
}
Atomic Counting With Check and Release Memory Barrier (“CAM”) - fget/fput
这是最复杂的场景:调用者需要获取一个它尚未持有引用的对象,但对象的存在性有保证。Linux 的 fget() 和 fput() 是经典实现。
fget() 使用 RCU 保护文件描述符表的遍历,然后使用 atomic_inc_not_zero()(现代内核中为 file_ref_get())原子地获取引用,只有当引用计数非零时才成功。fput() 使用 atomic_dec_and_test(),在计数归零时通过 call_rcu() 延迟释放文件结构。
现代内核 fs/file.c 中的 __fget_files_rcu() 展示了这一逻辑:
static inline struct file *__fget_files_rcu(struct files_struct *files,
unsigned int fd, fmode_t mask)
{
for (;;) {
struct file *file;
struct fdtable *fdt = rcu_dereference_raw(files->fdt);
/* ... */
file = rcu_dereference_raw(*fdentry);
/* ... */
if (unlikely(!file_ref_get(&file->f_ref)))
continue;
/* Verify the file table entry hasn't changed */
if (unlikely(file != rcu_dereference_raw(*fdentry)) ||
unlikely(rcu_dereference_raw(files->fdt) != fdt)) {
fput(file);
continue;
}
/* ... */
return file;
}
}
这里的关键是:RCU 保证文件描述符表指针的有效性,file_ref_get()(即 refcount_inc_not_zero)保证对象不会被释放,最后的重新验证保证获取的是正确的文件对象。
2.3 Counter Optimizations
当引用计数的增减非常频繁,但检查零值很少时,可以使用 per-CPU 计数器。Linux 内核的 percpu_ref 就是这类实现:
- 正常模式下,每个 CPU 维护本地计数器,增减操作无需原子指令。
- 当需要确定计数是否归零时,调用
percpu_ref_kill()切换到原子模式,收集所有 per-CPU 计数器。
include/linux/percpu-refcount.h 展示了这一设计的核心数据结构:
struct percpu_ref {
unsigned long percpu_count_ptr;
struct percpu_ref_data *data;
};
其中 percpu_count_ptr 的低比特位指示当前处于 percpu 模式还是原子模式。
C 代码实现:kref_demo.c 演示了原子引用计数和 kref_get_unless_zero() 的行为。
3. Hazard-Pointer Helpers
本章 Hazard Pointer 部分相对简短,主要讨论两个应用场景:
3.1 Scalable Reference Count
当引用计数成为性能瓶颈时,可以用 Hazard Pointers 替代。但 Hazard Pointers 很难高效地判断引用计数是否归零。一种折中方案是”分流”:数据元素之间的引用关系用引用计数跟踪,而读者使用 Hazard Pointers 保护正在访问的元素。Folly 的 UnboundedQueue 和 ConcurrentHashMap 采用了这种设计。
3.2 Long-Duration Accesses
当 reader-writer lock 的读者持有锁时间过长,导致更新延迟严重时,如果可以将读者转换为引用计数模型,Hazard Pointers 提供了一个可扩展的替代方案。与 rwlock 不同,Hazard Pointers 不会阻塞所有更新,只会”挂住”实际需要的数据。
4. Sequence-Locking Specials
4.1 Dueling Sequence Locks
经典 seqlock 的问题:如果 updater 在更新过程中被延迟(中断、NMI、vCPU 抢占),reader 也会被延迟。解决方案是维护两组数据,每组有自己的 seqlock。Updater 交替更新两组数据,reader 先尝试第一组,如果 retry 则尝试第二组。
Linux 内核的 seqcount_latch_t(include/linux/seqlock.h)正是为这种场景设计:
typedef struct {
seqcount_t seqcount;
} seqcount_latch_t;
static __always_inline void raw_write_seqcount_latch(seqcount_latch_t *s)
{
smp_wmb();
s->seqcount.sequence++;
smp_wmb();
}
读者使用序列号的最低位选择 data[0] 或 data[1],通过 raw_read_seqcount_latch_retry() 验证一致性。
4.2 Correlated Data Elements
当需要一致地查看多个相关数据元素时(例如哈希表中的婚姻状态),可以使用 seqlock 保护更新,RCU 保护删除。但 seqlock 不能替代 RCU:seqlock 防止并发修改,RCU 防止并发删除。
对于大量数据元素,可以引入 per-element seqlock 或 per-species seqlock 来减少竞争。
4.3 Atomic Move
这是本章最精妙的算法之一:数据元素需要从一个结构原子地移动到另一个结构。使用 RCU + per-element seqlock 组合:
- 获取全局 rename 锁
- 分配并初始化新元素
- Write-acquire 旧元素的 seqlock(tombstone)
- 插入新元素
smp_wmb()排序插入与移除- 移除旧元素
- Write-release seqlock
- 释放全局锁
读者查找旧元素时会 retry,直到最终失败;查找新元素则会成功。关键保证:任何成功查找新元素的读者,随后查找旧元素必定失败。
C 代码实现:atomic_move.c 使用全局版本号辅助读者做原子快照,验证了”不会同时在两处存在”和”不会同时缺失”两个不变量。
4.4 Upgrade to Writer
在 seqlock 中,读者升级为写者会导致该读者被迫 retry。Linux 内核的 sdma_flush()(drivers/infiniband/hw/hfi1/sdma.c)使用了这一技巧。
C 代码实现:seqlock_demo.c 演示了 seqlock 保护相关数据的一致性读取。
5. RCU Rescues
RCU 是本章的重点,展示了多种实际问题的优雅解决方案。
5.1 RCU and Per-Thread-Variable-Based Statistical Counters
问题:__thread 计数器提供快速的 inc_count(),但 read_count() 需要全局锁来遍历所有线程的计数器,且线程退出时其 __thread 变量消失。
解决方案:使用 RCU 保护 countarray 结构。该结构包含 total(已退出线程的计数总和)和 counterp[](当前运行线程的计数器指针数组)。线程退出时:
- 分配新的
countarray - 复制旧数组内容
- 将本线程的计数加到
total - 将本线程的
counterp[]置为 NULL - 用
rcu_assign_pointer()发布新数组 synchronize_rcu()等待所有读者完成- 释放旧数组
C 代码实现:count_end_rcu.c 使用简化的用户空间 RCU 实现了这一算法,验证了线程退出后计数不会丢失也不会重复。
5.2 RCU and Counters for Removable I/O Devices
将读写锁替换为 RCU:读端(I/O 快速路径)使用 rcu_read_lock(),写端(设备移除)使用 spin_lock() + synchronize_rcu()。虽然 synchronize_rcu() 延迟较大,但设备移除是罕见事件,这是合理的权衡。
5.3 Array and Length
RCU 保护可变长数组时,如果长度和数组指针分开存储,存在 race condition:CPU 0 读取长度 16,CPU 1 收缩数组为 8 并更新指针,CPU 0 用旧长度访问新数组导致越界。
解决方案:将长度和数组合并到同一结构体中,通过 rcu_assign_pointer() 原子更新指针。这样任何获取到指针的读者都能看到匹配的长度和数组。
5.4 Correlated Fields
与 4.2 类似,但针对单个数据元素内的相关字段。使用间接指针(indirection):将相关字段放入独立结构,通过 RCU 保护指针更新。为避免额外缓存未命中,可在主结构中嵌入一个默认的 measurement 实例,更新时临时指向新分配的结构,更新完成后再指回嵌入式实例。
C 代码实现:correlated_fields.c 演示了 RCU + 间接指针保证三个测量值的一致性。
5.5 Update-Friendly Traversal
对于需要扫描整个哈希表的统计操作,使用 RCU read-side critical section 可以让更新并发进行。对于可扩展哈希表,扫描时可能遇到 resizing,可以通过在 resize 期间同时更新新旧两个表来解决。
5.6 Scalable Reference Count Two
介绍 Linux 内核的 percpu_ref(已在 2.3 节讨论)。
5.7 Retriggered Grace Periods
当 open()/close() 语义与 RCU 结合时,简单的 call_rcu() 不够:如果在 grace period 结束前又调用了 open(),回调可能错误地释放仍在使用的资源。
解决方案是状态机:
- CLOSED -> OPEN -> CLOSING -> (REOPENING/RECLOSING) -> …
本章提供了伪代码和状态图。retrigger-gp.c 中的实现存在一处笔误(if (rtrg_status = RTRG_CLOSING) 应为 ==),但其状态转换逻辑清晰展示了如何处理 grace period 与 open/close 的竞争。
C 代码实现:retrigger_gp.c 使用模拟的 grace period 演示了三种场景:正常关闭、关闭前重新打开、多次快速开关。
5.8 Long-Duration Accesses Two
当 RCU reader 遍历极大的数据结构可能导致内存耗尽时,有几种解决方案:
- Use SRCU:独立的
srcu_struct将长时 reader 的限制隔离到特定数据结构,不影响全局 RCU grace period。 - Acquire a Reference:缩短 RCU critical section,在遍历中途获取对象引用后退出 RCU。Linux v5.16 的
khugepaged_scan_file()使用cond_resched_rcu()实现这一点。v6.17 的txopt_get()使用refcount_inc_not_zero()获取引用。 - Acquire a Hazard-Pointer Reference:当原子引用计数竞争过高时,使用 Hazard Pointers 替代。
5.9 Lockless Configuration Update
并发无锁更新单例配置指针时,直接使用 rcu_assign_pointer() 可能导致前一次更新被静默覆盖,造成内存泄漏。解决方案是使用原子 xchg():
old = xchg(&global_ptr, new);
synchronize_rcu();
kfree(old);
这样每次更新都会返回被覆盖的旧指针,确保可以正确释放。
5.10 Lockless Double-Checked Locking / Initialization
在无锁低延迟环境中实现首次初始化:
- 如果允许后续更新:使用 RCU 保护读路径,
xchg()执行更新,旧对象通过 RCU 释放。 - 如果仅需初始化(无后续更新):使用
cmpxchg()即可,无需 RCU。这是需求简化带来的实现简化。
5.11 Polling Patchups
Linux 内核 RCU 近年新增的 polling grace-period API 提供了重要的灵活性:
get_state_synchronize_rcu()- 获取当前 RCU 状态快照start_poll_synchronize_rcu()- 启动 grace period(如果尚未启动)poll_state_synchronize_rcu()- 轮询指定状态是否已完成 grace period
应用场景:
- Detecting Broken Readers:检测某个函数是否错误地临时退出了 RCU read-side critical section。如果在
get_state_synchronize_rcu()和poll_state_synchronize_rcu()之间发生了 grace period,说明 reader 曾经退出过。 - Restraining Reclamation:将内存回收推迟到工作负载方便的时候。通过给每个被移除的元素标记
oldstate,在合适的时机调用poll_state_synchronize_rcu()判断是否可释放。 - Proactive Reclamation:结合缓存结构(Polled RCU Cache),将元素分为”读者可访问”、”旧读者可能访问”、”无读者访问”三个区域,实现几乎零延迟的回收。
- Nurturing Non-Blocking Synchronization:
get_state_synchronize_rcu()和poll_state_synchronize_rcu()是 wait-free 的,适用于低延迟非阻塞同步场景。
内核源码 kernel/rcu/tree.c 中展示了实现:
unsigned long get_state_synchronize_rcu(void)
{
smp_mb();
return rcu_seq_snap(&rcu_state.gp_seq_polled);
}
6. Micro-Optimization(微优化)
本章最后提醒:最佳性能和可扩展性来自设计,而非事后优化。但当需要榨取最后一点性能时,微优化是必要的。
6.1 Specialization
通过模板(C++)或宏(C)消除函数指针调用,允许编译器内联,减少分支预测失败。这在关键路径上可能带来显著收益。
6.2 Bits and Bytes
- Bit-spinlocks:Linux 的
bit_spinlock.h将锁嵌入指针的低位,节省内存但牺牲性能和调试能力。 - 减少指针数量:使用单链表替代双向链表,或使用单一 bucket 指针集替代 resize 时的双份指针。
6.3 Hardware Considerations
缓存行对齐是最关键的微优化之一。将频繁更新的数据(如计数器)与只读数据(如链表指针)放在不同缓存行,避免 false sharing。
C 代码实现:microopt_demo.c 通过对比实验展示了缓存行对齐的效果。在测试机器上,将计数器对齐到独立缓存行使遍历性能提升了约 1.5 倍。
Linux 内核中大量使用这种技巧。例如 struct page 是高度空间优化的数据结构。对于 64 字节缓存行系统:
struct hash_elem {
struct ht_elem e;
long __attribute__ ((aligned(64))) counter;
};
规则总结:
- 将只读数据与频繁更新的数据分开
- 将不同代码路径更新的数据分组隔离
- 按 CPU/线程/任务划分更新密集的数据
- 对于无争用锁,将其与受保护数据放在同一缓存行
- 对于高度争用锁,将其放在不同缓存行或使用队列锁
与 Linux 内核源码的对照
| perfbook 概念 | Linux 内核源码位置 | 说明 |
|---|---|---|
| Simple Counting (“-“) | 多处 open-coded | 锁保护下的非原子计数 |
| Atomic Counting “A” | include/linux/kref.h |
kref 封装 refcount_t |
| Atomic + Release Barrier “AM” | include/net/dst.h |
dst_clone() / dst_release() |
| Atomic + Check “CAM” | fs/file.c, fs/file_table.c |
fget() / fput() |
| percpu_ref | include/linux/percpu-refcount.h |
可扩展引用计数 |
| seqlock | include/linux/seqlock.h |
seqcount_t, seqcount_latch_t |
| seqlock upgrade-to-writer | drivers/infiniband/hw/hfi1/sdma.c |
sdma_flush() |
| RCU polling APIs | kernel/rcu/tree.c |
get/poll/start_poll_synchronize_rcu() |
| SLAB_TYPESAFE_BY_RCU | mm/slab.h, mm/slub.c |
Type-safe memory 保证 |
| bit spinlock | include/linux/bit_spinlock.h |
空间优化的自旋锁 |
对用户疑问的回应
通过分析用户的并发笔记 ~/data/vn/docs/concurrent/,本章涉及或部分解答了以下疑问:
已解答或部分解答的疑问
-
atomic_inc_not_zero 的使用:本章
fget()的实现是最佳案例。file_ref_get()(内核中即refcount_inc_not_zero)用于从查找结构获取引用,当对象正在被释放时正确失败。 -
Hazard Pointer 的实现基础:13.3 节指出 Hazard Pointers 可以作为引用计数的可扩展替代,尤其适用于长时间持有引用的场景。用户笔记中关注了 Hazard Pointer 与 lock-free 的关系,本章提供了实际应用方向。
- 何时使用 seqlock 而不是 RCU:
- seqlock 用于保护频繁读取、极少写入的小数据集合,要求读者能容忍 retry。
- RCU 用于保护读取远多于写入的链表/树等结构,读者完全无锁。
- 本章 13.4.3 “Atomic Move” 展示了两者同时使用的场景:RCU 保护哈希表遍历,per-element seqlock 保证元素移动的原子性。
-
RCU 的正确使用:13.5 节通过 10+ 个具体场景系统性地回答了这个问题,涵盖统计计数器、设备移除、可变长数组、关联字段、长时访问等。
- Memory Barrier 的使用:本章多次出现
smp_wmb()、smp_rmb()和smp_mb(),特别是在引用计数释放、RCU 指针发布、seqlock 写端等场景。虽然深入理解需要阅读 memory ordering 章节,但本章提供了足够的使用范例。
未直接解答的疑问
-
Single-producer/single-consumer lockless queue:本章未直接讨论队列实现。但 Hazard Pointers 和 RCU 为无锁数据结构提供了基础,用户可参考 Folly 的实现。
-
READ_ONCE 与指令重排:本章使用了
READ_ONCE()/WRITE_ONCE(),但详细解释需参考内存序章节。简言之:READ_ONCE防止编译器优化和某些重排,但在弱序架构上可能仍需配合smp_load_acquire()。 -
Cache coherence 与 memory model 的关系:13.6.3 从硬件角度触及了 cache line 的重要性,但未深入理论层面。
-
DMA 的 cache 一致性:本章未涉及。
配套代码说明
本章提供了 8 个可编译运行的 C 示例,均附带 Makefile,二进制后缀为 .out:
| 文件 | 对应内容 | 功能 |
|---|---|---|
sref_demo.c |
13.2.1 Simple Counting | 锁保护的非原子引用计数 |
kref_demo.c |
13.2.2 Atomic Counting | 原子引用计数与 get_unless_zero |
seqlock_demo.c |
13.4 Sequence Locking | seqlock 保护相关数据的一致性读取 |
correlated_fields.c |
13.5.4 Correlated Fields | RCU + 间接指针保证字段一致性 |
count_end_rcu.c |
13.5.1 RCU Statistical Counters | RCU 保护 per-thread 计数器数组 |
atomic_move.c |
13.4.3 Atomic Move | RCU + seqlock 实现原子重命名 |
retrigger_gp.c |
13.5.6 Retriggered Grace Periods | open/close 与 grace period 竞争的状态机 |
microopt_demo.c |
13.6 Micro-Optimization | 缓存行对齐的性能对比 |
urcu_simple.h |
辅助头文件 | 简化的用户空间 RCU 实现 |
编译与测试:
make all # 编译所有示例
make test # 运行全部测试
make clean # 清理
所有测试已在本地验证通过。
总结
第 13 章是 perfbook 从理论走向实践的桥梁。它没有引入全新的同步原语,而是展示了如何将已学的计数器、引用计数、Hazard Pointers、Sequence Locking 和 RCU 组合起来解决真实问题。
核心洞见:
- 引用计数的复杂度不在于原子增减,而在于引用获取与对象释放的同步。Linux 内核的
kref/fget/percpu_ref代表了不同 trade-off 下的最优解。 - RCU 的真正威力在于延迟释放与读端零开销的结合。从统计计数器到配置更新,从数组长度到 grace period 状态机,RCU 提供了一致的解决框架。
- Sequence Lock 与 RCU 不是竞争关系而是互补关系:seqlock 保证修改原子性,RCU 保证存在性。
- 微优化应以测量为导向,缓存行对齐和数据结构特化往往比算法调整带来更稳定的收益。
本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。