土法炼钢兴趣小组的算法知识备份

【eBPF 内核实现深度拆解】BPF 并发模型:spinlock、RCU 与 per-CPU 模式

文章导航

分类入口
kernelebpf
标签入口
#ebpf#concurrency#spinlock#rcu#percpu#bpf_atomic#smp#linux-kernel

目录

你写了一个 BPF 程序跟踪 tcp 重传:遍历 struct tcp_sockretransmit_skb_hint 计数,更新到一个 hash map 中聚合。在开发机上单核测试正常;部署到多核服务器后,聚合计数出现严重丢失——这是典型的竞态症状:同一 BPF 程序在多个 CPU 上同时执行,对同一个 hash map value 做非原子 *counter += 1,高并发下计数会系统性偏低。问题不是 verifier,不是 helper 函数,而是并发原语的选择。

BPF 程序在内核上下文中并发执行——同一个 BPF 程序可能同时在所有 CPU 上运行。本文从 BPF 的并发环境出发,讲清 BPF_ATOMIC 指令的语义与平台映射、bpf_spin_lock 的实现与 verifier 约束、RCU 保护的 map 读取、per-CPU map 的免锁读写模式,以及中断上下文与进程上下文的执行语义差异。

一、BPF 程序的并发执行环境

1.1 多种内核上下文

BPF 程序可能在以下上下文中执行,取决于挂钩点类型:

挂钩点 内核上下文 是否可睡眠 同时执行数
XDP NAPI 软中断 不可 每 CPU 1 个
TC BPF 软中断 不可 每 CPU 1 个
socket filter 软中断或进程上下文 不可 未知
tracepoint 取决于挂钩位置 取决于 取决于
kprobe 中断或进程上下文 不可(中断)/ 可(进程) 取决于
raw tracepoint 取决于调用者 取决于 取决于
cgroup BPF 进程上下文 每 CPU 每进程
LSM BPF 进程上下文 每 CPU 每进程
struct_ops 取决于调用者 取决于 取决于

1.2 并发场景的真实压力

最极端的并发场景:64 核服务器上,NAPI 在每个 CPU 独立轮询,64 个 XDP 程序实例同时执行,全部可能对同一个 BPF map 中同一个 key 调用 bpf_map_update_elem()——每个 CPU 上的程序都试图写入同一块内存。kprobe on __schedule() 和 tracepoint on sched:sched_switch 同样面临全 CPU 并发。

1.3 BPF 内存模型总览

BPF 程序没有自己的线程模型,也没有锁机制的内核态支持(不能直接使用 kernel 的 spin_lock())。BPF 提供了三种并发控制原语:

flowchart TD
    Q["BPF 并发控制需求"] --> SIMPLE["简单计数器?"]
    Q --> COMPLEX["多字段原子更新?"]
    Q --> READONLY["只读或 CPU 本地?"]

    SIMPLE --> ATOMIC["BPF_ATOMIC 指令<br/>(5.6+)"]
    COMPLEX --> SPINLOCK["bpf_spin_lock<br/>(5.1+)"]
    READONLY --> PERCPU["per-CPU map<br/>免锁"]

    Q --> RCU["RCU 保护的 map 读取<br/>(所有 map 类型隐式 RCU)"]

二、BPF_ATOMIC 指令:原子操作

2.1 指令格式

BPF 原子指令在 BPF ISA v3(Linux 5.6+)中引入。指令编码复用 BPF_STX 指令的 code 字段,通过额外的 imm 字段指定具体原子操作:

/* Linux 6.6: include/uapi/linux/bpf.h */
#define BPF_ATOMIC_ADD   0xe0  /* 原子加 */
#define BPF_ATOMIC_AND   0xe2  /* 原子按位与 */
#define BPF_ATOMIC_OR    0xe3  /* 原子按位或 */
#define BPF_ATOMIC_XOR   0xe4  /* 原子按位异或 */
#define BPF_ATOMIC_XCHG  0xe6  /* 原子交换 */
#define BPF_ATOMIC_CMPXCHG 0xe7 /* 原子比较并交换 */

