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 核心频繁地读写同一个内存地址时,会发生:
- 锁竞争 (Lock Contention):为了保证数据一致性,必须使用自旋锁(spinlock)或原子操作等同步原语。CPU 越多,竞争越激烈,等待锁的时间越长,性能越差。
- 缓存行伪共享 (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)
:- 禁止内核抢占 (
preempt_disable()
)。 - 返回当前 CPU 上的
var
变量副本的地址。
- 禁止内核抢占 (
put_cpu_var(var)
:- 允许内核抢占 (
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_cpu
或 for_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_ptr 或 put_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的动态变量指针。 | 同上。 | |
访问任意CPU | per_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);
- 编译并加载模块 (
insmod percpu_example.ko
)。 - 多次向 proc 文件写入数据:
echo "1" > /proc/percpu_counter
。每次写入都会在执行该命令的 CPU 上增加计数。 - 读取 proc 文件查看总数:
cat /proc/percpu_counter
。你会看到所有写入操作的总和。
这个例子展示了 per-cpu 变量的核心价值:写操作(proc_write
)是高度并行和无锁的,而读操作(proc_read
)虽然需要遍历,但通常不那么频繁,整体性能远超使用 atomic_long_t
或 spinlock
保护的全局变量。