Skip to the content.

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)的基本原则。

本章包含六个主要部分:

  1. Counter Conundrums - 计数难题的解决方案
  2. Refurbish Reference Counting - 引用计数的并发改造
  3. Hazard-Pointer Helpers - Hazard Pointer 的辅助应用
  4. Sequence-Locking Specials - Sequence Lock 的特殊用法
  5. RCU Rescues - RCU 在实际场景中的救援应用
  6. Micro-Optimization - 微优化的基本原则

1. Counter Conundrums(计数难题)

1.1 Counting Updates

在并发场景下,如果更新操作已经被某个 per-data-element lock 保护,那么最优的计数方式就是直接在数据元素内部放置一个计数器,并在持有该锁时递增它。这是最简单、最高效的方法,因为锁已经提供了所需的内存序和原子性保证。

如果读者需要无锁访问该计数器,更新者应使用 WRITE_ONCE(),读者应使用 READ_ONCE(),以防止编译器优化导致的数据竞争。

1.2 Counting Lookups

当查找操作由 RCU 保护时,计数变得复杂。如果直接对查找计数器加锁,会破坏 RCU 的可扩展性优势。本章提出了几种方案:

C 代码实现:见 sref_demo.c 中简单计数示例,以及 count_end_rcu.c 中基于 RCU 的统计计数器实现。


2. Refurbish Reference Counting(改造引用计数)

引用计数在概念上简单,但并发实现充满陷阱:对象可能在引用获取过程中被释放。本章系统性地总结了五种保护引用获取的方法:

  1. 外部锁保护:操作引用计数时必须持有外部锁。
  2. 非零初始化:对象创建时引用计数非零,新引用只能在计数非零时获取。
  3. Hazard Pointers 替代:在某些场景下可直接替换引用计数。
  4. Existence Guarantee:保证对象在引用获取期间不被释放(如 GC、Hazard Pointers、RCU)。
  5. 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

其中:

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 就是这类实现:

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 的 UnboundedQueueConcurrentHashMap 采用了这种设计。

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_tinclude/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 组合:

  1. 获取全局 rename 锁
  2. 分配并初始化新元素
  3. Write-acquire 旧元素的 seqlock(tombstone)
  4. 插入新元素
  5. smp_wmb() 排序插入与移除
  6. 移除旧元素
  7. Write-release seqlock
  8. 释放全局锁

读者查找旧元素时会 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[](当前运行线程的计数器指针数组)。线程退出时:

  1. 分配新的 countarray
  2. 复制旧数组内容
  3. 将本线程的计数加到 total
  4. 将本线程的 counterp[] 置为 NULL
  5. rcu_assign_pointer() 发布新数组
  6. synchronize_rcu() 等待所有读者完成
  7. 释放旧数组

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(),回调可能错误地释放仍在使用的资源。

解决方案是状态机:

本章提供了伪代码和状态图。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 遍历极大的数据结构可能导致内存耗尽时,有几种解决方案:

  1. Use SRCU:独立的 srcu_struct 将长时 reader 的限制隔离到特定数据结构,不影响全局 RCU grace period。
  2. Acquire a Reference:缩短 RCU critical section,在遍历中途获取对象引用后退出 RCU。Linux v5.16 的 khugepaged_scan_file() 使用 cond_resched_rcu() 实现这一点。v6.17 的 txopt_get() 使用 refcount_inc_not_zero() 获取引用。
  3. 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

在无锁低延迟环境中实现首次初始化:

5.11 Polling Patchups

Linux 内核 RCU 近年新增的 polling grace-period API 提供了重要的灵活性:

应用场景:

  1. Detecting Broken Readers:检测某个函数是否错误地临时退出了 RCU read-side critical section。如果在 get_state_synchronize_rcu()poll_state_synchronize_rcu() 之间发生了 grace period,说明 reader 曾经退出过。
  2. Restraining Reclamation:将内存回收推迟到工作负载方便的时候。通过给每个被移除的元素标记 oldstate,在合适的时机调用 poll_state_synchronize_rcu() 判断是否可释放。
  3. Proactive Reclamation:结合缓存结构(Polled RCU Cache),将元素分为”读者可访问”、”旧读者可能访问”、”无读者访问”三个区域,实现几乎零延迟的回收。
  4. Nurturing Non-Blocking Synchronizationget_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

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;
};

规则总结:

  1. 将只读数据与频繁更新的数据分开
  2. 将不同代码路径更新的数据分组隔离
  3. 按 CPU/线程/任务划分更新密集的数据
  4. 对于无争用锁,将其与受保护数据放在同一缓存行
  5. 对于高度争用锁,将其放在不同缓存行或使用队列锁

与 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/,本章涉及或部分解答了以下疑问:

已解答或部分解答的疑问

  1. atomic_inc_not_zero 的使用:本章 fget() 的实现是最佳案例。file_ref_get()(内核中即 refcount_inc_not_zero)用于从查找结构获取引用,当对象正在被释放时正确失败。

  2. Hazard Pointer 的实现基础:13.3 节指出 Hazard Pointers 可以作为引用计数的可扩展替代,尤其适用于长时间持有引用的场景。用户笔记中关注了 Hazard Pointer 与 lock-free 的关系,本章提供了实际应用方向。

  3. 何时使用 seqlock 而不是 RCU
    • seqlock 用于保护频繁读取、极少写入的小数据集合,要求读者能容忍 retry。
    • RCU 用于保护读取远多于写入的链表/树等结构,读者完全无锁。
    • 本章 13.4.3 “Atomic Move” 展示了两者同时使用的场景:RCU 保护哈希表遍历,per-element seqlock 保证元素移动的原子性。
  4. RCU 的正确使用:13.5 节通过 10+ 个具体场景系统性地回答了这个问题,涵盖统计计数器、设备移除、可变长数组、关联字段、长时访问等。

  5. Memory Barrier 的使用:本章多次出现 smp_wmb()smp_rmb()smp_mb(),特别是在引用计数释放、RCU 指针发布、seqlock 写端等场景。虽然深入理解需要阅读 memory ordering 章节,但本章提供了足够的使用范例。

未直接解答的疑问

  1. Single-producer/single-consumer lockless queue:本章未直接讨论队列实现。但 Hazard Pointers 和 RCU 为无锁数据结构提供了基础,用户可参考 Folly 的实现。

  2. READ_ONCE 与指令重排:本章使用了 READ_ONCE()/WRITE_ONCE(),但详细解释需参考内存序章节。简言之:READ_ONCE 防止编译器优化和某些重排,但在弱序架构上可能仍需配合 smp_load_acquire()

  3. Cache coherence 与 memory model 的关系:13.6.3 从硬件角度触及了 cache line 的重要性,但未深入理论层面。

  4. 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 组合起来解决真实问题。

核心洞见:

本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。