/* BPF_ATOMIC 的 size 修饰符(code 字段的 bits)*/
#define BPF_FETCH        0x01  /* fetch-and-op:返回旧值 */

指令编码:BPF_STX | BPF_ATOMIC | BPF_W | <size_flag>,其中 dst_reg 指向目标地址所在的基础寄存器,src_reg 包含操作数,off 为目标偏移,imm 为原子操作子码。

2.2 内存排序语义

BPF_ATOMIC 指令提供完整的内存屏障语义。在 x86-64 上,JIT 编译器将其翻译为带有 lock 前缀的指令:

/* arch/x86/net/bpf_jit_comp.c 中的原子操作翻译逻辑 */
/* BPF_ATOMIC_ADD + BPF_W → lock addl src, (dst) */
/* BPF_ATOMIC_CMPXCHG + BPF_DW → lock cmpxchgq src, (dst) */

在 ARM64 上,使用 LSE(Large System Extensions)原子指令(如果硬件支持)或 LL/SC(Load-Linked/Store-Conditional)回退:

/* arch/arm64/net/bpf_jit_comp.c 中的原子操作翻译逻辑 */
/* BPF_ATOMIC_ADD + BPF_W → stadd w_src, [x_dst]   (ARMv8.1 LSE) */
/* 回退(无 LSE)→ ldaxr + add + stlxr loop */

2.3 verifier 约束

verifier 对原子操作施加了额外的检查:

/* Linux 6.6: kernel/bpf/verifier.c */
static int check_atomic(struct bpf_verifier_env *env, int insn_idx,
                        struct bpf_insn *insn)
{
    ...
    /* 1. 目标必须是指针(不能是标量)*/
    if (!reg_is_pkt_pointer(reg) && !is_map_value(dst_reg))
        return -EACCES;

    /* 2. 操作数必须对齐(自然对齐)*/
    /* BPF_W 需要 4 字节对齐,BPF_DW 需要 8 字节对齐 */

    /* 3. 操作数必须在对象边界内 */

    /* 4. 操作数不能是指针(防止指针破坏)*/
    ...
}

最关键的限制:原子操作的目标必须是 map value 内存或栈内存,不能是任意内核内存。

2.4 用法示例:原子计数器

/* BPF C 代码:原子计数器 */
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 1024);
} counter_map SEC(".maps");

SEC("fentry/tcp_connect")
int BPF_PROG(count_tcp_connects, struct sock *sk)
{
    u32 key = 0;  /* 全局单 key */
    u64 *counter = bpf_map_lookup_elem(&counter_map, &key);
    if (!counter)
        return 0;

    /* __sync_fetch_and_add 生成 BPF_ATOMIC_FETCH_ADD 指令 */
    __sync_fetch_and_add(counter, 1);
    return 0;
}

编译后,该原子操作的 BPF 指令输出(bpftool prog dump xlated):

  ...
  10: (db) r1 = *(u64 *)(r10 - 8)       ; r1 = counter pointer
  11: (c3) r2 = *(u32 *)(r10 - 12)      ; r2 = 1
  12: (db) lock *(u64 *)(r1 + 0) += r2  ; BPF_ATOMIC_ADD
  ...

2.5 原子操作适用场景

操作 开销量级 适用场景
BPF_ATOMIC_ADD 低(无竞争时约数十 cycles,随 cache bounce 上升) 计数器、引用计数
BPF_ATOMIC_FETCH_ADD 同上 需要旧值的计数器(如生成唯一 ID)
BPF_ATOMIC_CMPXCHG 略高于 ADD 构建无锁数据结构、状态机
BPF_ATOMIC_XCHG 同上 指针交换

上表为定性量级,具体 cycles 取决于 CPU 型号、对齐与竞争程度,本站未做基准测试。

注意事项:原子操作的性能劣化主要来自 cache line bouncing——在 64 个 CPU 上对同一 cache line 的原子递增,会导致 cache line 在 CPU 间频繁弹跳,每次 ~50–200ns。per-CPU 分配可以有效避免这个问题。

