解决竞态问题的途径是保证对共享资源的互斥访问,即一个执行单位在访问共享资源时,其他的执行单位被禁止访问。中断屏蔽、原子操作、自旋锁、信号量、互斥体等是LInux设备驱动中科采用的互斥途径。

1 指令乱序问题

1.1 编译乱序

现代的编译器在目标吗优化上具备对指令进行乱序优化的能力,可以对访问内存的指令进行乱序,减少逻辑上不必要的访存。
比如下面代码,编译的指令中可能最后的指针赋值gp=p在a、b、c赋值之前

  1. p = kmalloc(sizeof(*p), GFP_KERNEL);
  2. p->a = 1;
  3. p->b = 2;
  4. p->c = 3;
  5. gp = p;//编译后,本语句不一定在最后

解决编译乱序问题,可以通过barrier()编译屏障进行(volatile关键字的作用不大):

  1. #define barrier() __asm__ __volatile__("": : :"memory")
  2. p = kmalloc(sizeof(*p), GFP_KERNEL);
  3. p->a = 1;
  4. p->b = 2;
  5. p->c = 3;
  6. barrier();
  7. 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不响应中断

  1. local_irq_disable(); //屏蔽中断
  2. ...
  3. //临界区
  4. ...
  5. local_irq_enable(); //打开中断

注意:单独使用中断屏蔽不是推荐的并发手段,应该与自旋锁一起配合使用

3 原子操作

原子操作可以保证对整型数据的修改是原子的。Linux内核提供了API函数用于实现内核中针对位和整型变量的原子操作。函数的具体实现依赖CPU的原子操作。对于ARM处理器而言, 底层使用LDREX和STREX指令

3.1 整型的原子操作函数

头文件:#include <asm/atomic.h>

  1. //1.设置原子变量的值
  2. atomic_t v = ATOMIC_INIT(0); /* 定义原子变量 v 并初始化为 0 */
  3. void atomic_set(atomic_t *v, int i); /* 设置原子变量的值为 i */
  4. //2.获取原子变量的值
  5. atomic_read(atomic_t *v); /* 返回原子变量的值 */
  6. //3.原子变量加/减
  7. void atomic_add(int i, atomic_t *v); /* 原子变量增加 i */
  8. void atomic_sub(int i, atomic_t *v); /* 原子变量减少 i */
  9. //4.原子变量自增/自减
  10. void atomic_inc(atomic_t *v); /* 原子变量增加 1 */
  11. void atomic_dec(atomic_t *v); /* 原子变量减少 1 */
  12. //5.先自增、减、自减,之后判断是否为0,为0返回true,非0返回false
  13. int atomic_inc_and_test(atomic_t *v);
  14. int atomic_dec_and_test(atomic_t *v);
  15. int atomic_sub_and_test(int i, atomic_t *v);
  16. //6.对原子变量进行加/减和自增/自减操作,并返回新的值
  17. int atomic_add_return(int i, atomic_t *v);
  18. int atomic_sub_return(int i, atomic_t *v);
  19. int atomic_inc_return(atomic_t *v);
  20. int atomic_dec_return(atomic_t *v);

3.2 位的原子操作函数

头文件:#include <asm/bitops.h>

  1. //1.设置addr地址的第nr位,将位写为1
  2. void set_bit(nr, void *addr);
  3. //2.设置addr地址的第nr位,将位写为0
  4. void clear_bit(nr, void *addr);
  5. //3.对addr地址的第nr位进行反置
  6. void change_bit(nr, void *addr);
  7. //4.返回addr地址的第nr位
  8. test_bit(nr, void *addr);
  9. 5.返回并操作位,等同于返回值,在操作位
  10. int test_and_set_bit(nr, void *addr);
  11. int test_and_clear_bit(nr, void *addr);
  12. int test_and_change_bit(nr, void *addr);

3.3 如何在驱动中应用

将原子增减放在open和release函数中,可以控制驱动设备只能被一个进程打开。

  1. static atomic_t xxx_available = ATOMIC_INIT(1); /* 定义原子变量 */
  2. static int xxx_open(struct inode *inode, struct file *filp)
  3. {
  4. //先自减1,再检查当前值是否为0
  5. if (!atomic_dec_and_test(&xxx_available))
  6. {
  7. //如果减1后不是0,说明出现负数值了,进入if原子变量加一恢复到0
  8. atomic_inc(&xxx_available);
  9. return -EBUSY; /* 已经打开 */
  10. }
  11. //...
  12. return 0; /* 成功 */
  13. }
  14. static int xxx_release(struct inode *inode, struct file *filp)
  15. {
  16. atomic_inc(&xxx_available); /* 释放设备 */
  17. return 0;
  18. }

灵活运用,将原子变量的增减和check放在write或read函数中,可以实现只允许一个进程读、写等功能。

4 自旋锁

理解自旋锁最简单的方法是把它作为一个变量看待, 该变量把一个临界区标记为“我当前在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁; 当B执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。
在ARM体系结构下, 自旋锁的实现借用了ldrex指令、 strex指令、 ARM处理器内存屏障指令dmb和dsb、 wfe指令和sev指令

4.1 自旋锁函数

Linux自旋锁的操作函数如下:
第九十章 内核同步

4.2 注意事项

驱动工程师应谨慎使用自旋锁,要特别注意如下几个问题:

  1. 自旋锁实际上是忙等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁,CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降低系统的性能。
  2. 自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,则该CPU将死锁。
  3. 在自旋锁锁定期间不能调用可能引起进程调度的函数。如果进程获得自旋锁之后再阻塞,如调用copy_from_user()、copy_to_user()、 kmalloc()和msleep()等函数,则可能导致内核的崩溃。
  4. 在单核情况下编程的时候,也应该认为自己的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; }

  1. <a name="T1RQJ"></a>
  2. ## 4.4 其他变种锁
  3. - 读写自旋锁:[https://www.yuque.com/barret/giv6pv/utcgds#ddf1aa9e](https://www.yuque.com/barret/giv6pv/utcgds#ddf1aa9e)
  4. - 顺序锁:[https://www.yuque.com/barret/giv6pv/utcgds#cLKDW](https://www.yuque.com/barret/giv6pv/utcgds#cLKDW)
  5. <a name="nR5bc"></a>
  6. # 5 RCU读-复制-更新
  7. RCU可以看作**读写锁的高性能版本**,相比读写锁,RCU的优点在于既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访问被保护的数据。但是,RCU不能替代读写锁,因为如果写比较多时,对读执行单元的性能提高不能弥补写执行单元同步导致的损失。因为**使用RCU时,写执行单元之间的同步开销会比较大**,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制来同步并发的其他写执行单元的修改操作。
  8. <a name="9dPUV"></a>
  9. ## 5.1 RCU函数
  10. 头文件:`#include <linux/rcupdate.h>`
  11. ```c
  12. //读锁定
  13. void rcu_read_lock();
  14. void rcu_read_lock_bh();
  15. //读解锁
  16. void rcu_read_unlock();
  17. void rcu_read_unlock_bh();
  18. //同步rcu
  19. void synchronize_rcu(void);
  20. //挂载回调
  21. void call_rcu(struct rcu_head *head, rcu_callback_t func);

6 其他并发控制手段

请移步:
3 多线程同步方式汇总