Linux 内核 RCU:Read-Copy-Update 并发机制深度工程实战

在 Linux 内核的并发工具箱中,RCU(Read-Copy-Update)是最精妙也最令初学者困惑的机制。它允许多个读者完全不加锁地访问共享数据,而写者则在副本上修改后原子替换指针,并延迟回收旧数据。这种"读多写少"场景下的极致设计,使 RCU 成为内核网络栈、文件系统、路由子系统的核心基础设施。本文将从硬件内存模型出发,逐层拆解 RCU 的实现原理、API 体系、场景陷阱与工程决策。

一、RCU 的问题定义与理论基础

1.1 读写锁的根本性缺陷

经典的 rwlock 在读者持有期间完全排斥写者——当存在大量读者时,写者面临严重的饥饿问题。即使在读多写少的场景下,rwlock 的原子操作(至少是一次 atomic increment)也会在多核之间引发缓存行乒乓(cache-line bouncing),导致可扩展性崩溃。

RCU 的哲学完全不同:读者不承担任何写操作。读取路径只需要禁用内核抢占(在经典 RCU 中),本质上零开销。

1.2 内存模型与happens-before

理解 RCU 必须先建立内存屏障的心智模型:

  • Publication(发布):写者更新全局指针时,必须保证新数据的初始化对读者可见。smp_wmb()(写屏障)确保发布前所有写入完成。
  • Subscription(订阅):读者在解引用指针前,必须保证能看到完整数据。smp_rmb()(读屏障)确保指针读取后再读取所指内容。
  • Grace Period(宽限期):从写者发布新指针开始,到"所有在发布前开始的读临界区都结束"的时间窗口。
时间线:
Writer:        |--publish(new_ptr)--|--synchronize_rcu()--|
Reader-1: |--rcu_read_lock--|       |--rcu_read_unlock--|
Reader-2:         |--rcu_read_lock-----------------|--rcu_read_unlock--|
                 Grace Period: = 所有旧读端临界区的最大跨度

二、RCU 核心 API 详解

2.1 经典 RCU 读端 API

// 进入读临界区(仅禁止内核抢占,不产生原子操作)
rcu_read_lock();

// 安全地解引用受 RCU 保护的指针
void *p = rcu_dereference(gp);
if (p)
    do_something_with(p);

// 退出读临界区
rcu_read_unlock();

rcu_dereference() 在非 DEC Alpha 架构上是一个简单的 barrier() + 类型转换;在 α 平台则插入完整的读内存屏障。它返回的是"获取那一刻"的指针值,编译器不能将其后面的内存读取重排到它前面。

2.2 写端 API

                        ┌────────────────────────────────────┐
               写入端   │  1. kmalloc / 初始化新数据           │
                        │  2. 修改新数据的各字段                │
                        │  3. rcu_assign_pointer(gp, new)     │
                        │  4. synchronize_rcu() 或 call_rcu() │
                        │  5. kfree(old)                       │
                        └────────────────────────────────────┘

rcu_assign_pointer():带有写内存屏障的指针赋值,确保 new 的所有字段初始化在指针发布之前完成。

两种等待宽限期的策略:

API 行为 使用场景
synchronize_rcu() 阻塞等待所有当前读者离开读临界区 模块卸载、路径不能睡眠
call_rcu(head, func) 异步回调,宽限期结束后执行 func 高频写入、不可阻塞上下文

2.3 SRCU(Sleepable RCU)

当读临界区可能睡眠(如访问用户内存、执行 I/O)时,必须使用 SRCU:

int srcu_read_lock(struct srcu_struct *ssp);
void srcu_read_unlock(struct srcu_struct *ssp, int idx);
void synchronize_srcu(struct srcu_struct *ssp);

SRCU 的开销高于经典 RCU——它在读端维护了一个 per-CPU 计数器阵列,且 synchronize_srcu() 的开销随 CPU 数量线性增长。但在需要睡眠的路径上,这是唯一的选择。

三、RCU 的内核实现机制

