并发(concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race conditions)
一、中断(硬中断、软中断、Tasklet、底半部)与进程之间
1、中断可以打断正在执行的进程,如果中断处理程序访问进程正在访问的资源,则竞态也会发生
2、中断也有可能被新的更高优先级的中断打断
3、多个中断之间本身也可能引起并发而导致竞态
解决竞态问题的途径是保证对共享资源的互斥访问 ,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问 ;
访问共享资源的代码区域称为临界区(critical sections),临界区需要以某种互斥机制加以保护。中断屏蔽、原子操作、自旋锁和信号量等是Linux 设备驱动中可采用的互斥途径 ;
二、中断屏蔽
1、在单 CPU 范围内避免竞态的一种简单方法是在进入临界区之前屏蔽系统的中断
2、中断屏蔽将使得中断与进程之间的并发不再发生,而且,由于 Linux 内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也就得以避免了
由于 Linux 系统的异步 I/O、进程调度等很多重要操作都依赖于中断,中断对于内核的运行非常重要,在屏蔽中断期间所有的中断都无法得到处理,
因此长时间屏蔽中断是很危险的,有可能造成数据丢失甚至系统崩溃。这就要求在屏蔽了中断之后,当前的内核执行路径应当尽快地执行完临界区的代码
1、local_irq_disable()和 local_irq_enable()都只能禁止和使能本 CPU 内的中断,因此,并不能解决 SMP 多 CPU 引发的竞态
2、单独使用中断屏蔽通常不是一种值得推荐的避免竞态的方法,它适宜与自旋锁联合使用
3、与 local_irq_disable()不同的是,local_irq_save(flags)除了进行禁止中断的操作以外,还保存目前 CPU 的中断位信息,local_irq_restore(flags)进行的是与 local_irq_save(flags)相反的操作
4、如果只是想禁止中断的底半部,应使用 local_bh_disable(),使能被 local_bh_disable()禁止的底半部应该调用 local_bh_enable()
三、原子操作
原子操作指的是在执行过程中不会被别的代码路径所中断的操作。在任何情况下操作都是原子的,内核代码可以安全地调用它们而不被打断
1、设置原子变量的值
void atomic_set(atomic_t *v, int i); //设置原子变量的值为 i
atomic_t v = ATOMIC_INIT(0); //定义原子变量 v 并初始化为 0
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、原子变量加减
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);
四、自旋锁
所谓的“自旋”,通俗地说就是“在原地打转” ,当自旋锁的持有者通过重置该变量释放这个自旋锁后,某个等待的“测试并设置”操作向其调用者报告锁已释放
理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区或者标 ,记为“我当前在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”
自旋锁一般这样被使用,如下所示:
//定义一个自旋锁
spinlock_t lock;
spin_lock_init(&lock);
spin_lock (&lock) ; //获取自旋锁,保护临界区
...//临界区
spin_unlock (&lock) ; //解锁
重要知识点
1、自旋锁主要针对 SMP 或单 CPU 但内核可抢占的情况,对于单 CPU 和内核不支持抢占的系统,自旋锁退化为空操作
2、在单 CPU 和内核可抢占的系统中,自旋锁持有期间内核的抢占将被禁止
3、由于内核可抢占的单 CPU 系统的行为实际很类似于 SMP(多核系统),系统,因此,在这样的单 CPU 系统中使用自旋锁仍十分必要
自旋锁可以保证临界区不受别的 CPU 和本 CPU 内的抢占进程打扰 ,但是得到锁的代码路径在执行临界区的时候还可能受到中断和底半部(BH)的影响
为了防止这种影响,就需要用到自旋锁的衍生 。
spin_lock()/spin_unlock()是自旋锁机制的基础,它们和关中断 local_irq_ disable()/开中断 local_irq_enable()、关底半部
local_bh_disable()/开底半部 local_bh_enable()、关中断并保存状态字 local_irq_save()/
开中断并恢复状态 local_irq_restore()结合就形成了整套自旋锁机制,关系如下所示:
spin_lock_irq() = spin_lock() + local_irq_disable()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = spin_unlock() + local_irq_save()
spin_unlock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bh() = spin_lock() + local_bh_disable()
spin_unlock_bh() = spin_unlock() + local_bh_enable()
驱动工程师应谨慎使用自旋锁,而且在使用中还要特别注意如下几个问题。
1、自旋锁实际上是忙等锁,当锁不可用时,CPU 一直循环执行“测试并设置”
该锁直到可用而取得该锁,CPU 在等待自旋锁时不做任何有用的工作,仅仅
是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的,
当临界区很大或有共享设备的时候,需要较长时间占用锁,使用自旋锁会降
低系统的性能
2、自旋锁可能导致系统死锁
引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的
CPU 想第二次获得这个自旋锁,则该 CPU 将死锁。此外,如果进程获得自旋锁之后再
阻塞,也有可能导致死锁的发生。copy_from_user()、copy_to_user()和 kmalloc()
等函数都有可能引起阻塞,因此在自旋锁的占用期间不能调用这些函数。
1、读写自旋锁
读写自旋锁是一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多有一个写进程,在读操作方面,
同时可以有多个读执行单元 。当然,读和写也不能同时进行
2、顺序锁
顺序锁(seqlock)是对读写锁的一种优化,若使用顺序锁,读执行单元绝不会被写执行单元阻塞
1、读执行单元不必等待写执行单元完成写操作
2、写执行单元不需要等待所有读执行单元完成读操作才去进行写操作
3、写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行,
写操作,其他写执行单元必须自旋在那里,直到写执行单元释放了顺序锁
4、读执行单元在读操作期间,写执行单元已经发生了写操作,读执行单
元必须重新读取数据,以便确保得到的数据是完整的
顺序锁有一个限制,它必须要求被保护的共享资源不含有指针,因为写执行单元可能使得指针失效,但读执行单元如果正要访问该指针,将导致 Oops
五、读-拷贝-更新 RCU(Read-Copy Update,读-拷贝-更新)
使用 RCU 的写执行单元在访问它前需首先复制一个副本,然后对副本进行修改,最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的,
数据,这个时机就是所有引用该数据的 CPU 都退出对共享数据的操作的时候。
RCU 可以看做读写锁的高性能版本,相比读写锁,RCU 的优点在于既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访
问被保护的数据
RCU 不能替代读写锁,因为如果写比较多时,对读执行单元的性能提高不能弥补写执行单元导致的损失。因为使用 RCU 时,写执行单元之间的同步开销会比
较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其他写执行单元的修改操作
六、信号量
与自旋锁不同的是,当获取不到信号量时,进程不会原地打转而是进入休眠等待状态。
1、定义信号量
struct semaphore sem;
2、初始化信号量
void sema_init (struct semaphore *sem, int val);
3、获得信号量
void down(struct semaphore * sem);
该函数用于获得信号量 sem,它会导致睡眠,因此不能在中断上下文使用。
int down_trylock(struct semaphore * sem);
该函数尝试获得信号量 sem,如果能够立刻获得,它就获得该信号量并返回 0,
否则,返回非 0 值。它不会导致调用者睡眠,可以在中断上下文使用
4、释放信号量
void up(struct semaphore * sem);
信号量一般这样被使用,如下所示:
//定义信号量
DECLARE_MUTEX(mount_sem);
down(&mount_sem);//获取信号量,保护临界区
...
critical section //临界区
...
up(&mount_sem);//释放信号量
七、自旋锁 vs 信号量
信号量和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者
信号量是进程级的,用于多个进程之间对资源的互斥,虽然也是在内核中,但是该内核执行路径是以进程的身份,代表进程来争夺资源的。如果竞争失败,会发生进
程上下文切换,当前进程进入睡眠状态,CPU 将运行其他进程。鉴于进程上下文切换的开销也很大,因此,只有当进程占用资源时间较长时,用信号量才是较好的选择。
当所要保护的临界区访问时间比较短时,用自旋锁是非常方便的,因为它节省上下文切换的时间。但是 CPU 得不到自旋锁会在那里空转直到其他执行单元解锁为止,
所以要求锁不能在临界区里长时间停留,否则会降低系统的效率
由此,可以总结出自旋锁和信号量选用的 3 项原则。
1、 当锁不能被获取时,使用信号量的开销是进程上下文切换时间 Tsw,使用自
旋锁的开销是等待获取自旋锁(由临界区执行时间决定)Tcs,若 Tcs 比较小,
应使用自旋锁,若 Tcs 很大,应使用信号量。
2、信号量所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免
用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果
进程被切换出去后,另一个进程企图获取本自旋锁,死锁就会发生。
3、信号量存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中
断情况下使用,则在信号量和自旋锁之间只能选择自旋锁。当然,如果一定
要使用信号量,则只能通过 down_trylock()方式进行,不能获取就立即返回
以避免阻塞
八、读写信号量
读写信号量与信号量的关系与读写自旋锁和自旋锁的关系类似,读写信号量可能引起进程阻塞,但它可允许 N 个读执行单元同时访问共享资源,
而最多只能有一个写执行单元
读写信号量一般这样被使用,如下所示:
rw_semaphore rw_sem; //定义读写信号量
init_rwsem(&rw_sem); //初始化读写信号量
//读时获取信号量
down_read(&rw_sem);
... //临界资源
up_read(&rw_sem);
//写时获取信号量
down_write(&rw_sem);
... //临界资源
up_write(&rw_sem);
九、互斥体
mutex_lock()与 mutex_lock_interruptible()的区别和 down()与 down_trylock()的区别完全一致,前者引起的睡眠不能被信号打断,而后者可以。mutex_trylock()用于尝试
获得 mutex,获取不到 mutex 时不会引起进程睡眠
十、总结
并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁和信号量都是解决并发问题的机制。中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和信号量应用最为广泛。
自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。信号量允许临界区阻塞,可以适用于临界区大的情况。
读写自旋锁和读写信号量分别是放宽了条件的自旋锁和信号量,它们允许多个执行单元对共享资源的并发读。