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

【eBPF 内核实现深度拆解】Map 内核实现(上):hash / array / per-CPU 的数据结构与并发模型

文章导航

分类入口
kernelebpf
标签入口
#ebpf#bpf-maps#hash-map#array-map#per-cpu#rcu#spinlock#linux-kernel

目录

BPF 程序在内核中执行时,不能访问全局变量,不能调用任意内核函数,也不能使用 malloc()。BPF 程序唯一能持久化状态、与用户态交换数据的机制就是 BPF map。这不是一个简单的 key-value store 包装——每种 map 类型背后是一套独立的数据结构和并发策略,选错类型带来的性能退化可能是数量级的。

本文是第一篇拆解 BPF map 内核实现的文章。我们从 struct bpf_mapbpf_map_ops 虚函数表出发,逐层剖析 BPF_MAP_TYPE_HASH(hash 表)、BPF_MAP_TYPE_ARRAY(数组)以及它们的 per-CPU 变体的内核实现、并发模型和适用场景。

源码基准:Linux 6.8,路径 kernel/bpf/hashtab.ckernel/bpf/arraymap.cinclude/linux/bpf.h

一、问题的起点:一个可编程子系统的存储需求

考虑一个最简单的需求:写一个 BPF 程序统计每个 CPU 上的网络包数量。你需要:

  1. 一块内存来存计数器(BPF 不能 malloc
  2. 多核并发写这 64 个计数器时不加锁(同一 CPU 同时只有一个 BPF 程序在跑,但不同 CPU 会并发写同一块内存)
  3. 用户态程序能读到所有 CPU 的汇总值

通用 key-value store 做不到第三点,锁保护的全局数组做不到第二点。BPF map 的 per-CPU array 用 this_cpu_ptr() 解决了这个问题——每个 CPU 一份私有拷贝、无锁读写、用户态汇总。这不是一个”BPF 版 hashmap”的故事,这是”内核为 BPF 的执行约束专门设计了存储子系统”的故事。

在深入具体实现之前,先建立 BPF map 子系统的骨架。

二、BPF Map 子系统的骨架:bpf_map 与 bpf_map_ops

2.1 struct bpf_map:一张 map 的内核表示

所有 map 类型共享同一个头结构 struct bpf_map,定义在 include/linux/bpf.h

/* include/linux/bpf.h — Linux 6.8 */
struct bpf_map {
    /* map 元数据 */
    const struct bpf_map_ops *ops;       /* 虚函数表:每种类型的操作实现 */
    struct bpf_map *inner_map_meta;      /* map-in-map 的内层 map 模板 */
    void *record;                        /* struct_ops 使用 */

    /* 用户设置的属性 */
    u32 map_type;                        /* BPF_MAP_TYPE_HASH / ARRAY / ... */
    u32 key_size;
    u32 value_size;
    u32 max_entries;
    u64 map_extra;                       /* 类型相关的额外标志 */
    u32 map_flags;                       /* BPF_F_NO_PREALLOC / BPF_F_RDONLY_PROG 等 */
    u32 id;                              /* 内核分配的唯一 ID */
    int numa_node;
    u32 btf_key_type_id;
    u32 btf_value_type_id;

    /* 内存统计 */
    u32 pages;                           /* 占用的页面数 */
    bool bypass_spec_v1;                 /* Spectre V1 缓解 */
    bool frozen;                         /* 冻结(禁止修改) */

    /* 引用计数与生命周期 */
    atomic64_t refcnt;                   /* 用户态 FD 引用 */
    atomic64_t usercnt;                  /* 用户态持有计数 */
    struct work_struct work;             /* RCU 延迟释放 */
    struct mutex freeze_mutex;
    atomic64_t writecnt;                 /* 写入者计数(map-in-map 使用) */

    /* 所有者信息 */
    char name[BPF_OBJ_NAME_LEN];
    struct bpf_map_off_arr *off_arr;
    ...
};

关键字段解读:

2.2 struct bpf_map_ops:虚函数表

每种 map 类型通过 bpf_map_ops 提供自己的操作实现。定义在 include/linux/bpf.h

/* include/linux/bpf.h — Linux 6.8 */
struct bpf_map_ops {
    /* 生命周期 */
    int (*map_alloc_check)(union bpf_attr *attr);
    struct bpf_map *(*map_alloc)(union bpf_attr *attr);
    void (*map_free)(struct bpf_map *map);
    int (*map_extra_elem_count)(const struct bpf_map *map);

    /* 核心 CRUD */
    void *(*map_lookup_elem)(struct bpf_map *map, void *key);
    long (*map_update_elem)(struct bpf_map *map, void *key,
                            void *value, u64 flags);
    long (*map_delete_elem)(struct bpf_map *map, void *key);

    /* 遍历 */
    int (*map_get_next_key)(struct bpf_map *map, void *key, void *next_key);

    /* 内存映射(array map 的关键操作) */
    int (*map_mmap)(struct bpf_map *map, struct vm_area_struct *vma);

    /* syscall 级别操作 */
    int (*map_lookup_elem_sys_only)(struct bpf_map *map, void *key, void *value);
    int (*map_update_elem_sys_only)(struct bpf_map *map, void *key,
                                     void *value, u64 flags);

    /* map-in-map 支持 */
    void *(*map_fd_sys_lookup_elem)(void *ptr);
    void *(*map_fd_sys_lookup_elem_sys_only)(void *ptr);

    /* 批量操作 */
    int (*map_lookup_batch)(struct bpf_map *map, u64 *total_count,
                            u64 *batch, void *keys, void *values,
                            u32 *count, void *elem_flags,
                            bool is_lookup_batch, bool has_uidx_opt);
    int (*map_update_batch)(struct bpf_map *map, u64 *total_count,
                            u64 *batch, void *keys, void *values,
                            u32 *count, void *elem_flags,
                            bool is_lookup_batch, bool has_uidx_opt);
    int (*map_delete_batch)(struct bpf_map *map, u64 *total_count,
                            u64 *batch, void *keys, void *values,
                            u32 *count, void *elem_flags,
                            bool is_lookup_batch, bool has_uidx_opt);

    /* BPF 程序端操作(不同于 syscall 端) */
    void *(*map_lookup_percpu_elem)(struct bpf_map *map, void *key, u32 cpu);

    /* 更多特殊操作 */
    int (*map_set_for_each_callback_args)(struct bpf_verifier_env *env,
                                          struct bpf_func_state *caller,
                                          struct bpf_func_state *callee);
    ...
};

这个虚函数表的规模说明了一件事:BPF map 不是一个简单的”内核版 unordered_map”。不同 map 类型在这些操作上的实现差异巨大——array map 的 map_lookup_elem 是 O(1) 数组索引,而 hash map 的是 O(1) 哈希查找。array map 不支持 map_delete_elem(返回错误),而 hash map 支持。

理解了这个骨架之后,我们进入第一个具体实现:hash map。

三、BPF_MAP_TYPE_HASH:htab 的分桶链表与预分配策略

Hash map(BPF_MAP_TYPE_HASH)是使用最广泛的 BPF map 类型。内核实现位于 kernel/bpf/hashtab.c。核心数据结构是 struct bpf_htab

/* kernel/bpf/hashtab.c — Linux 6.8 */
struct bpf_htab {
    struct bpf_map map;                  /* 嵌入基类 */
    struct bpf_mem_alloc ma;             /* 元素内存分配器 */

    struct bucket *buckets;              /* bucket 数组 */
    void *elems;                         /* prealloc 模式下的元素存储 */
    union {
        struct pcpu_htab_value __percpu *pprev;  /* per-CPU 变体使用 */
        ...
    };
    atomic_t count;                      /* 当前元素数 */
    u32 n_buckets;                       /* bucket 数量(2 的幂) */
    u32 elem_size;                       /* htab_elem + key + value 的总大小 */
    ...
};

3.1 Bucket 与链表:拉链法(Separate Chaining)

struct bucket 本身就是单链表头节点:

/* kernel/bpf/hashtab.c — Linux 6.8 */
struct bucket {
    struct hlist_raw_head head;          /* 实际是 hlist_nulls_head */
    raw_spinlock_t raw_lock;             /* 每 bucket 一把自旋锁 */
};

struct htab_elem {
    union {
        struct hlist_nulls_node hash_node;  /* 链表节点(查找时用) */
        struct {
            void *padding;
            union {
                struct pcpu_htab_value __percpu *pprev;  /* per-CPU 反向指针 */
                ...
            };
        };
    };
    union {
        struct rcu_head rcu;             /* RCU 延迟释放 */
        enum {
            HTAB_ELEM_EXTRA_ENT,
            HTAB_ELEM_PERCPU_ENT,
        } mt;
        struct bpf_mem_alloc *ma;
        struct {
            struct htab_elem *next;      /* 释放链表 */
            void *ptr;
        } ll;
    };
    u32 hash;                            /* 完整哈希值(加速键比较) */
    u32 key_size;
    /* 紧接 key + value 数据 */
    char key_and_value[] __aligned(8);   /* 柔性数组成员 */
};

哈希映射流程:

  1. 根据 key 计算 hash:内核使用 jhash()jhash2() 生成 32-bit 哈希值。
  2. hash & (n_buckets - 1) 确定 bucket 索引(n_buckets 是 2 的幂,位运算替代取模)。
  3. buckets[index]hlist_nulls_head 链表上遍历,比对 htab_elem->hash 和 key 内容。

下面是 hash bucket 链表的布局示意:

flowchart LR
    subgraph buckets["buckets 数组"]
        b0["bucket[0]\nhead: * → elem01\nlock"]
        b1["bucket[1]\nhead: *\nlock"]
        b2["bucket[2]\nhead: * → elem21\nlock"]
    end

    subgraph elems["htab_elem 链表节点"]
        e01["elem01\nhash: 0x...3f\nkey...\nvalue...\nnext → elem02"]
        e02["elem02\nhash: 0x...1a\nkey...\nvalue...\nnext → NULL"]
        e21["elem21\nhash: 0x...88\nkey...\nvalue...\nnext → NULL"]
    end

    b0 --> e01
    e01 --> e02
    b2 --> e21

每个 bucket 有自己独立的 raw_spinlock_t。这意味着不同 bucket 上的操作可以并行——hash map 的并发度等于 bucket 数量。

3.2 预分配(Preallocation)策略

Hash map 的 map_alloc 路径(htab_map_alloc())有一个关键决策:是否预分配元素

/* kernel/bpf/hashtab.c — htab_map_alloc() 简化逻辑 */
static struct bpf_map *htab_map_alloc(union bpf_attr *attr)
{
    struct bpf_htab *htab;
    ...

    /*
     * prealloc 判断逻辑:
     * - 如果 attr->map_flags 设置了 BPF_F_NO_PREALLOC,则不预分配
     * - 如果元素数量 * 元素大小 <= 一页(PAGE_SIZE),则预分配
     * - 如果运行在非抢占内核(!CONFIG_PREEMPT),则不预分配
     *   (因为此时 GFP_ATOMIC 实际上等同于 GFP_KERNEL 所以不会失败)
     */
    bool prealloc = !(attr->map_flags & BPF_F_NO_PREALLOC);

    if (prealloc) {
        /* 在 map 创建时一次性分配所有元素 */
        htab->elems = bpf_map_area_alloc(
            htab->elem_size * attr->max_entries,
            htab->map.numa_node);
        /* 初始化每个 elem 并将其加入 freelist */
        prealloc_init(htab);
    } else {
        /* 使用 bpf_mem_alloc 按需分配 */
        err = bpf_mem_alloc_init(&htab->ma, htab->elem_size, false);
    }
    ...
}

两种模式的核心差异:

维度 prealloc no-prealloc (BPF_F_NO_PREALLOC)
内存时机 map 创建时一次分配 update_elem 时按需分配
分配上下文 进程上下文(GFP_KERNEL BPF 运行上下文(GFP_ATOMICGFP_NOWAIT
确定性 写入延迟完全确定 写入可能因内存碎片而失败
内存占用 max_entries * elem_size(满额) 按实际使用量增长
写入失败 只在 freelist 为空时(map 满) 可能因 GFP_ATOMIC 分配失败(即使 map 未满)
适用场景 对延迟敏感的 XDP/TC 路径 稀疏使用、大 value、内存紧张的场景

关键工程决策:prealloc 模式用更多内存换了确定性延迟。在 XDP 数据面上,GFP_ATOMIC 失败可能意味着丢包,因此生产部署几乎总是使用 prealloc。

Prealloc 模式的 freelist 在 prealloc_init() 中构建:

/* kernel/bpf/hashtab.c — prealloc_init() 简化 */
static void prealloc_init(struct bpf_htab *htab)
{
    u32 num_entries = htab->map.max_entries;
    int i;

    for (i = 0; i < num_entries; i++) {
        /* 从连续内存块获取第 i 个元素 */
        htab_elem = get_htab_elem(htab, i);

        /* 初始化 key_size(后续用于哈希值比对时的字段大小) */
        htab_elem->key_size = htab->map.key_size;
    }
}

注意:prealloc 的 freelist 是隐式的——通过 bpf_mem_cache_alloc()htab->ma 分配,元素不在任何链表中时就是”空闲”状态。实际的空闲管理由 bpf_mem_alloc 的内存池机制负责,不是链表维护的。

3.3 写入路径:htab_map_update_elem

/* kernel/bpf/hashtab.c — htab_map_update_elem() 简化 */
static long htab_map_update_elem(struct bpf_map *map, void *key,
                                 void *value, u64 map_flags)
{
    struct bpf_htab *htab = container_of(map, struct bpf_htab, map);
    struct htab_elem *l_new, *l_old;
    struct bucket *b;
    u32 key_size, hash;
    int ret;

    /* 1. 计算 hash 并定位 bucket */
    hash = htab_map_hash(key, key_size, htab->hashrnd);
    b = __select_bucket(htab, hash);

    /* 2. 获取 bucket 自旋锁 */
    raw_spin_lock_irqsave(&b->raw_lock, flags);

    /* 3. 查找是否已存在该 key */
    l_old = lookup_elem_raw(&b->head, hash, key, key_size);

    /* 4. 分配新元素(prealloc 模式从 freelist 取;no-prealloc 调用 kmalloc) */
    l_new = alloc_htab_elem(htab, key, value, key_size, false, l_old);

    /* 5. 原子替换:旧 → 新 */
    if (l_old) {
        hlist_nulls_replace_rcu(&l_old->hash_node, &l_new->hash_node);
        /* 释放旧元素 */
        free_htab_elem(htab, l_old);
    } else {
        hlist_nulls_add_head_rcu(&l_new->hash_node, &b->head);
    }

    raw_spin_unlock_irqrestore(&b->raw_lock, flags);
    return 0;
}

关键细节: - Per-bucket 锁raw_spin_lock_irqsave(&b->raw_lock, flags) 只锁当前 bucket。不同 key hash 到不同 bucket 时可以完全并行。 - RCU 链表操作:使用 hlist_nulls_replace_rcu / hlist_nulls_add_head_rcu,保证读侧(lookup)在无锁情况下不会走到野指针。 - 原子替换语义:对于已存在的 key,替换是原子的——新元素入链、旧元素出链,RCU grace period 后释放旧元素。

3.4 无锁读路径:htab_map_lookup_elem

/* kernel/bpf/hashtab.c — htab_map_lookup_elem() 简化 */
static void *htab_map_lookup_elem(struct bpf_map *map, void *key)
{
    struct bpf_htab *htab = container_of(map, struct bpf_htab, map);
    struct hlist_nulls_head *head;
    struct htab_elem *l;
    u32 hash, key_size;

    /* 注意:BPF 程序执行时 preempt_disable() 已经生效,
     * 因此这里不需要显式的 RCU 读锁。
     */
    hash = htab_map_hash(key, key_size, htab->hashrnd);
    head = &__select_bucket(htab, hash)->head;

    /* 遍历链表,比对 hash 和 key */
    l = lookup_elem_raw(head, hash, key, key_size);
    if (l)
        return l->key_and_value + round_up(key_size, 8);  /* 返回 value 指针 */
    return NULL;
}

关键设计:lookup 不加锁。它依赖 RCU 保证在遍历链表时不会遇到已释放的节点——因为写入者在替换元素后通过 call_rcu() 延迟释放旧元素,等待所有 CPU 完成一个 grace period(此时没有 CPU 持有对该元素的引用)后才释放。

在 BPF 程序上下文中,preempt_disable() 已经在 BPF 执行入口生效,这隐含了 RCU 读锁的保护。

对比常规内核哈希表(如 linux/hashtable.h)和 BPF hash map:

特性 内核 hash_table BPF BPF_MAP_TYPE_HASH
并发读 RCU 保护 RCU 保护(自动)
并发写 调用者负责加锁 per-bucket 自旋锁
内存分配 调用者管理 prealloc 或 bpf_mem_alloc
遍历 hash_for_each_possible() map_get_next_key() 顺序扫描所有 bucket
预分配 可选的 prealloc
最大元素限制 无强制 max_entries

3.5 删除操作

/* kernel/bpf/hashtab.c — htab_map_delete_elem() 简化 */
static long htab_map_delete_elem(struct bpf_map *map, void *key)
{
    struct bpf_htab *htab = container_of(map, struct bpf_htab, map);
    struct bucket *b = __select_bucket(htab, hash);
    struct htab_elem *l;

    raw_spin_lock_irqsave(&b->raw_lock, flags);
    l = lookup_elem_raw(&b->head, hash, key, key_size);
    if (l) {
        hlist_nulls_del_rcu(&l->hash_node);
        free_htab_elem(htab, l);
    }
    raw_spin_unlock_irqrestore(&b->raw_lock, flags);
    return 0;
}

删除操作和更新操作共享同一个 bucket 锁,保证对同一个 bucket 的写操作序列化。

3.6 遍历:map_get_next_key

BPF map 的遍历不是通过迭代器,而是通过反复调用 map_get_next_key()。这要求 hash map 记住”当前位置”(通过 key 参数传入的当前 key),然后找到下一个。

/* kernel/bpf/hashtab.c — htab_map_get_next_key() 简化逻辑 */
/*
 * 1. 如果 key == NULL,返回第一个非空 bucket 的第一个元素
 * 2. 否则:
 *    a. 从当前 key 所在 bucket 找到当前元素的下一个
 *    b. 如果当前 bucket 没有更多元素,扫描后续 bucket
 *    c. 返回第一个找到的元素的 key
 */

遍历在无锁(RCU 读)下进行,因此在遍历过程中元素可能被并发删除——这是”弱一致性”遍历,调用者必须接受这个语义。

四、BPF_MAP_TYPE_ARRAY:一维连续内存与零拷贝共享

Array map(BPF_MAP_TYPE_ARRAY)是第二常用的 BPF map 类型。和 hash map 不同,array map 使用 index 而非任意 key 寻址。内核实现位于 kernel/bpf/arraymap.c

/* kernel/bpf/arraymap.c — Linux 6.8 */
struct bpf_array {
    struct bpf_map map;
    u32 elem_size;                       /* value_size */
    u32 index_mask;                      /* max_entries - 1(用于位运算代替取模) */
    struct bpf_array_aux *aux;
    /* value 数组紧随此结构体 */
    char value[] __aligned(8);           /* max_entries * elem_size */
};

4.1 内存布局:一个扁平的连续数组

Array map 在创建时分配一个连续内存块:

/* kernel/bpf/arraymap.c — array_map_alloc() 简化 */
static struct bpf_map *array_map_alloc(union bpf_attr *attr)
{
    u32 elem_size, array_size;
    struct bpf_array *array;

    elem_size = round_up(attr->value_size, 8);
    array_size = sizeof(*array) + attr->max_entries * elem_size;

    /* 分配连续内存:结构体 + 所有 value */
    array = bpf_map_area_alloc(array_size, numa_node);
    ...
    array->elem_size = elem_size;
    array->index_mask = attr->max_entries - 1;

    return &array->map;
}

sizeof(struct bpf_array) + max_entries * elem_size ——所有 value 紧接在结构体之后。因此:

4.2 零拷贝查找

/* kernel/bpf/arraymap.c — array_map_lookup_elem() */
static void *array_map_lookup_elem(struct bpf_map *map, void *key)
{
    struct bpf_array *array = container_of(map, struct bpf_array, map);
    u32 index = *(u32 *)key;

    if (unlikely(index >= array->map.max_entries))
        return NULL;

    return array->value + (u64)index * array->elem_size;
}

没有锁、没有 RCU dereference、没有哈希计算——只有一次边界检查和一个指针偏移。在 BPF 程序上下文中,这通常编译为 2-3 条本地指令。

零拷贝的完整含义:BPF 程序得到的指针直接指向内核中 array map 的内存。用户态通过 BPF_MAP_LOOKUP_ELEM syscall 读同一块内存时,内核做一次 copy_to_user()。但用户态还可以通过 mmap() 将 array map 映射到用户空间,实现真正的零拷贝读写:

/* kernel/bpf/arraymap.c — array_map_mmap() */
static int array_map_mmap(struct bpf_map *map, struct vm_area_struct *vma)
{
    struct bpf_array *array = container_of(map, struct bpf_array, map);

    return remap_vmalloc_range(vma, array, vma->vm_pgoff);
}

此时用户态进程可以直接读写 array map 的内存,和 BPF 程序看到的是同一块物理内存。这是 array map 独有的能力——hash map 不支持 mmap,因为元素分散在链表中。

4.3 固定大小与无删除的语义

Array map 的关键约束:

  1. 不能扩展max_entries 在创建时固定,超出索引的访问返回 NULL
  2. 不能删除map_delete_elem 对 array map 返回 -EINVAL。要”清空”一个元素,只能 update 为零值。
  3. 索引必须从 0 开始:key 是 u32 索引,范围 [0, max_entries)

4.4 写入路径

/* kernel/bpf/arraymap.c — array_map_update_elem() 简化 */
static long array_map_update_elem(struct bpf_map *map, void *key,
                                  void *value, u64 map_flags)
{
    struct bpf_array *array = container_of(map, struct bpf_array, map);
    u32 index = *(u32 *)key;

    if (unlikely(index >= array->map.max_entries))
        return -E2BIG;

    /* 使用 WRITE_ONCE 保证单次写入的原子性(对对齐的 32/64-bit 值) */
    if (array->elem_size == 8)
        *(u64 *)(array->value + (u64)index * array->elem_size) = *(u64 *)value;
    else
        memcpy(array->value + (u64)index * array->elem_size, value,
               array->elem_size);
    return 0;
}

注意 array map 的 update 没有加锁。为什么?

这种设计意味着:array map 对读者零开销,对写者极低开销。在 XDP 程序中,array map 通常用作预计算的路由表——启动时写一次,数据面永远只读。

4.5 Array 与 Hash 的选择对比

维度 BPF_MAP_TYPE_ARRAY BPF_MAP_TYPE_HASH
寻址方式 u32 索引 任意 key(key_size 可配)
查找复杂度 O(1),无哈希计算 O(1) 平均,需要哈希 + 链表遍历
读并发 无锁(连续内存) RCU 保护(间接访问)
写并发 无锁(memcpy,非原子) per-bucket 自旋锁
mmap 支持(零拷贝) 不支持
删除 不支持(只能置零) 支持
动态大小 固定 固定(但元素数 <= max_entries)
内存布局 连续内存 分散(每个 htab_elem 独立分配)
遍历 O(n),简单递增索引 O(n_buckets + n_elems),扫描所有 bucket
最佳场景 预计算表、固定索引配置、频繁读 连接跟踪、动态 key、频繁增删

经验法则:如果 key 是固定范围的整数(如 CPU 编号、端口号、协议类型),用 array。如果 key 是 IP 地址、进程 PID、连接五元组等稀疏空间,用 hash。

五、Per-CPU 变体:this_cpu_ptr() 的无锁语义

BPF_MAP_TYPE_PERCPU_HASHBPF_MAP_TYPE_PERCPU_ARRAY 是 hash / array 的 per-CPU 变体。它们的核心思路是:每个 CPU 有一份独立的 value 拷贝

5.1 数据布局

Per-CPU hash map 在 struct bpf_htab 中增加了 per-CPU 数据:

/* kernel/bpf/hashtab.c — per-CPU 相关字段 */
struct bpf_htab {
    ...
    struct bucket *buckets;
    void *elems;                         /* prealloc 模式 */

    union {
        /* per-CPU 变体:每个 htab_elem 的 value 实际分布在所有 CPU 上 */
        struct pcpu_htab_value __percpu *pprev;
        struct bpf_mem_alloc ma;         /* no-prealloc 模式 */
    };
    ...
};

struct pcpu_htab_value {
    u64 ____cacheline_aligned_in_smp value[];  /* cacheline 对齐,避免 false sharing */
};

对于 per-CPU array map(BPF_MAP_TYPE_PERCPU_ARRAY),结构体同样嵌入 per-CPU 标记:

/* kernel/bpf/arraymap.c — per-CPU array 的 value 布局 */
/*
 * array->value 指向一个连续内存块,但大小是
 * max_entries * elem_size * num_possible_cpus()
 *
 * 第 i 个元素、第 c 个 CPU 的偏移量:
 *   (i * num_possible_cpus() + c) * elem_size
 * 或者在某些实现中:
 *   i * (num_possible_cpus() * elem_size) + c * elem_size
 */

5.2 读路径:this_cpu_ptr()

在 BPF 程序上下文中,对 per-CPU map 的读取不需要任何锁:

/* kernel/bpf/hashtab.c — __htab_map_lookup_percpu_elem() 简化 */
/* BPF helper 在 CPU N 上执行时: */
static void *__htab_map_lookup_percpu_elem(struct bpf_map *map, void *key, u32 cpu)
{
    struct bpf_htab *htab = container_of(map, struct bpf_htab, map);
    struct htab_elem *l;

    /* 1. 找到 key 对应的 htab_elem(和普通 hash lookup 一样) */
    l = __htab_map_lookup_elem(map, key);  /* RCU 读保护 */
    if (!l)
        return NULL;

    /* 2. 从 per-CPU 数据中返回当前 CPU 的 value */
    return this_cpu_ptr(l->pprev);
    /* l->pprev 指向该 htab_elem 的 per-CPU value 数组 */
}

this_cpu_ptr() 是 per-CPU 变量的核心宏。在 x86-64 上,它编译为对 gs 段寄存器的偏移访问——不涉及任何原子操作、锁或总线锁定:

/* 伪汇编:this_cpu_ptr(ptr) 在 x86-64 上的效果 */
// mov %gs:ptr_offset, %rax   ; 从 gs 段读取当前 CPU 的基址
// add $value_offset, %rax    ; 加上 value 在结构中的偏移

这套机制保证: - CPU 0 上的 BPF 程序读写的是 CPU 0 的 value 拷贝 - CPU 1 上的 BPF 程序读写的是 CPU 1 的 value 拷贝 - 没有 cache line bouncing——每个 CPU 操作自己 cache line 上的数据

5.3 写路径

/* kernel/bpf/hashtab.c — __htab_percpu_map_update_elem() 简化 */
static long __htab_percpu_map_update_elem(struct bpf_map *map, void *key,
                                          void *value, u64 map_flags,
                                          bool onallcpus)
{
    struct bpf_htab *htab = container_of(map, struct bpf_htab, map);
    struct htab_elem *l_new, *l_old;
    struct bucket *b;
    u32 key_size, hash;
    int ret;

    hash = htab_map_hash(key, key_size, htab->hashrnd);
    b = __select_bucket(htab, hash);

    raw_spin_lock_irqsave(&b->raw_lock, flags);

    l_old = lookup_elem_raw(&b->head, hash, key, key_size);

    /* 分配新元素(per-CPU value 的大小是 elem_size * num_possible_cpus()) */
    l_new = alloc_htab_elem(htab, key, value, key_size, false, l_old);

    if (l_old) {
        hlist_nulls_replace_rcu(&l_old->hash_node, &l_new->hash_node);
        free_htab_elem(htab, l_old);
    } else {
        hlist_nulls_add_head_rcu(&l_new->hash_node, &b->head);
    }

    raw_spin_unlock_irqrestore(&b->raw_lock, flags);
    return 0;
}

注意:per-CPU hash map 的更新仍然需要 bucket 锁——因为需要修改 hash 链表。但是 value 的填写是在分配时完成的,写入的值由调用方预先准备好所有 CPU 的拷贝。BPF helper 侧的 bpf_percpu_hash_update() 只写入当前 CPU 的 value 分片。

5.4 用户态汇总

用户态读取 per-CPU map 时,得到一个 num_possible_cpus() * value_size 字节的数据块。用户态代码负责汇总:

/* 用户态伪代码:汇总 per-CPU counter */
static u64 sum_percpu_counter(int map_fd, u32 key)
{
    int nr_cpus = libbpf_num_possible_cpus();
    u64 values[nr_cpus];
    u64 total = 0;

    bpf_map_lookup_elem(map_fd, &key, values);
    for (int i = 0; i < nr_cpus; i++)
        total += values[i];

    return total;
}

5.5 使用场景

Per-CPU counters(最经典场景)

/* BPF 程序:per-CPU 计数器 */
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, u64);
} pkt_count SEC(".maps");

SEC("xdp")
int count_packets(struct xdp_md *ctx)
{
    u32 key = 0;
    u64 *counter;

    counter = bpf_map_lookup_elem(&pkt_count, &key);
    if (counter)
        __sync_fetch_and_add(counter, 1);  /* 原子加,但只在本地 CPU 竞争 */

    return XDP_PASS;
}

注意:即使在 per-CPU map 上,使用 __sync_fetch_and_add 仍然是好习惯。同一 CPU 上如果有 NMI 或更高优先级的中断也执行同一个 BPF 程序(虽然概率极低),不加原子操作可能导致丢失计数。实际上,在没有中断嵌套的情况下,仅 (*counter)++ 即可,但 __sync_fetch_and_add 提供了防御性。

Per-CPU 临时缓冲区:BPF 程序的栈大小限制为 512 字节(MAX_BPF_STACK)。当需要更大的临时空间时(如字符串格式化、数据重组),per-CPU array 的 value 可以用作临时 buffer:

SEC("tracepoint/syscalls/sys_enter_execve")
int trace_exec(struct trace_event_raw_sys_enter *ctx)
{
    u32 key = 0;
    char *buf;

    buf = bpf_map_lookup_elem(&tmp_buf, &key);
    if (!buf)
        return 0;

    /* buf 是一块 per-CPU 的 512 字节缓冲区,无锁安全 */
    bpf_probe_read_user_str(buf, 256, ctx->args[0]);
    ...
    return 0;
}

5.6 Per-CPU 与普通 Map 的性能差异

Per-CPU 带来的性能提升来自消除 cache line bouncing。普通 map 的 value 在被多个 CPU 并发访问时,包含该 value 的 cache line 在 CPU 之间迁移,每次迁移需要数十纳秒。Per-CPU map 每个 CPU 操作自己 cache line 上的数据,不存在迁移。

场景 普通 Array Per-CPU Array 差异
单 CPU 递增计数器 低延迟 低延迟 相近
多 CPU 递增同一计数器 显著变慢(cache line bouncing) 保持低延迟 数量级差距
多 CPU 读同一值 略慢 低延迟 明显

多 CPU 争用同一计数器时,普通 array 因 cache line 在 CPU 间迁移而显著变慢;per-CPU 变体各写各的 cache line,避免 bouncing。具体数值因 CPU 架构与 workload 而异,但数量级差异在所有 x86 平台上一致。

六、并发模型总结

BPF map 的并发模型可以总结为一个表格:

Map 类型 读并发机制 写并发机制 删除支持 mmap
BPF_MAP_TYPE_HASH RCU(无锁读) per-bucket raw_spinlock 支持 不支持
BPF_MAP_TYPE_ARRAY 自然(连续内存) 无锁 memcpy(非原子) 不支持 支持
BPF_MAP_TYPE_PERCPU_HASH RCU + this_cpu_ptr() per-bucket 锁(结构)+ per-CPU 免锁(数据) 支持 不支持
BPF_MAP_TYPE_PERCPU_ARRAY this_cpu_ptr()(无锁) this_cpu_ptr()(无锁) 不支持 不支持

核心设计原则:

  1. 读路径尽量无锁:RCU(hash)或连续内存(array)保证读不受写干扰。
  2. 写路径精细化加锁:hash map 用 per-bucket 锁最大化并行度;array map 利用固定大小内存的天然优势避免加锁。
  3. Per-CPU 消除竞争:把热点数据的竞争从”所有 CPU 争一个缓存行”变成”各写各的”。

理解这些并发模型,是避免生产级 BPF 程序性能问题的前提。下一篇(第 07 篇)将拆解 ring buffer、bloom filter、LPM trie 和队列/栈等更复杂的 map 类型。

参考

内核源码

规范与文档

补充资料

上一篇JIT 编译器后端:x86-64 / ARM64 的 BPF→Native 翻译(第 05 篇)

下一篇Map 内核实现(下):ringbuf / bloom / queue-stack / LPM(第 07 篇)

同主题继续阅读

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

2026-06-12 · kernel / ebpf

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

BPF 程序在内核上下文中并发执行——同一程序可能在多个 CPU 同时运行。本文讲清 BPF 环境下的内存模型(BPF_ATOMIC 指令的语义)、bpf_spin_lock 的实现限制、RCU 保护的 map 读取、per-CPU map 的免锁读写,以及中断上下文与进程上下文的执行语义差异。

2026-06-12 · kernel / ebpf

【eBPF 内核实现深度拆解】从验证器到 JIT,从 BTF 到调度器

eBPF 内核虚拟机内部实现系统讲解:BPF 指令集与寄存器机器、验证器的抽象解释与状态裁剪、JIT 编译器后端、Map 各类型的并发与内存模型、helper 函数注册与类型检查、BTF 格式规范与 CO-RE 重定位引擎、libbpf 加载器工程、fentry/fexit 蹦床机制、sched_ext 调度器内核接口。面向想读懂 eBPF 内核源码、写生产级 BPF 程序的系统工程师。


By .