三、bpf_spin_lock:BPF 自旋锁

3.1 数据结构

bpf_spin_lock 是嵌入在 map value 中的锁结构体:

/* Linux 6.6: include/uapi/linux/bpf.h */
struct bpf_spin_lock {
    __u32 val;  /* queued_spin_lock 的值 */
};

map value 结构体示例:

/* 包含自旋锁的 map value */
struct lockable_value {
    struct bpf_spin_lock lock;
    u64 count;
    u32 flag;
    u32 last_cpu;
    /* ... 其他需要原子更新的字段 */
};

3.2 verifier 约束

verifier 对 bpf_spin_lock 施加了极其严格的约束:

/* Linux 6.6: kernel/bpf/verifier.c 中的 spinlock 检查逻辑 */
/*
 * 约束 1:bpf_spin_lock 必须是 map value 的第一个字段
 * 约束 2:不能在持有 spinlock 时调用 helper 函数
 * 约束 3:不能持有多个 spinlock(单锁持有)
 * 约束 4:spinlock 必须在函数结束时释放(所有路径)
 * 约束 5:不能将 bpf_spin_lock 的值复制到其他地方
 */

约束 2 是最大的实践限制。verifier 在 process_spin_lock() 中将当前寄存器状态标记为 active_spin_lock,任何 helper 调用前都会检查这个标志:

/* kernel/bpf/verifier.c 中 check_helper_call() 的简化逻辑 */
static int check_helper_call(struct bpf_verifier_env *env, ...)
{
    ...
    /* 持有 spinlock 时不能调用 helper */
    if (cur->active_spin_lock) {
        verbose(env, "cannot call helper while holding spinlock\n");
        return -EINVAL;
    }
    ...
}

这意味着 bpf_spin_lock(&val->lock); bpf_map_update_elem(...); bpf_spin_unlock(&val->lock); 会被 verifier 拒绝——不能在持锁期间更新 map。但 bpf_spin_lock(&val->lock); val->count++; bpf_spin_unlock(&val->lock); 是可以的。

3.3 内核实现

bpf_spin_lock()bpf_spin_unlock() 通过 helper 函数实现:

/* Linux 6.6: kernel/bpf/helpers.c */
BPF_CALL_1(bpf_spin_lock, struct bpf_spin_lock *, lock)
{
    /* 使用内核的 queued_spin_lock */
    queued_spin_lock_slowpath(&lock->val, ...);
    return 0;
}

BPF_CALL_1(bpf_spin_unlock, struct bpf_spin_lock *, lock)
{
    queued_spin_unlock(&lock->val);
    return 0;
}

关键细节:bpf_spin_lock 不是完整的内核自旋锁——它保护的只是 BPF 程序之间的并发访问,不是 BPF 程序和内核原生代码之间的并发访问。如果内核的某个函数也在访问同一块 map value 内存(比如 BPF map 的内部更新操作),bpf_spin_lock 不能阻止它。

3.4 死锁防护

verifier 保证程序不能同时持有多个 bpf_spin_lock,从而避免了 AB-BA 死锁。但这不意味着程序安全——如果内核本身在持有某个内部锁时调用 BPF 程序,而 BPF 程序又在等待 bpf_spin_lock,则可能触发锁序反转。

四、RCU 与 BPF maps

4.1 隐式 RCU 保护

绝大多数 BPF 程序在 隐式 RCU read-side 临界区 中运行。这意味着 bpf_map_lookup_elem() 返回的指针受 RCU 保护——指针在 RCU 宽限期内保持有效,但不能跨 RCU 临界区持有。

/* hash map 查找的实现——通过 rcu_dereference() 访问 */
/* Linux 6.6: kernel/bpf/hashtab.c */
static void *__htab_map_lookup_elem(struct bpf_map *map, void *key)
{
    struct htab_elem *l;
    ...
    hlist_for_each_entry_rcu(l, head, hash_node) {
        if (!memcmp(l->key, key, map->key_size)) {
            return l->key + round_up(map->key_size, 8);
        }
    }
    return NULL;
}

