linux 内核开发之 per-cpu 变量


1. per-cpu 变量是什么?(What)

核心思想:per-cpu 变量不是一个单一的变量,而是一个为系统中每个 CPU 都创建一个独立副本的变量集合

想象一个场景:你在一个有8个收银台的超市。

  • 普通全局变量:所有8个收银员共用一个收银抽屉。每次有人要结账,收银员们就得排队,一个一个地使用这个抽屉。如果有两个收银员同时想用,就必须有一个人等待(这就是“锁”的概念),效率极低。
  • per-cpu 变量:每个收银台都配有自己独立的收银抽屉。8个收银员可以同时、互不干扰地操作自己的抽屉。这样,结账(数据操作)的过程就是并行的,效率大大提高。当老板想知道今天的总收入时,他只需要把8个抽屉里的钱加起来就行了。

在 Linux 内核中,这个“收银员”就是 CPU 核心,“收银抽屉”就是 per-cpu 变量的实例。当运行在 CPU 0 上的代码访问一个 per-cpu 变量时,它实际上访问的是专属于 CPU 0 的那个副本。当代码在 CPU 1 上访问同一个 per-cpu 变量时,它访问的是专属于 CPU 1 的副本。

关键优势
由于每个 CPU 操作的都是自己的数据副本,因此访问 per-cpu 变量几乎不需要任何锁机制,极大地减少了多核处理器之间的竞争(lock contention)和缓存伪共享(false sharing),从而显著提升了系统的性能和伸缩性。


2. 为什么需要及使用场景是什么?(Why & Use Cases)

在多核(SMP)架构下,对共享数据的访问是性能瓶颈的主要来源。当多个 CPU 核心频繁地读写同一个内存地址时,会发生:

  1. 锁竞争 (Lock Contention):为了保证数据一致性,必须使用自旋锁(spinlock)或原子操作等同步原语。CPU 越多,竞争越激烈,等待锁的时间越长,性能越差。
  2. 缓存行伪共享 (Cache Line False Sharing):即使多个变量在逻辑上不相关,但如果它们恰好位于同一个缓存行(Cache Line)中,一个 CPU 修改其中一个变量会导致整个缓存行失效,迫使其他 CPU 重新从主存加载数据,造成不必要的性能开销。

per-cpu 变量正是为了解决这些问题而生的。

典型的使用场景包括

  • 统计计数器:这是最经典的应用。例如,统计网络协议栈收到的包数量、某个系统调用的调用次数、磁盘 I/O 的次数等。每个 CPU 都在自己的副本上累加计数值,完全无锁。当需要获取总数时,再遍历所有 CPU 的副本求和。这比用一个带锁的全局计数器效率高得多。
  • 状态管理:例如,内核调度器需要为每个 CPU 维护一个运行队列(runqueue),这个队列就是 per-cpu 数据结构。每个 CPU 只操作自己的队列,无需与其他 CPU 争抢。
  • 线程/进程信息:Linux 内核中著名的 current 宏(指向当前正在运行的进程的 task_struct),其实现就高度依赖于 per-cpu 变量。每个 CPU 都有一个指针,指向在它上面运行的 task_struct
  • 缓存/缓冲区:为每个 CPU 提供一个本地的缓冲区或对象池。例如,slab 分配器中的 per-cpu slab 缓存,CPU 优先从自己的本地缓存分配/释放对象,只有当本地缓存不够时才去访问全局缓存,大大减少了全局锁的争用。
  • 随机数种子:每个 CPU 维护自己的随机数种子,以避免在生成随机数时产生竞争。

总结:任何主要由单个 CPU 独立更新,但需要全局汇总或查看的数据,都是 per-cpu 变量的绝佳候选。


3. 具体如何使用?(How)

per-cpu 变量分为静态创建动态创建两种方式。

3.1 静态创建 (Compile-time)

在编译时就知道类型和名称的变量,通常定义在全局范围。

A. 定义

使用 DEFINE_PER_CPU 宏来定义一个 per-cpu 变量。

#include <linux/percpu.h>

// 定义一个名为 my_counter 的 per-cpu 无符号长整型变量
DEFINE_PER_CPU(unsigned long, my_counter);

这个宏会在链接时将 my_counter 放入一个特殊的段 .data.percpu 中。内核加载时,会为每个 CPU 核心分配并初始化这个变量的副本。

B. 访问

访问 per-cpu 变量必须使用专门的 API,因为直接访问 my_counter 变量名得到的是一个无意义的地址。

最常用的访问方式是 get_cpu_var()put_cpu_var()

// 增加当前CPU上的 my_counter 计数
get_cpu_var(my_counter)++;
put_cpu_var(my_counter);