3.1 拓扑感知的宽限期推进

Linux 4.x 以后,RCU 采用树状拓扑(rcu_node 树)跟踪各 CPU 上的读者状态:

                 rcu_node (root)
                /               \
        rcu_node(0)         rcu_node(1)
        /         \          /         \
    CPU0..3    CPU4..7   CPU8..11   CPU12..15

每个叶子节点监控一批 CPU,记录它们是否通过了 quiescent state(静止状态,即 CPU 离开读临界区或发生上下文切换)。只有所有 CPU 都通过一次静止状态,宽限期才算结束。

3.2 dyntick-idle 优化

在 NO_HZ_IDLE 系统中,当 CPU 空闲时停止周期性时钟中断,此时 CPU 处于 dyntick-idle 模式——它自然处于静止状态。RCU 利用这一特性,无需等待时钟 tick 即可确认空闲 CPU 的静止状态,大幅缩短宽限期判定延迟。

3.3 Tree RCU 的状态机

每个 CPU 的 rcu_data:
  ├── completed: 已完成的 GP 编号
  ├── gpnum:     当前正在经历的 GP 编号
  └── qs[4]:     每级 rcu_node 的 QS 确认位

GP 推进流程:
  1. GP 开始:设置 gpnum,等待所有 CPU 报告 QS
  2. 叶子层收集 QS,逐层向根节点上报
  3. 根节点确认后,触发回调执行

3.4 call_rcu 的回调批处理

频繁的 call_rcu 不会立刻触发宽限期检测。内核将回调组织在 rcu_data->nxtlist 链表中,通过 RCU_SOFTIRQ 在适当的时机批量执行,减少 GP 起始次数并摊销 softirq 开销。

四、经典 RCU 模式:受保护链表的增删改查

4.1 RCU 链表遍历

struct my_node {
    int key;
    void *data;
    struct rcu_head rcu;  // 嵌入其中供 call_rcu 使用
    struct list_head node;
};

// 读者:完全无锁遍历
struct my_node *find(struct list_head *head, int key)
{
    struct my_node *p;

    rcu_read_lock();
    list_for_each_entry_rcu(p, head, node) {
        if (p->key == key) {
            rcu_read_unlock();
            return p;
        }
    }
    rcu_read_unlock();
    return NULL;
}

4.2 RCU 安全删除

void delete_node(struct list_head *head, int key)
{
    struct my_node *p, *tmp;
    struct my_node *to_free = NULL;

    write_lock(&my_lock);  // 写者之间仍需要互斥
    list_for_each_entry_safe(p, tmp, head, node) {
        if (p->key == key) {
            list_del_rcu(&p->node);
            to_free = p;
            break;
        }
    }
    write_unlock(&my_lock);

    if (to_free) {
        synchronize_rcu();  // 等待所有读者安全离开
        kfree(to_free);      // 再无读者访问,安全释放
    }
}

4.3 RCU 替换(Update)

void replace_node(struct list_head *head, int key, void *new_data)
{
    struct my_node *old, *new;

    new = kmalloc(sizeof(*new), GFP_KERNEL);
    new->key = key;
    new->data = new_data;

    write_lock(&my_lock);
    old = find_locked(head, key);  // 在写锁保护下找到旧节点
    list_replace_rcu(&old->node, &new->node);
    write_unlock(&my_lock);

    call_rcu(&old->rcu, free_node_callback);
}

static void free_node_callback(struct rcu_head *rh)
{
    struct my_node *old = container_of(rh, struct my_node, rcu);
    kfree(old);
}

五、RCU 在内核子系统中的工程应用

5.1 网络路由缓存(dst/route)

Linux 路由查找采用 RCU 保护的路由缓存。路由表频繁被查找(每包一次),但更新频率相对极低。RCU 使得 ip_route_output() 路径上几乎零开销:

__ip_route_output_key()
    rcu_read_lock()
        rt = rcu_dereference(rp->table)  // 获取当前路由表
        ... 计算哈希、查找 ...
    rcu_read_unlock()

