Linux驱动开发-第五章-Linux设备驱动中的并发控制

Linux设备驱动中的并发控制

并发与竞态

  • 并发(Concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(Race Conditions)
1、对称多处理器(SMP)的多个CPU
  • SMP是一种紧耦合、共享存储的系统模型,其体系结构如图所示,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和储存器。

在这里插入图片描述

  • 在SMP的情况下,两个核(CPU0和CPU1)的竞态可能发生于CPU0的进程与CPU1的进程之间、CPU0的进程与CPU1的中断之间以及CPU0的中断与CPU1的中断之间,图中任何一条线连接的两个实体都有核间并发可能性。

在这里插入图片描述

2、单CPU进程相互抢占
  • Linux 2.6以后的内核支持内核抢占调度,一个进程在内核执行的时候可能耗完了自己的时间片(timeslice),也可能被另一个高优先级进程打断,进程与抢占它的进程访问共享资源的情况类似于SMP的多个CPU。
3、中断(硬中断、软中断、Tasklet、底半部)与进程之间
  • 中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则竞态也会发生。此外,中断也有可能被新的更高优先级的中断打断,因此,多个中断之间本身也可能引起并发而导致竞态。

上述并发的发生除了SMP是真正的并行以外,其他的都是单核上的“宏观并行,微观串行”,但其引发的实质问题和SMP相似。

解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。访问共享资源的代码区域称为临界区(Critical Sections),临界区需要被以某种互斥机制加以保护。中断屏蔽、原子操作、自旋锁、信号量、互斥体等是Linux设备驱动中可采用的互斥途径。

编译乱序和执行乱序

1、编译乱序
  • 由于现代编译器在目标码优化上都具备对指令进行乱序优化的能力。编译器可以对访存的指令进行乱序,减少逻辑上不必要的访存,以及尽量提高Cache命中率和CPU的Load/Store单元的工作效率。导致编译生成的汇编码并非严格按照代码执行顺序;
  • 解决编译乱序问题,需通过设置barrier()编译屏障来解决。
2、执行乱序
  • 执行乱序是指即便编译的二进制指令的顺序按照“p->a=1;p->b=2;p->c=3;gp=p;”排放,在处理器上执行时,后发射的指令还是可能先执行完,这是处理器的“乱序执行(Out-of-Order Execution)”策略。高级的CPU可以根据自己缓存的组织特性,将访存指令重新排序执行。连续地址的访问可能会先执行,因为这样缓存命中率高。有的还允许访存的非阻塞,即如果前面一条访存指令因为缓存不命中,造成长延时的存储访问时,后面的访存指令可以先执行,以便从缓存中取出。因此,即使是从汇编上看顺序正确的指令,其执行的顺序也是不可预知的。

  • 处理器为了解决多核间一个核的内存行为对另外一个核可见的问题,引入了一些内存屏障的指令。

中断屏蔽

  • Linux内核中将中断响应的处理可以分为两部分:顶半部和底半部

    1,顶半部处理要紧且操作时间短的事件,如恢复相应的寄存器状态

    2,底半部处理操作时间长的事件

  • 在单CPU范围内避免竞态的一种简单而有效的方法是在进入临界区之前屏蔽系统的中断,但是在驱动编程中不值得推荐,驱动通常需要考虑跨平台特点而不假定自己在单核上运行。CPU一般都具备屏蔽中断和打开中断的功能,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言,中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免了。

  • 中断屏蔽的使用示例如下:

local_irq_disable()		/* 屏蔽中断 */
. . .
critical section	/* 临界区 */
. . .
local_irq_enable()		/* 开中断 */
  • local_irq_disable()和local_irq_enable()都只能禁止和使能本CPU内的中断,因此,并不能解决SMP多CPU引发的竞态。因此,单独使用中断屏蔽通常不是一种值得推荐的避免竞态的方法(换句话说,驱动中使用local_irq_disable/enable()通常意味着一个bug)
  • 与local_irq_disable()不同的是,local_irq_save(flags)除了进行禁止中断的操作以外,还保存目前CPU的中断位信息,local_irq_restore(flags)进行的是与local_irq_save(flags)相反的操作。对于ARM处理器而言,其实就是保存和恢复CPSR。
  • 如果只是想禁止中断的底半部,应使用local_bh_disable(),使能被local_bh_disable()禁止的底半部应该调用local_bh_enable()。

原子操作

  • 原子操作可以保证对一个整型数据的修改是排他性的。Linux内核提供了一系列函数来实现内核中的原子操作,这些函数又分为两类,分别针对位和整型变量进行原子操作。位和整型变量的原子操作都依赖于底层CPU的原子操作,因此所有这些函数都与CPU架构密切相关。对于ARM处理器而言,底层使用LDREX和STREX指令,比如atomic_inc()底层的实现会调用到atomic_add()
1、整形原子操作
void atomic_set(atomic_t *v, int i);	/* 定义原子变量 v 并初始化为 0 */
atomic_t v = ATOMIC_INIT(0);	/* 设置原子变量的值为 i*/
atomic_read(atomic_t *v);	/* 返回原子变量的值 */
void atomic_add(int i, atomic_t *v);	/* 原子变量增加 i */
void atomic_sub(int i, atomic_t *v);	/* 原子变量减少 i */
void atomic_inc(atomic_t *v);	/* 原子变量增加 1 */
void atomic_dec(atomic_t *v);	/* 原子变量减少 1 */
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);
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);
2、位原子操作
void set_bit(nr, void *addr);	/* 所谓设置位即是将位写为1 */
void clear_bit(nr, void *addr);		/* 所谓设置位即是将位写为0 */
void change_bit(nr, void *addr);	/* 将设置位反置 */
test_bit(nr, void *addr);	/* 返回addr地址的第nr位 */
int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);

