解决竞态问题的途径是保证对共享资源的互斥访问,即一个执行单位在访问共享资源时,其他的执行单位被禁止访问。中断屏蔽、原子操作、自旋锁、信号量、互斥体等是LInux设备驱动中科采用的互斥途径。
1 指令乱序问题
1.1 编译乱序
现代的编译器在目标吗优化上具备对指令进行乱序优化的能力,可以对访问内存的指令进行乱序,减少逻辑上不必要的访存。
比如下面代码,编译的指令中可能最后的指针赋值gp=p
在a、b、c赋值之前
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
gp = p;//编译后,本语句不一定在最后
解决编译乱序问题,可以通过barrier()编译屏障进行(volatile关键字的作用不大):
#define barrier() __asm__ __volatile__("": : :"memory")
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
barrier();
gp = p;
1.2 执行乱序
编译乱序是编译器的行为,而执行乱序是处理器运行时的行为。
即使编译后的二进制指令按照设定的顺序摆放,在执行时,由于处理器乱序执行的策略(连续地址的访问可能会一起先执行完,提高缓存命中率),后面的指令可能先执行完。
处理器为了解决多核间一个核的内存行为对另外一个核可见的问题, 引入了一些内存屏障的指令。 譬如, ARM处理器的屏障指令包括:
- DMB(数据内存屏障):在DMB之后的显式内存访问执行前,保证所有在DMB指令之前的内存访问完成
- DSB(数据同步屏障):等待所有在DSB指令之前的指令完成(位于此指令前的所有显式内存访问均完成, 位于此指令前的所有缓存、跳转预测和TLB维护操作全部完成)
- ISB(指令同步屏障):Flush流水线, 使得所有ISB之后执行的指令都是从缓存或内存中获得的
在Linux内核中, 定义了读写屏障mb() 、读屏障rmb() 、写屏障wmb() 、以及作用于寄存器读写的iormb() 、iowmb()这样的屏障API。 读写寄存器的readl_relaxed()和readl()、writel_relaxed()和writel()API的区别就体现在有无屏障。
2 中断屏蔽
中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免了。
中断屏蔽的方法如下,其原理是让CPU不响应中断:
local_irq_disable(); //屏蔽中断
...
//临界区
...
local_irq_enable(); //打开中断
注意:单独使用中断屏蔽不是推荐的并发手段,应该与自旋锁一起配合使用。
3 原子操作
原子操作可以保证对整型数据的修改是原子的。Linux内核提供了API函数用于实现内核中针对位和整型变量的原子操作。函数的具体实现依赖CPU的原子操作。对于ARM处理器而言, 底层使用LDREX和STREX指令。
3.1 整型的原子操作函数
头文件:#include <asm/atomic.h>
//1.设置原子变量的值
atomic_t v = ATOMIC_INIT(0); /* 定义原子变量 v 并初始化为 0 */
void atomic_set(atomic_t *v, int i); /* 设置原子变量的值为 i */
//2.获取原子变量的值
atomic_read(atomic_t *v); /* 返回原子变量的值 */
//3.原子变量加/减
void atomic_add(int i, atomic_t *v); /* 原子变量增加 i */
void atomic_sub(int i, atomic_t *v); /* 原子变量减少 i */
//4.原子变量自增/自减
void atomic_inc(atomic_t *v); /* 原子变量增加 1 */
void atomic_dec(atomic_t *v); /* 原子变量减少 1 */
//5.先自增、减、自减,之后判断是否为0,为0返回true,非0返回false
int atomic_inc_and_test(atomic_t *v);
int atomic_dec_and_test(atomic_t *v);
int atomic_sub_and_test(int i, atomic_t *v);
//6.对原子变量进行加/减和自增/自减操作,并返回新的值
int atomic_add_return(int i, atomic_t *v);
int atomic_sub_return(int i, atomic_t *v);
int atomic_inc_return(atomic_t *v);
int atomic_dec_return(atomic_t *v);
3.2 位的原子操作函数
头文件:#include <asm/bitops.h>
//1.设置addr地址的第nr位,将位写为1
void set_bit(nr, void *addr);
//2.设置addr地址的第nr位,将位写为0
void clear_bit(nr, void *addr);
//3.对addr地址的第nr位进行反置
void change_bit(nr, void *addr);
//4.返回addr地址的第nr位
test_bit(nr, void *addr);
5.返回并操作位,等同于返回值,在操作位
int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);
3.3 如何在驱动中应用
将原子增减放在open和release函数中,可以控制驱动设备只能被一个进程打开。
static atomic_t xxx_available = ATOMIC_INIT(1); /* 定义原子变量 */
static int xxx_open(struct inode *inode, struct file *filp)
{
//先自减1,再检查当前值是否为0
if (!atomic_dec_and_test(&xxx_available))
{
//如果减1后不是0,说明出现负数值了,进入if原子变量加一恢复到0
atomic_inc(&xxx_available);
return -EBUSY; /* 已经打开 */
}
//...
return 0; /* 成功 */
}
static int xxx_release(struct inode *inode, struct file *filp)
{
atomic_inc(&xxx_available); /* 释放设备 */
return 0;
}
灵活运用,将原子变量的增减和check放在write或read函数中,可以实现只允许一个进程读、写等功能。
4 自旋锁
理解自旋锁最简单的方法是把它作为一个变量看待, 该变量把一个临界区标记为“我当前在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁; 当B执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。
在ARM体系结构下, 自旋锁的实现借用了ldrex指令、 strex指令、 ARM处理器内存屏障指令dmb和dsb、 wfe指令和sev指令。
4.1 自旋锁函数
Linux自旋锁的操作函数如下:
第九十章 内核同步
4.2 注意事项
驱动工程师应谨慎使用自旋锁,要特别注意如下几个问题:
- 自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
- 自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。
- 在自旋锁锁定期间不能调用可能引起进程调度的函数。如果进程获得自旋锁之后再阻塞,如调用copy_from_user()、copy_to_user()、 kmalloc()和msleep()等函数,则可能导致内核的崩溃。
- 在单核情况下编程的时候,也应该认为自己的CPU是多核的,驱动特别强调跨平台的概念。比如,在单CPU的情况下,若中断和进程可能访问同一临界区,进程里调用spin_lock_irqsave()是安全的,在中断里其实不调用spin_lock()也没有问题,因为spin_lock_irqsave()可以保证这个CPU的中断服务程序不可能执行。但是,若CPU变成多核,spin_lock_irqsave()不能屏蔽另外一个核的中断,所以另外一个核就可能造成并发问题。因此,无论如何,我们在中断服务程序里也应该调用spin_lock()。
4.3 如何在驱动中应用
在模块加载时初始化自旋锁,在open和release函数中获取锁,并及时释放锁。 ```c static int xxx_count = 0;/ 定义文件打开次数计数 / static spinlock_t xxx_lock;
static int __init xxx_init(void) { //… spin_lock_init(&xxx_lock);//加载模块时就初始化自旋锁 } static int xxx_open(struct inode inode, struct file filp) { //… spinlock(&xxx_lock); //获取锁 if (xxx_count) {/ 已经打开 / spin_unlock(&xxx_lock); return -EBUSY; } xxx_count++;/ 增加使用计数 / spin_unlock(&xxx_lock); //释放锁,不能影响read或write的copy等函数 //… return 0;/ 成功 / } static int xxx_release(struct inode inode, struct file filp) { //… spinlock(&xxx_lock);//获取锁 xxx_count—;/ 减少使用计数 / spin_unlock(&xxx_lock);//释放锁 return 0; }
<a name="T1RQJ"></a>
## 4.4 其他变种锁
- 读写自旋锁:[https://www.yuque.com/barret/giv6pv/utcgds#ddf1aa9e](https://www.yuque.com/barret/giv6pv/utcgds#ddf1aa9e)
- 顺序锁:[https://www.yuque.com/barret/giv6pv/utcgds#cLKDW](https://www.yuque.com/barret/giv6pv/utcgds#cLKDW)
<a name="nR5bc"></a>
# 5 RCU读-复制-更新
RCU可以看作**读写锁的高性能版本**,相比读写锁,RCU的优点在于既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访问被保护的数据。但是,RCU不能替代读写锁,因为如果写比较多时,对读执行单元的性能提高不能弥补写执行单元同步导致的损失。因为**使用RCU时,写执行单元之间的同步开销会比较大**,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制来同步并发的其他写执行单元的修改操作。
<a name="9dPUV"></a>
## 5.1 RCU函数
头文件:`#include <linux/rcupdate.h>`
```c
//读锁定
void rcu_read_lock();
void rcu_read_lock_bh();
//读解锁
void rcu_read_unlock();
void rcu_read_unlock_bh();
//同步rcu
void synchronize_rcu(void);
//挂载回调
void call_rcu(struct rcu_head *head, rcu_callback_t func);
6 其他并发控制手段
请移步:
3 多线程同步方式汇总