5.2 文件系统 inode 缓存

Linux VFS 的 inode_hash_table 受 RCU 保护。find_inode_fast() 在 RCU 模式下遍历哈希链,无锁查找 inode:

// fs/inode.c
static struct inode *find_inode_fast(struct super_block *sb,
    struct hlist_head *head, unsigned long ino)
{
    struct inode *inode;

    hlist_for_each_entry_rcu(inode, head, i_hash) {
        if (inode->i_ino == ino && inode->i_sb == sb)  // BC-ing ipref
            return inode;
    }
    return NULL;
}

iput() 路径中 inode 从活跃链表移除后使用 call_rcu 延迟释放。

5.3 进程热插拔与 CPU Mask

cpu_online_mask 使用 RCU 保护。CPU online/offline 事件触发 mask 修改时,读者(如调度器、cpuset)通过 cpumask_test_cpu 等 RCU 安全接口访问。

5.4 内核模块引用计数

try_module_get() 路径避免了原子操作开销,而模块卸载使用 synchronize_rcu() 确保所有引用者退出。

5.5 Netfilter 规则链

iptables/nftables 的 rule chain 在查找时使用 RCU 遍历,写入时使用 synchronize_rcu + 替换规则的新版本。

六、RCU vs Mutex vs Rwlock:性能工程决策

6.1 标准微基准测试

在 128 核 ARM64 服务器上的典型结果(读多写少,读:写=1000:1):

┌──────────────┬────────────────┬──────────────────┬─────────────────┐
│ 同步机制      │ 读者延迟(ns)   │ 写者延迟(ns)     │ 总吞吐量(Mops/s) │
├──────────────┼────────────────┼──────────────────┼─────────────────┤
│ RCU          │ ~5             │ ~50000 + GP时间  │ ~1250           │
│ rwlock       │ ~25            │ ~26              │ ~850            │
│ mutex        │ ~55            │ ~56              │ ~620            │
│ seqlock      │ ~15 (无重试)   │ ~20              │ ~1100           │
│ rwsem        │ ~30            │ ~32              │ ~780            │
└──────────────┴────────────────┴──────────────────┴─────────────────┘

6.2 决策矩阵

场景 推荐机制 理由
读极为频繁,写罕见,不能阻塞读者 RCU 读者零开销,可扩展到千核
读多写少,偶尔需重试 Seqlock 写者优先,读者可重试
写稍频繁,容忍读者短暂阻塞 rwlock/rwsem 实现简单
读写相当,临界区小 spinlock 最简语义
读者需睡眠 SRCU 或 mutex 避免死锁
临界区递归 mutex 唯一支持递归的

6.3 RCU 不适用场景

  1. 写远比读多:宽限期成本每次都需支付,总开销大于锁
  2. 临界区内需获取其他锁(可能死锁的顺序):RCU 读临界区获取 spinlock 是合法的;但获取 mutex 可能导致睡眠死锁(经典 RCU 中禁止睡眠)
  3. 超大临界区的实时路径:宽期可能阻塞太久

七、高级 RCU 变体

7.1 RCU Tasks Trace(RT-RCU)

专为 tracing 设计,读端仅需 preempt_disable()(甚至不需要),宽限期采用 IPI(处理器间中断)精确追踪。Tasks-RCU 家族的变体包括 RCU-Tasks、RCU-Tasks-Rude、RCU-Tasks-Trace,各有不同的延迟目标。

7.2 RCU Priority Boosting

在 RT(实时)内核中,RCU 读者可能阻塞实时写者。内核引入优先级继承机制:当高优先级任务等待宽限期,而低优先级任务持有读临界区时,临时提升低优先级任务的优先级。

7.3 BPF 中的 RCU

eBPF map 操作(如 bpf_map_lookup_elem)在内核中依赖 RCU 保护。BPF 程序可以自由调用 RCU 辅助函数,因为 BPF 上下文本身就是 RCU 读临界区。

八、调试与故障排查

8.1 CONFIG_PROVE_RCU

启用 CONFIG_PROVE_RCU 后,RCU 锁验证器严格检查每一条规则:

WARNING: suspicious rcu_dereference_check() usage
  vfs_read+0x45/0x120

RCU-protected pointer used outside of RCU read-side critical section

这会捕获在 RCU 读临界区之外使用 rcu_dereference() 的危险操作。

8.2 RCU Stall 检测

CONFIG_RCU_STALL_COMMON 启用宽限期超时检测。当宽限期拖延超过 rcu_stall_kernel_timeout(默认约 21 秒,可通过 sysctl 调整)时,内核打印调用栈:

INFO: RCU GPdetected stall on CPU 3
  rcu_sched kthread starving for 26436 jiffies!

常见原因: - CPU 长时间关中断(如死循环) - CPU 长时间处于 dyntick-idle 但 RCU 不认可 - 单个读者持有读临界区过长

8.3 rcu_dereference_sparse / __rcu 标记

Sparse 静态分析工具配合 __rcu 标记检查类型安全:

struct foo __rcu *gp;  // 告诉 sparse 这个指针需要 rcu_dereference

错误的直接解引用会触发稀疏警告。

8.4 常见陷阱一:忘记同步

// 危险!没有等待宽限期就释放旧数据
list_del_rcu(&p->node);
kfree(p);  // BUG! 读者可能还在访问 p

正确做法:使用 synchronize_rcu() 或 call_rcu(&p->rcu, free_callback)。

8.5 常见陷阱二:经典 RCU 中睡眠

rcu_read_lock();
copy_from_user(buf, ubuf, len);  // 可能触发缺页 → 睡眠 → 死锁!
rcu_read_unlock();

正确做法:在外面临时拷贝用户内存,或改用 SRCU。

8.6 常见陷阱三:写者之间未同步

// RCU 只保护读者-写者,不保护写者-写者!
// 并发调用 this function 将导致数据竞争
list_add_rcu(&new_node->node, head);

正确做法:写者之间仍需使用 spinlock 或 mutex。

九、实战:实现一个 RCU 保护的并发哈希表

#define HT_BUCKETS 256

struct ht_entry {
    u32 key;
    void *value;
    struct hlist_node hlist;
    struct rcu_head rcu;
};

struct ht_bucket {
    struct hlist_head head;
    spinlock_t lock;
};

static struct ht_bucket ht_buckets[HT_BUCKETS];

// 查询 — 纯 RCU 无锁
void *ht_lookup(u32 key)
{
    struct ht_entry *e;
    u32 hash = jhash_1word(key, 0) & (HT_BUCKETS - 1);
    void *val = NULL;

    rcu_read_lock();
    hlist_for_each_entry_rcu(e, &ht_buckets[hash].head, hlist) {
        if (e->key == key) {
            val = e->value;
            break;
        }
    }
    rcu_read_unlock();
    return val;
}

// 插入 — 需 spinlock
int ht_insert(u32 key, void *value)
{
    struct ht_entry *e = kmalloc(sizeof(*e), GFP_KERNEL);
    u32 hash;
    if (!e) return -ENOMEM;

    e->key = key;
    e->value = value;
    hash = jhash_1word(key, 0) & (HT_BUCKETS - 1);

    spin_lock(&ht_buckets[hash].lock);
    hlist_add_head_rcu(&e->hlist, &ht_buckets[hash].head);
    spin_unlock(&ht_buckets[hash].lock);
    return 0;
}

// 删除 — spinlock + call_rcu
void ht_delete(u32 key)
{
    struct ht_entry *e;
    u32 hash = jhash_1word(key, 0) & (HT_BUCKETS - 1);

    spin_lock(&ht_buckets[hash].lock);
    hlist_for_each_entry(e, &ht_buckets[hash].head, hlist) {
        if (e->key == key) {
            hlist_del_rcu(&e->hlist);
            spin_unlock(&ht_buckets[hash].lock);
            call_rcu(&e->rcu, ht_free_entry);
            return;
        }
    }
    spin_unlock(&ht_buckets[hash].lock);
}