// 读取当前CPU上的值
unsigned long val;
val = get_cpu_var(my_counter);
put_cpu_var(my_counter);

⚠️ 极其重要的概念:抢占保护

get_cpu_var()put_cpu_var() 必须成对使用

  • get_cpu_var(var)
    1. 禁止内核抢占 (preempt_disable())。
    2. 返回当前 CPU 上的 var 变量副本的地址。
  • put_cpu_var(var)
    1. 允许内核抢占 (preempt_enable())。

为什么必须禁止抢占?
假设你的代码正在访问 CPU 0 的副本,代码执行到一半,如果此时发生抢占,你的进程被调度器迁移到 CPU 1 上继续执行。那么接下来的代码就会错误地访问 CPU 1 的副本,导致数据混乱。get/put 对通过关闭和开启抢占,确保了在这段代码的执行期间,进程不会被迁移到其他 CPU。

C. 访问其他 CPU 的副本

如果确实需要访问特定 CPU 的副本(不常见,但可能需要),可以使用 per_cpu 宏。

int cpu_id = 3;
unsigned long counter_on_cpu3;

// 读取 CPU 3 上的 my_counter 值
counter_on_cpu3 = per_cpu(my_counter, cpu_id);

// 写入 CPU 3 上的 my_counter 值
per_cpu(my_counter, cpu_id) = 100;

注意:跨 CPU 访问不提供任何同步保证,你需要自己处理并发问题(例如,确保目标 CPU 不会同时修改该值)。

3.2 动态创建 (Run-time)

当你在模块加载时,或者在运行时才需要 per-cpu 数据时,就需要动态创建。

A. 分配和释放

使用 alloc_percpu()__alloc_percpu() 分配,使用 free_percpu() 释放。

#include <linux/percpu.h>

struct my_data {
    int a;
    char *b;
};

// 声明一个 per-cpu 指针
struct my_data __percpu *p_my_data;

// 在模块初始化函数中分配
int my_module_init(void) {
    // 为每个CPU分配一个 struct my_data 实例
    p_my_data = alloc_percpu(struct my_data);
    if (!p_my_data) {
        return -ENOMEM; // 内存不足
    }
    return 0;
}

// 在模块退出函数中释放
void my_module_exit(void) {
    free_percpu(p_my_data);
}

alloc_percpu(type)__alloc_percpu(sizeof(type), __alignof__(type)) 的一个方便的封装。

B. 访问

动态分配的 per-cpu 变量通过指针进行访问。

同样,使用 get_cpu_ptr()put_cpu_ptr() 来安全地访问。

struct my_data *data_on_this_cpu;

// 获取当前CPU的 data 指针
data_on_this_cpu = get_cpu_ptr(p_my_data);

// 操作数据
data_on_this_cpu->a = 123;
data_on_this_cpu->b = "hello";

// 完成操作,允许抢占
put_cpu_ptr(data_on_this_cpu); // 注意:这里传递的是 p_my_data, 历史版本是这样的,现在是直接 put_cpu_ptr()
// 现代内核中,更常用的做法是 put_cpu()
// 例如:
// raw_smp_processor_id() 获得当前CPU ID
// this_cpu_ptr(p_my_data)
// get_cpu() / put_cpu()

更现代和简洁的访问方式是:

// 获取当前CPU的指针
struct my_data *p = this_cpu_ptr(p_my_data);
p->a = 456;
// ...
// 不需要 put_cpu_ptr,因为 this_cpu_ptr 假设上下文已经是抢占安全的。

this_cpu_* 系列函数要求调用者自行保证抢占安全,例如在中断处理程序、spinlock 保护的临界区内等。

3.3 遍历所有 CPU 副本

最常见的操作之一就是汇总所有 CPU 的统计数据。这通常通过 for_each_possible_cpufor_each_online_cpu 循环完成。

unsigned long total = 0;
int cpu;

// 遍历系统中所有可能的CPU
for_each_possible_cpu(cpu) {
    // 使用 per_cpu() 宏访问指定CPU的副本并累加
    total += per_cpu(my_counter, cpu);
}

printk("Total count across all CPUs: %lu\n", total);

4. 涉及的关键接口汇总 (API Summary)

分类接口/宏作用重要说明
静态定义DEFINE_PER_CPU(type, name)在编译时定义一个 per-cpu 变量。最常用,用于全局静态变量。
DECLARE_PER_CPU(type, name)仅声明一个外部 per-cpu 变量,类似于 extern用于头文件中。
动态分配/释放alloc_percpu(type)在运行时为每个 CPU 分配指定类型的数据。返回一个 __percpu 指针;失败返回 NULL
__alloc_percpu(size, align)更底层的分配函数,指定大小和对齐。alloc_percpu 内部调用它。
free_percpu(ptr)释放动态分配的 per-cpu 数据。
安全访问(当前CPU)get_cpu_var(name)禁止抢占,并返回当前CPU的静态变量地址。必须与 put_cpu_var 配对使用。
put_cpu_var(name)允许抢占。
get_cpu_ptr(ptr)禁止抢占,并返回当前CPU的动态变量地址。必须与 put_cpu_ptrput_cpu() 配对。
put_cpu_ptr(ptr)允许抢占。
非安全访问(当前CPU)this_cpu_read(name)读取当前CPU的变量值(原子)。要求上下文已禁止抢占 (如中断处理、锁内)。比get/put快。
this_cpu_write(name, val)写入当前CPU的变量值。同上。
this_cpu_add(name, val)对当前CPU的变量进行加法操作。同上。
this_cpu_ptr(ptr)获取当前CPU的动态变量指针。同上。
访问任意CPUper_cpu(name, cpu)获取指定CPU的静态变量值或左值。需要自己处理同步。
per_cpu_ptr(ptr, cpu)获取指定CPU的动态变量指针。需要自己处理同步。
迭代for_each_possible_cpu(cpu)遍历所有可能存在的CPU。用于汇总数据。
for_each_online_cpu(cpu)遍历所有当前在线的CPU。

5. 示例:实现一个简单的内核模块计数器

这个例子展示了如何定义、修改和读取一个 per-cpu 计数器。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/percpu.h>
#include <linux/proc_fs.h>
#include <linux/uaccess.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple per-cpu counter example");

// 1. 静态定义一个 per-cpu 计数器
DEFINE_PER_CPU(long, event_count);

#define PROC_NAME "percpu_counter"

// 当用户读取 /proc/percpu_counter 时调用
static ssize_t proc_read(struct file *file, char __user *usr_buf, size_t count, loff_t *ppos)
{
    long total_events = 0;
    int cpu;
    char buf[100];
    int len;

    if (*ppos > 0) {
        return 0; // 只允许从头开始读
    }

    // 2. 遍历所有在线CPU,汇总计数
    for_each_online_cpu(cpu) {
        total_events += per_cpu(event_count, cpu);
    }

    len = scnprintf(buf, sizeof(buf), "Total events: %ld\n", total_events);

    if (copy_to_user(usr_buf, buf, len)) {
        return -EFAULT;
    }

    *ppos = len;
    return len;
}

// 当用户写入 /proc/percpu_counter 时调用
static ssize_t proc_write(struct file *file, const char __user *usr_buf, size_t count, loff_t *ppos)
{
    // 3. 在当前CPU上增加计数,使用安全的 get/put 对
    get_cpu_var(event_count)++;
    put_cpu_var(event_count);

    // 我们也可以使用更快的 this_cpu_inc(),因为它已经是原子操作
    // this_cpu_inc(event_count); // 效果相同,且性能更好

    pr_info("Event recorded on CPU %d\n", smp_processor_id());

    return count; // 假装我们处理了所有输入
}

static const struct proc_ops proc_fops = {
    .proc_read  = proc_read,
    .proc_write = proc_write,
};

static int __init percpu_example_init(void)
{
    proc_create(PROC_NAME, 0666, NULL, &proc_fops);
    pr_info("/proc/%s created\n", PROC_NAME);

    // 可以在这里初始化所有CPU的计数器
    int cpu;
    for_each_possible_cpu(cpu) {
        per_cpu(event_count, cpu) = 0;
    }

    return 0;
}

static void __exit percpu_example_exit(void)
{
    remove_proc_entry(PROC_NAME, NULL);
    pr_info("/proc/%s removed\n", PROC_NAME);
}

module_init(percpu_example_init);
module_exit(percpu_example_exit);
  1. 编译并加载模块 (insmod percpu_example.ko)。
  2. 多次向 proc 文件写入数据:echo "1" > /proc/percpu_counter。每次写入都会在执行该命令的 CPU 上增加计数。
  3. 读取 proc 文件查看总数:cat /proc/percpu_counter。你会看到所有写入操作的总和。

这个例子展示了 per-cpu 变量的核心价值:写操作(proc_write)是高度并行和无锁的,而读操作(proc_read)虽然需要遍历,但通常不那么频繁,整体性能远超使用 atomic_long_tspinlock 保护的全局变量。

本文版权归原作者zhaofujian所有,采用 CC BY-NC-ND 4.0 协议进行许可,转载请注明出处。

发表评论