BPF 程序在以下程序类型中运行在隐式 RCU 读锁内:XDP、TC BPF、socket filter、tracepoint、raw tracepoint。kprobe 程序在中断上下文中也在 RCU 读锁内(因为 irq_enter() 会调用 rcu_irq_enter())。

4.2 可睡眠 BPF 程序与显式 RCU

对于可睡眠的程序类型(如 BPF_PROG_TYPE_LSM 和某些 BPF_PROG_TYPE_TRACING 程序),RCU 保护可能不自动生效。这些程序需要显式管理 RCU 临界区:

/* Linux 6.8+: bpf_rcu_read_lock() / bpf_rcu_read_unlock() kfunc */
SEC("lsm.s/file_open")
int BPF_PROG(file_open_audit, struct file *file)
{
    bpf_rcu_read_lock();

    /* 在 RCU 保护下访问 map 元素 */
    u64 *val = bpf_map_lookup_elem(&my_map, &key);
    if (val)
        *val += 1;

    bpf_rcu_read_unlock();
    return 0;
}

4.3 RCU 指针存储(6.8+)

Linux 6.8 引入了 bpf_rcu_pointer_store()bpf_obj_drop() 用于在 BPF 程序中使用 RCU 保护的指针。这使得在 BPF 程序中构建 RCU 保护的数据结构成为可能:

/* 在 BPF map 中存储 RCU 保护的对象引用 */
struct my_obj {
    struct bpf_spin_lock lock;
    struct bpf_list_node list;  /* 链表节点(RCU 保护)*/
    int data;
};

/* 使用 bpf_obj_new() 分配对象,bpf_list_push_front() 加入链表 */
/* 使用 bpf_obj_drop() 释放(等 RCU 宽限期后真正释放)*/

五、per-CPU map:零锁读写

5.1 机制原理

BPF_MAP_TYPE_PERCPU_ARRAYBPF_MAP_TYPE_PERCPU_HASH 为每个 CPU 分配独立的内存副本。BPF 程序在 CPU N 上执行时,直接访问 CPU N 的副本——没有锁,没有原子。

/* Linux 6.6: kernel/bpf/arraymap.c (per-CPU array 分配) */
static int __percpu_array_map_alloc(struct bpf_map *map)
{
    /* 为每个 CPU 分配一块 array */
    array->pptrs = bpf_map_alloc_percpu(map,
                     bpf_array_alloc_size(map),
                     sizeof(u64), GFP_USER | __GFP_NOWARN);
    ...
}

per-CPU 访问路径没有锁、没有原子指令、也没有 cache line bouncing:

/* BPF 程序在 CPU N: 直接访问 array->pptrs[N] */
/* 内核在 bpf_map_update_elem 中的逻辑 */
static long percpu_array_map_update_elem(struct bpf_map *map, ...)
{
    /* 通过 this_cpu_ptr 获取当前 CPU 的副本 */
    void __percpu *pptr = array->pptrs + index * mem_alloc_size;
    memcpy(this_cpu_ptr(pptr), value, map->value_size);
    return 0;
}

5.2 per-CPU map 的使用示例

/* per-CPU 计数器——零锁 */
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, u32);
    __type(value, u64);
    __uint(max_entries, 1);
} percpu_counter SEC(".maps");

SEC("xdp")
int xdp_counter(struct xdp_md *ctx)
{
    u32 key = 0;
    u64 *counter = bpf_map_lookup_elem(&percpu_counter, &key);
    if (counter) {
        *counter += 1;  /* 零锁——总是访问本 CPU 的副本 */
    }
    return XDP_PASS;
}

5.3 用户态聚合

per-CPU 数据分散在各 CPU 中,用户态读取时需要聚合:

/* 用户态程序:聚合所有 CPU 的计数器 */
u64 total = 0;
int ncpus = libbpf_num_possible_cpus();
u64 *values = calloc(ncpus, sizeof(u64));

bpf_map_lookup_elem(map_fd, &key, values);
for (int i = 0; i < ncpus; i++)
    total += values[i];
printf("Total: %llu\n", total);

per-CPU 模式的适用性判断:

六、并发模型的选择决策

6.1 决策表

场景 推荐方案 性能量级 主要限制
简单计数器 per-CPU 零竞争开销 需要用户态聚合
简单计数器(需精确单一值) BPF_ATOMIC_ADD 低(高并发时有 cache bounce) 单变量原子操作
多字段原子更新 bpf_spin_lock 中等(无竞争时) 不能调 helper
只读数据 直接读取 0 额外开销 需 RCU 保护
哈希表共享状态 bpf_spin_lock 中等 每次锁操作独立
统计直方图 per-CPU array 零竞争开销 用户态聚合

6.2 实际的复杂度

组合使用这些原语时,复杂度是非线性的:

6.3 上下文感知

BPF 程序必须考虑自己运行的上下文来决定能使用哪些并发原语:

XDP(通常为 NAPI 软中断上下文):XDP 在驱动 NAPI poll 路径中执行,属于软中断上下文而非硬中断(与表 1.1 一致)。可使用 BPF_ATOMIC、per-CPU map、bpf_spin_lock(自旋锁在软中断中可用)、RCU(隐式保护)。注意:若 XDP 挂在 generic 模式或特定驱动路径,上下文可能不同,应以实际挂钩点为准。

TC / socket(软中断上下文):全部可用——BPF_ATOMIC、per-CPU map、bpf_spin_lock、RCU(隐式保护)。

kprobe(可能是硬中断或进程上下文):取决于触发点——硬中断上下文不能使用可能睡眠的原语;进程上下文则全功能可用。

LSM / tracing(进程上下文):全功能可用,包括 bpf_rcu_read_lock()

七、排障与可观测性

7.1 检测并发问题

# 查看 BPF 程序的全局运行计数(run_cnt 为累计值,非 per-CPU)
bpftool prog show id <PROG_ID>
# 输出包含 run_cnt(全局)和 run_time_ns(全局)

# 使用 bpftrace 检测竞争(近似)
bpftrace -e '
kprobe:bpf_spin_lock { @lock_acq[tid] = count(); }
kprobe:bpf_spin_unlock { @lock_rel[tid] = count(); }
'

# 监控 map 的更新速率
bpftool map dump id <MAP_ID> | head -50

7.2 常见问题

计数丢失(无保护共享更新): 如果对一个普通 hash map value 做 *counter += 1 而没有原子或锁保护,在高并发下必然丢失。解决:使用 __sync_fetch_and_add() 或 per-CPU map。

verifier 拒绝(持锁调 helper): 不能在 bpf_spin_lock()bpf_spin_unlock() 之间调用任何 helper。解决:将 helper 调用移到锁外,或重新设计数据流。

性能不佳(cache line ping-pong): 全 CPU 原子递增同一个全局计数器。解决:用 per-CPU 替代全局原子。

八、总结

BPF 程序的并发模型是三种原语的组合:

  1. BPF_ATOMIC 指令——适合单变量原子操作,有完整内存屏障,但高并发下有 cache bounce
  2. bpf_spin_lock——适合多字段原子更新,但持锁期间不能调 helper,且不能与内核原生锁混用
  3. per-CPU map——零开销读写,但需要用户态聚合,不能跨 CPU 即时共享

这三种原语覆盖了 BPF 程序的绝大多数并发控制需求。理解它们的内核实现和 verifier 约束,才能在写 BPF 程序时避免并发错误——而不是上线后才在监控中发现计数系统性偏低。

本系列中,与本文最直接相关的是 第 15 篇(蹦床与 fentry/fexit)——蹦床的执行上下文直接影响并发语义。与 第 17 篇(eBPF 安全模型) 交叉——Spectre 缓解和 bpf_jit_harden 影响原子指令的 JIT 翻译。

参考资料

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .