你写了一个 BPF 程序跟踪 tcp 重传:遍历
struct tcp_sock 的
retransmit_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_ARRAY 和
BPF_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 模式的适用性判断:
- 适合:统计计数(可聚合)、临时缓冲、per-CPU 状态机
- 不适合:全局唯一标识符、共享数据结构、需要跨 CPU 即时可见的状态
六、并发模型的选择决策
6.1 决策表
| 场景 | 推荐方案 | 性能量级 | 主要限制 |
|---|---|---|---|
| 简单计数器 | per-CPU | 零竞争开销 | 需要用户态聚合 |
| 简单计数器(需精确单一值) | BPF_ATOMIC_ADD |
低(高并发时有 cache bounce) | 单变量原子操作 |
| 多字段原子更新 | bpf_spin_lock |
中等(无竞争时) | 不能调 helper |
| 只读数据 | 直接读取 | 0 额外开销 | 需 RCU 保护 |
| 哈希表共享状态 | bpf_spin_lock |
中等 | 每次锁操作独立 |
| 统计直方图 | per-CPU array | 零竞争开销 | 用户态聚合 |
6.2 实际的复杂度
组合使用这些原语时,复杂度是非线性的:
- 嵌套:per-CPU 数据可以在
bpf_spin_lock外部安全访问(实际上不需要锁) - 混合:per-CPU 数据 + 全局原子标志可以实现读多写少的全局状态
- 陷阱:在同一 map value 中混合 per-CPU 和共享数据—per-CPU 部分不需要锁,但共享部分仍然需要
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 -507.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 程序的并发模型是三种原语的组合:
BPF_ATOMIC指令——适合单变量原子操作,有完整内存屏障,但高并发下有 cache bouncebpf_spin_lock——适合多字段原子更新,但持锁期间不能调 helper,且不能与内核原生锁混用- per-CPU map——零开销读写,但需要用户态聚合,不能跨 CPU 即时共享
这三种原语覆盖了 BPF 程序的绝大多数并发控制需求。理解它们的内核实现和 verifier 约束,才能在写 BPF 程序时避免并发错误——而不是上线后才在监控中发现计数系统性偏低。
本系列中,与本文最直接相关的是 第
15 篇(蹦床与
fentry/fexit)——蹦床的执行上下文直接影响并发语义。与 第 17
篇(eBPF 安全模型) 交叉——Spectre 缓解和
bpf_jit_harden 影响原子指令的 JIT 翻译。
参考资料
- Linux 内核源码
kernel/bpf/helpers.c:bpf_spin_lock/bpf_spin_unlock实现 - Linux 内核源码
kernel/bpf/verifier.c:原子操作和 spinlock 的 verifier 检查 - Linux 内核源码
arch/x86/net/bpf_jit_comp.c:BPF_ATOMIC 的 x86 JIT 翻译 - Linux 内核源码
arch/arm64/net/bpf_jit_comp.c:BPF_ATOMIC 的 ARM64 JIT 翻译 - Linux 内核源码
kernel/bpf/hashtab.c:RCU 保护的 hash map 查找 - Linux 内核源码
kernel/bpf/arraymap.c:per-CPU array map 的实现 - IETF BPF ISA 草案:原子指令规范(
BPF_ATOMIC语义)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【eBPF 内核实现深度拆解】Map 内核实现(上):hash / array / per-CPU 的数据结构与并发模型
从 bpf_map_ops 虚函数表出发,逐层拆解 BPF_MAP_TYPE_HASH、BPF_MAP_TYPE_ARRAY、per-CPU 变体的内核实现——htab 的 bucket 链表与 prealloc、bpf_array 的零拷贝共享、per-CPU 分配器的无锁语义。
【eBPF 内核实现深度拆解】BPF 指令集解码:寄存器机器、调用约定与指令编码
从 eBPF 虚拟机的 11 个 64-bit 寄存器和 struct bpf_insn 出发,逐条拆解 ALU64/ALU32、跳转、加载存储、call 四类指令的字段语义与编码格式,建立后续 verifier 和 JIT 讨论的精确基础。
【eBPF 内核实现深度拆解】验证器框架:从 BPF_PROG_LOAD 到 do_check()
跟踪 BPF_PROG_LOAD 系统调用的内核执行路径,逐层拆解 bpf_prog_load()→bpf_check()→do_check_main() 的调用链,建立 verifier 执行全景——这是理解 verifier 安全保证的入口。
【eBPF 内核实现深度拆解】JIT 编译器后端:x86-64 与 ARM64 的 BPF→Native 翻译管线
从 bpf_jit_compile() 入口出发,拆解 BPF 字节码到 x86-64/ARM64 本地指令的翻译过程——寄存器映射策略、ALU 指令的 one-to-one/many-to-one 翻译、尾调用与 call 的本地实现、JIT 镜像的 kallsyms 集成,以及 JIT 与 interpreter 的性能边界。