自旋锁

  • 自旋锁(Spin Lock)是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。为了获得一个自旋锁,在某CPU上运行的代码需先执行一个原子操作,该操作测试并设置(Test-And-Set)某个内存变量。由于它是原子操作,所以在该操作完成之前其他执行单元不可能访问这个内存变量。
  • 自旋锁的一般使用如下:
spinlock_t lock;	/* 定义一个自旋锁 */
spin_lock_init(&lock);
spin_lock (&lock) ;		/* 获取自旋锁,保护临界区 */
. . .		/* 临界区 */
spin_unlock (&lock) ; 	/*解锁 */
  • 自旋锁主要针对SMP或单CPU但内核可抢占的情况,对于单CPU和内核不支持抢占的系统,自旋锁退化为空操作;尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部的影响。

  • 对自旋锁的使用,实际上要注意以下方面:

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()

1、读写自旋锁
  • 自旋锁不关心锁定的临界区究竟在进行什么操作,不管是读还是写,它都一视同仁。即便多个执行单元同时读取临界资源也会被锁住。实际上,对共享资源并发访问时,多个执行单元同时读取它是不会有问题的,自旋锁的衍生锁读写自旋锁(rwlock)可允许读的并发
rwlock_t lock;				/* 定义 rwlock */
rwlock_init(&lock);		/* 初始化 rwlock */
read_lock(&lock);		/* 读时获取锁 */
...										/* 临界资源 */
read_unlock(&lock);
write_lock_irqsave(&lock, flags);	/* 写时获取锁 */
...																		/* 临界资源 */
write_unlock_irqrestore(&lock, flags);
2、顺序锁
  • 顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元不会被写执行单元阻塞,也就是说,读执行单元在写执行单元对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其他写执行单元必须自旋在那里,直到写执行单元释放了顺序锁。

  • 写执行单元使用顺序锁的模式如下:

write_seqlock(&seqlock_a);
...										/* 写操作代码块 */
write_sequnlock(&seqlock_a);
  • 读执行单元使用顺序锁的模式如下:
do {
	seqnum = read_seqbegin(&seqlock_a);
	...	/* 读操作代码块 */
} while (read_seqretry(&seqlock_a, seqnum));
3、RCU(Read-Copy-Update,读-复制-更新)
  • 不同于自旋锁,使用RCU的读端没有锁、内存屏障、原子指令类的开销,几乎可以认为是直接读 (只是简单地标明读开始和读结束),而RCU的写执行单元在访问它的共享资源前首先复制一个副本,然后对副本进行修改,最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,这个时机就是所有引用该数据的CPU都退出对共享数据读操作的时候。等待适当时机的这一时期称为宽限期(Grace Period)。

互斥体

  • 互斥体是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其他进程。鉴于进程上下文切换的开销也很大,因此,只有当进程占用资源时间较长时,用互斥体才是较好的选择

  • 互斥体的基本使用示例模板

    struct mutex my_mutex;	/* 定义 mutex */
    mutex_init(&my_mutex);	/* 初始化 mutex */
    mutex_lock(&my_mutex);	/* 获取 mutex */
    ...																/* 临界资源 */
    mutex_unlock(&my_mutex);	/* 释放 mutex */
    
  • 自旋锁和互斥体选用的3项原则如下
    1)当锁不能被获取到时,使用互斥体的开销是进程上下文切换时间,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定)。若临界区比较小,宜使用自旋锁,若临界区很大,应使用互斥体

    2)互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。

    3)互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在互斥体和自旋锁之间只能选择自旋锁。当然,如果一定要使用互斥体,则只能通过mutex_trylock()方式进行,不能获取就立即返回以避免阻塞。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值