Concurrency management in BPF

eBPF 对并发编程的支持是非常弱的

有一些讨论:

  1. https://lists.iovisor.org/g/iovisor-dev/topic/bpf_concurrency/74407447
  2. https://lwn.net/Articles/779120/

但是你甚至在 eBPF 的官方文档 https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md 里面几乎找不到任何关于并发的描述,除了 map.atomic_increment() 接口

但是这个借口也很奇怪。为了突出这个接口是原子的,线程安全的,难道其他接口就不是了吗?但好像又不是,因为 eBPF 里有大量的示例,并没有对 map 做特殊的并发控制,所以理论上可以认为,其他大部分 map 操作接口,也是线程安全的

线程安全验证

测试代码如下:

// calculate latency
int trace_run(struct pt_regs *ctx, struct task_struct *prev)
{
    u32 pid = bpf_get_current_pid_tgid();
    u64 *tsp;
    if (pid <= 1) {
        return 0;
    }
    
    pid = 10000; 
    u64 ts = bpf_ktime_get_ns();
    start.update(&pid, &ts);
   
#define __OP__ == 
    tsp = start.lookup(&pid); if (tsp __OP__ 0) { dist.increment(bpf_log2l(1)); }
    tsp = start.lookup(&pid); if (tsp __OP__ 0) { dist.increment(bpf_log2l(1)); }
    /** 重复N次 **/
    tsp = start.lookup(&pid); if (tsp __OP__ 0) { dist.increment(bpf_log2l(1)); }
    tsp = start.lookup(&pid); if (tsp __OP__ 0) { dist.increment(bpf_log2l(1)); }
    
    start.delete(&pid);
    return 0;
}

这段代码,修改自 bbc 工具集自带的 runqlat.py

这段代码实际上是在构造一个冲突场景,来验证 trace_run 到底是并行执行还是串行执行的。代码的逻辑是在一个超高频触发的钩子(调度器进行上下文切换)函数里,先在 map 里插入一个固定key(pid)的元素,然后查询 N 次,每次查询如果 key 不存在,计数器就 +1,最后把 key 从 map 里面删除掉,如果:

  1. trance_run 是串行的,那么技术器永远都是 0,因为在一个串行执行的逻辑里,插入元素后,不管查询多少次,元素必然是存在的
  2. 如果 trace_run 是并行的,那么就存在一个条件,一定有一个 cpu,在另外一个 cpu 查询 key 是否存在的时候,执行到了函数结束的地方,把 key 从 map 里面删掉了

事实证明,trace_run 是并行执行的,并且中间 lookup 的次数越多,冲突的概率就越大

想想肯定是这样的,内核不可能保证同一个函数的hook点全部串行执行,不然效率得多差啊

既然确认了这一点,写 eBPF 程序的时候,就得自己做好并发控制了

并发编程

1. 原子操作

bcc 目前并没有统一的原子指令解决方案,由于bcc本身的运行环境很特殊,runtime是内核的一个虚拟机,其运行机制本身就有诸多限制,所以原子操作的支持也是很不完备的,各种坑

从目前了解到的信息来看,bcc支持的原子操作主要2种:

  1. eBPF 原子指令,这是最底层的,eBPF 虚拟机提供的指令级别的原子操作
  2. llvm 内置的原子操作,比如 __sync_and_fetch_add
  3. atomic_increment() API:这是 bcc 官方提供的 helper_function

1.1. eBPF 原子指令

大概21年春节的时候,google给社区提了个patch,开始正是为eBPF指令集引入原子操作

详细可以看这里:

https://lwn.net/Articles/842354/

支持的指令集包括:

  • atomic[64][fetch]add
  • atomic[64][fetch]and
  • atomic[64][fetch]or
  • atomic[64]_xchg
  • atomic[64]_cmpxchg

但只支持64位的,不支持16位和32位,也不支持 Explicit memory barriers

目前来看,由于引入时间还没多久,还没看到 bcc 有相应的支持

1.2. llvm 内置的原子操作

由于bcc自带了一个 llvm 前端,因此可以使用一些 llvm 内置的原子操作函数。

注意:实际使用时最好自行确保函数语义是符合预期的,因为bcc编译有时候非常坑的,有些问题编译不出错的,但是运行结果不是你想像中的那样

llvm 内置的原子操作有两类:

  1. __sync_and_fetch_and_add / _sync_**_sub
  2. lock_xadd 系列

bcc 在 v0.21.0 版本引入 atomic_increment() 接口,其底层实现以来的就是 lock_xadd

https://github.com/iovisor/bcc/releases/tag/v0.21.0

https://github.com/iovisor/bcc/blob/63618552f81a2631990eff59fd7460802c58c30b/src/cc/frontends/clang/b_frontend_action.cc

另外 __sync_and_fetch_and_add 目前是有一个已知的llvm的bug

https://github.com/iovisor/bcc/issues/2654

在 clang 7 版本里面,__sync_and_fetch_and_add 函数理论上应该返回 add 之前的原值,但是实际在 ebpf 代码里面返回里被 add 的 delta 值,比如你每次加1,那这个函数就返回1,没有起到相应的作用

具体可以看看上面的 issue,有讨论,不知道新版本修复了没

不过据我实测,在机器上用 clang-7 编译器直接编译,是能返回正确的结果的,但是 ebpf 里面编译就不行,原因不明。如果有升级 clang 编译器可以解决这个问题的话,可以告知,谢谢,我懒得折腾了

2. 线程安全

由于上面我们已经实证,ebpf 代码是并行运行的,因此,并发情况下,如何保证 table 等数据结构的线程安全,就是个很重要的问题

对于bcc内置的table类数据结构,bcc 自身保证其线程安全,对于用户自定义的数据结构,bcc提供 bpf_spin_lock,具体可以参考

https://lwn.net/Articles/779120

2.1. bcc 内置函数的线程安全实现

xx

2.2. bpf_spin_lock 原理和使用

xx

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注