static void ht_free_entry(struct rcu_head *rh)
{
    struct ht_entry *e = container_of(rh, struct ht_entry, rcu);
    kfree(e);
}

十、RCU 与硬件内存模型进阶

10.1 ARM64 的 RCU 屏障生成

ARM64 弱序模型要求更谨慎的内存屏障使用。rcu_assign_pointer() 在 ARM64 上映射为 store-release(stlr 指令),确保所有之前的写入对随后加载新指针的 CPU 可见。

rcu_dereference() 映射为 load-acquire(ldar),可以与写入端的 release 配对。

10.2 PowerPC 的 lwsync

PowerPC 使用 lwsync(轻量同步)实现 RCU 屏障——这是一种比完整 sync 更高效但仍能保证 RCU 顺序性的指令。

10.3 x86 的 TSO 和隐式屏障

x86 的 TSO(Total Store Order)内存模型天然保证:写者写指针的操作不会被读者看到。因此 rcu_assign_pointer() 在 x86 上退化为仅仅防止编译器重排序。

十一、RCU 与 eBPF 的协同

eBPF 程序在 RCU 读临界区内执行(从 syscall entry 退出到 do_softirq 之前)。这意味着 BPF 程序可以直接使用 bpf_rcu_read_lock() / bpf_rcu_read_unlock() 保护对 RCU 保护内核结构的安全访问。

// BPF 程序中使用 RCU
SEC("kprobe/ht_lookup")
int BPF_PROG(trace_ht_lookup, u32 key)
{
    bpf_rcu_read_lock();
    // 安全地访问内核中 RCU 保护的哈希表
    void *val = bpf_map_lookup_elem(&user_ht, &key);
    if (val) {
        bpf_printk("found value %p for key %u\n", val, key);
    }
    bpf_rcu_read_unlock();
    return 0;
}

十二、未来方向:URCU 与 Rust RCU

12.1 Userspace RCU (URCU)

LTTng、 dyninst 等项目使用用户态 RCU(liburcu)实现与内核 RCU 类似的无锁读取。URCU 采用 qsbr(Quiescent State Based Reclamation)模型:读者显式声明静止状态,写者扫描所有读者确认后回收。

12.2 Rust for Linux 中的 RCU

Rust 内核模块可以通过 kernel::rcu 模块安全绑定 RCU API:

use kernel::rcu;

struct MyData {
    value: u64,
}

impl MyData {
    fn read() -> Result<u64> {
        let guard = rcu::read_lock();  // 进入读临界区
        let p = unsafe { rcu::dereference(self.gp) };
        Ok(unsafe { (*p).value })
        // guard drop 时自动调用 rcu_read_unlock
    }
}

利用 Rust 的 RAII 和生命周期保障,可消除忘记配对的 rcu_read_lock/unlock 问题。

十三、总结与工程决策清单

RCU 不是万能的同步原语,而是在特定场景下(读多写少 + 读者不可阻塞)无与伦比的性能引擎。工程中使用 RCU 的决策清单:

□ 读路径是否频繁?(> 1000:1 读写比)
□ 读者是否可以承受零开销?(答案是 RCU 的核心优势)
□ 读者是否可能在临界区内睡眠?(是则必须使用 SRCU)
□ 写者之间是否已另有互斥保护?(RCU 不管写者-写者竞争)
□ 宽实时延是否可接受?(`synchronize_rcu()` 在繁忙系统可达数十毫秒)
□ 旧数据的回收是否可以延迟?(必须能等待宽限期结束)
□ 是否在 debug 环境启用了 CONFIG_PROVE_RCU?(应该)

掌握 RCU,意味着拿到了通往 Linux 内核高性能区域的路由钥匙。从网络包处理到文件查找,从路由表到热插拔——凡追求"读者感知不到存在"的地方,RCU 就在那里安静地工作。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }