2. 原子操作:整型原子操作与位原子操作、原子变量的API与使用场景、内存屏障与顺序保证、原子操作在驱动中的实战
说到并发控制,很多人第一反应就是锁。但锁的开销其实不小,尤其是自旋锁,忙等待的时候CPU都在空转。我个人的习惯是,能不用锁就不用锁。那不用锁怎么保证数据安全?原子操作就是第一道防线。
说白了,原子操作就是“不可分割”的操作。要么全部执行完,要么一点都没执行。不会出现执行到一半被中断的情况。这在单核时代很简单,关中断就行。但在多核系统里,事情就没那么简单了。
整型原子操作
整型原子操作是最基础的一类。Linux内核提供了atomic_t类型,专门用来做原子计数。你想想看,如果直接用普通的int变量做i++,在汇编层面其实是三条指令:读、改、写。多核同时执行时,数据就乱了。
核心API其实不多,我列几个常用的:
atomic_t v = ATOMIC_INIT(0); // 定义并初始化为0
atomic_set(&v, 5); // 设置值为5
atomic_read(&v); // 读取当前值
atomic_inc(&v); // 原子加1
atomic_dec(&v); // 原子减1
atomic_add(2, &v); // 原子加2
atomic_sub(2, &v); // 原子减2
atomic_inc_and_test(&v); // 加1后测试是否为0
atomic_dec_and_test(&v); // 减1后测试是否为0
atomic_add_negative(1, &v); // 加1后测试是否为负数
关键点:atomic_t在32位系统上是32位,在64位系统上也是32位。如果需要64位原子操作,用atomic64_t。
我在项目中遇到过一个问题:驱动里用了一个全局计数器统计中断次数。最开始用的普通int,结果发现统计值偶尔会少。排查了半天,最后发现是中断嵌套导致的。换成atomic_inc后问题就解决了。
位原子操作
除了整型,内核还提供了位级别的原子操作。这在管理资源位图时特别有用。比如你要管理一组硬件资源,每个资源用一个bit表示是否被占用。
unsigned long bitmap = 0;
set_bit(3, &bitmap); // 将第3位置1
clear_bit(3, &bitmap); // 将第3位清0
test_bit(3, &bitmap); // 测试第3位的值
test_and_set_bit(3, &bitmap); // 测试并置位
test_and_clear_bit(3, &bitmap); // 测试并清0
嗯,这里要注意:set_bit和clear_bit是原子的,但如果你先test_bit再根据结果去set_bit,这两步之间就不是原子的了。这时候要用test_and_set_bit这种复合操作。
避坑指南:我曾经在写网卡驱动时,用test_bit加set_bit的组合来分配DMA描述符。结果两个CPU同时执行,都以为资源是空闲的,都分配了同一个描述符。后来改成test_and_set_bit才搞定。记住,测试和设置必须是一个原子操作。
内存屏障与顺序保证
原子操作能保证操作的原子性,但不能保证内存访问的顺序。为什么?因为编译器和CPU都可能重排指令。编译器为了优化会调整代码顺序,CPU为了流水线效率也会乱序执行。
举个例子:
// 线程A
data = 100;
flag = 1; // 假设flag是atomic_t
// 线程B
if (flag == 1) {
printk("data = %d\n", data); // 可能打印出0!
}
为什么会这样?因为CPU可能先把flag的写入执行了,data的写入还在store buffer里没刷到内存。线程B看到flag为1,但data还是旧值。
解决这个问题需要内存屏障。Linux内核提供了几种屏障:
| 屏障类型 | 宏定义 | 作用 |
|---|---|---|
| 通用屏障 | mb() | 读写操作都禁止重排 |
| 读屏障 | rmb() | 只禁止读操作重排 |
| 写屏障 | wmb() | 只禁止写操作重排 |
修正上面的代码:
// 线程A
data = 100;
wmb(); // 确保data写入先于flag写入
atomic_set(&flag, 1);
// 线程B
if (atomic_read(&flag) == 1) {
rmb(); // 确保flag读取先于data读取
printk("data = %d\n", data);
}
小技巧:其实很多原子操作API自带内存屏障语义。比如atomic_inc_return就包含了完整的屏障。如果你不确定,可以查一下内核文档里的"Documentation/atomic_t.txt"。
原子操作在驱动中的实战
说了这么多理论,来看看实际驱动里怎么用。我以一个字符设备驱动为例,展示原子操作如何管理设备打开次数。
#include <linux/atomic.h>
#include <linux/fs.h>
static atomic_t device_open = ATOMIC_INIT(0);
static int my_device_open(struct inode *inode, struct file *file)
{
// 尝试将device_open从0变为1
if (!atomic_cmpxchg(&device_open, 0, 1)) {
// 成功获取设备
return 0;
}
// 设备已被占用
return -EBUSY;
}
static int my_device_release(struct inode *inode, struct file *file)
{
atomic_set(&device_open, 0);
return 0;
}
atomic_cmpxchg是个好东西。它比较旧值,如果相等就设置新值,整个过程是原子的。这比先读再写安全多了。
再来看一个更复杂的场景:多生产者、单消费者的环形缓冲区。我习惯用原子操作来管理头尾指针:
struct ring_buffer {
char data[BUFFER_SIZE];
atomic_t head;
atomic_t tail;
};
// 生产者写入
int rb_write(struct ring_buffer *rb, char c)
{
int head = atomic_read(&rb->head);
int tail = atomic_read(&rb->tail);
int next = (head + 1) % BUFFER_SIZE;
if (next == tail) {
return -ENOSPC; // 缓冲区满
}
rb->data[head] = c;
smp_wmb(); // 确保数据写入先于head更新
atomic_set(&rb->head, next);
return 0;
}
// 消费者读取
int rb_read(struct ring_buffer *rb, char *c)
{
int head = atomic_read(&rb->head);
int tail = atomic_read(&rb->tail);
if (head == tail) {
return -ENODATA; // 缓冲区空
}
*c = rb->data[tail];
smp_rmb(); // 确保数据读取后于tail更新
atomic_set(&rb->tail, (tail + 1) % BUFFER_SIZE);
return 0;
}
这个环形缓冲区是无锁的。多个生产者可以同时写,因为每个生产者操作的是不同的head值。但要注意,这里假设了只有一个消费者。如果多个消费者,就需要更复杂的机制了。
实战经验:我在做网络驱动时,用原子操作管理NAPI的调度状态。用test_and_set_bit来避免同一个设备被多次调度到软中断。这个模式在内核网络子系统中很常见,你可以看看net/core/dev.c里的napi_schedule实现。
最后说一句,原子操作虽然轻量,但不是万能的。复杂的同步场景还是得用锁。但能用原子操作的地方,尽量用原子操作。性能好,代码也清晰。
公众号:蓝海资料掘金营,微信deep3321