Linux驱动设备中的并发控制
Linux设备驱动中必须解决的一个问题是多个进程对共享资源的并发访问,并发的访问会导致竞态,即使是经验丰富的驱动工程师也常常设计出包含并发问题的bug驱动程序。
Linux提供了多种解决竞态问题的方式,这些方式适合不同的应用场景。
并发(concurrency):指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件资源上的全局变量、静态变量等)的访问则很容易导致竞态(race condition);
竞态(race condition):简单来讲,竞态就是指多个执行序列同时访问同一个共享资源的状况;
临界区(critical sections):访问共性资源的代码区域称为临界区,临界区需要被以某种互斥机制加以保护;
临界资源:是指一段时间内只允许一个进程访问的资源
在宏观上并行或者真正意义上的并行(这里为什么是宏观意义的并行呢?我们应该知道“时间片”这个概念,微观上还是串行的,所以这里称为宏观上的并行),可能会导致竞争; 类似两条十字交叉的道路上运行的车。当他们同一时刻要经过共同的资源(交叉点)的时候,如果没有交通信号灯,就可能出现混乱。
在Linux内核中,主要的竞态发生于如下几种情况:
1)对称多处理器(SMP)的多个 CPU
SMP是一种紧密耦合、共享存储的系统模型,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。
2)单 CPU 内进程与抢占它的进程
一个进程在内核执行的时候可能被另一高优先级进程打断。
3)中断(硬中断、软中断、Tasklet、底半部)与进程之间
中断可以打断正在执行的进程,如果中断处理程序访问进程正在访问的资源,则竞态就会发生。此外,中断也有可能被更高优先级的中断打断,因此,多个中断之间本身也可能引起并发而导致竞态。
Linux提供的竞态问题解决方式
解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问时指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。中断屏蔽、原子操作、自旋锁和信号量等是Linux设备驱动中可采用的互斥途径。
在单CPU范围内避免竞态的一种简单省事的方法是在进入临界区之前屏蔽系统的中断,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞态条件的发生。具体而言:
1)中断屏蔽将使得中断与进程之间的并发不再发生
2)由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占与进程之间的并发就得意避免了。
中断屏蔽的使用方法为:
local_irq_disable() /*屏蔽中断*/ ... critical section /*临界区*/ ... local_irq_enable() /*打开中断*/
local_irq_disable() local_irq_enable() /* 只能禁止和使能本地CPU的中断,所以不能解决多CPU引发的竞态 */ local_irq_save(flags) local_irq_restore(flags) /* 除了能禁止和使能中断外,还保存和还原目前的CPU中断位信息 */ local_bh_disable() local_bh_disable() /* 如果只是想禁止中断的底半部,这是个不错的选择 */
需要注意:
1)由于Linux的异步I/O、进程调度等很多重要操作依赖于中断,中断对于内核的执行非常重要,在屏蔽中断期间说有的中断都无法得到处理,因此产时间屏蔽中断是很危险的,有可能造成数据丢失乃至系统崩溃等后果,因此在屏蔽了中断之后,当前的内核执行路径应当尽快的执行完临界区的代码;
2)单独使用中断屏蔽不是一种值得推荐的避免竞态的方式,它宜与自旋锁联合使用。
原子操作(整型原子操作和位原子操作)是在执行过程不会被别的代码路径所中断的操作,它在任何情况下操作都是院子的,内核代码可以安全的调用它们而不被打断。
整型原子操作
1、设置原子变量的值
#define atomic_set(v,i) ((v)->counter = (i))
void atomic_set(atomic_t *v, int i); /* 设置原子变量的值为i */
#define ATOMIC_INIT(i) ( (atomic_t) { (i) } )
atomic_t v = ATOMIC_INIT(0); /* 定义原子变量 v 并初始化为 0 (该宏只支持初始为 0)*/
2、获取原子变量的值
#define atomic_read(v) ((v)->counter + 0)
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、原子变量自增/自减
#define atomic_inc(v) atomic_add(1, v);
void atomic_inc(atomic_t *v); /* 原子变量自增 1 */
#define atomic_dec(v) atomic_sub(1, v);
void atomic_dec(atomic_t *v); /* 原子变量自减 1 */
5、操作并测试
#define atomic_inc_and_test(v) (atomic_add_return(1, (v)) == 0)static inline int atomic_inc_and_test(atomic_t *v); /* 原子变量自增 1 并判断结果是否为 0 */
int atomic_dec_and_test(atomic_t *v); /* 原子变量自减 1 并判断结果是否为 0 */ int atomic_sub_and_teset(int i, atomic_t *v); /* 原子变量减 i 并判断结果是否为 0 */ /* 上述测试结果为 0 返回 true 否者返回 false */
6、操作并返回
int atomic_add_and_return(int i, atomic_t *v); /* 原子变量加 i 并返回新值 */ int atomic_sub_and_return(int i, atomic_t *v); /* 原子变量减 i 并返回新值 */ int atomic_inc_and_return(atomic_t *v); /* 原子变量自增 1 并返回新值 */ int atomic_dec_and_return(atomic_t *v); /* 原子变量自减 1 并返回新值 */
原子操作的优点编写简单;缺点是功能太简单,只能做计数操作,保护的东西太少。下面看一个实例
static atomic_t v=ATOMIC_INIT(1); static int hello_open (struct inode *inode, struct file *filep) { if(!atomic_dec_and_test(&v)) { atomic_inc(&v); return -EBUSY; } return 0; } static int hello_release (struct inode *inode, struct file *filep) { atomic_inc(&v); return 0; }
四、自旋锁 (http://blog.csdn.net/vividonly/article/details/6594195)
一)自旋锁的使用
自旋锁(spin lock)是一个互斥设备,它只有两个值:“锁定”和“解锁”。它通常实现为某个整数值中的某个位。希望获得某个特定锁,需要代码测试相关的位。如果锁可用,则“锁定”被设置,而代码继续进入临界区;相反,如果锁被其他人获得,则代码进入忙循环(而不是休眠,这也是自旋锁和一般锁的区别)并重复检查这个锁,直到该锁可用为止,这就是自旋的过程。“测试并设置位”的操作必须是原子的,这样,即使多个线程在给定时间自旋,也只有一个线程可获得该锁。
Linux 中与直选说相关的操作主要由以下4种。
1、定义自旋锁
spinlock_t lock;
2、初始化自旋锁
#define spin_lock_init(_lock) /* 该宏用于动态初始化自旋锁 lock */void spin_lock_init(spinlock_t *);
3、获取自旋锁
void spin_lock(spinlock_t *lock); /* 该宏用于获得自旋锁 lock,如果 lock 未被加锁,它就会称为 持有者并立即返回,否者它将自旋在那里,知道该自旋锁的持有者释放 */ int spin_trylock(spinlock_t *lock); /* 该宏用于获得自旋锁 lock,如果 lock 未被加锁,它就会称为 持有者并返回真,否者立即返回假 */
4、释放自旋锁
void spin_unlock(spinlock_t *lock); /* 用于释放自旋锁,与 spin_lock 或 spin_trylock 配对使用*/
自旋锁一般这样被使用:
/* 定义一个自旋锁 */ spinlock_t lock; spin_lock_init(&lock); spin_lock(&lock); /* 获取自旋锁,保护临界区 */ ... critical section /*临界区*/ ... spin_unlock(&lock); /* 解锁*/
下面是一个实例:
static spinlock_t lock; static int flag = 1; static int hello_open (struct inode *inode, struct file *filep) { spin_lock(&lock); if(flag !=1) { spin_unlock(&lock); return -EBUSY; } flag = 0; spin_unlock(&lock); return 0;
} static int hello_release (struct inode *inode, struct file *filep) { flag = 1; return 0; }
自旋锁最初是为了在多处理器系统(SMP)使用而设计的,但是只要考虑到并发问题,单处理器在运行可抢占内核时其行为就类似于SMP。因此,自旋锁对于SMP和单处理器可抢占内核都适用。可以想象,当一个处理器处于自旋状态时,它做不了任何有用的工作,因此自旋锁对于单处理器不可抢占内核没有意义,实际上,非抢占式的单处理器系统上自旋锁被实现为空操作,不做任何事情。
注意:
1)自旋锁实际上是忙等锁,因此只有在占用锁的时间极短的情况下,使用自旋锁才是合理的;
2)自旋锁可能导致系统死锁。引发这个问题的常见情况是递归使用一个自旋锁;
自旋锁导致死锁的实例】
a) a进程拥有自旋锁,在内核态阻塞的,内核调度进程b,b也要或得自旋锁,b只能自旋,而此时抢占已经关闭了,a进程就不会调度到了,b进程永远自旋。
b) 进程a拥有自旋锁,中断来了,cpu执行中断,中断处理函数也要获得锁访问共享资源,此时也获得不到锁,只能死锁。
3)自旋锁锁定期间不能调用任何可能引起进程调度的函数。
自旋锁有几个重要的特性:
1、被自旋锁保护的临界区代码执行时不能进入休眠;
2、被自旋锁保护的临界区代码执行时是不能被被其他中断中断;
3、被自旋锁保护的临界区代码执行时,内核不能被抢占。
从这几个特性可以归纳出一个共性:被自旋锁保护的临界区代码执行时,它不能因为任何原因放弃处理器。
二) 读写自旋锁
自旋锁不关心锁定的临界区究竟进行怎样的操作,不管是读还是写,它都一视同仁。即多个执行单元同时读取临界资源也会被锁住。读写自旋锁(rwlock)是自旋锁的衍生出来的、它允许读的并发操作。
读写自旋锁是一种比自旋锁粒度(保护范围)更小的锁机制,它保留了“自旋”的概念。在读写操作时,允许多个读执行单元;写操作最多有一个写进程,且读和写不能同时进行
自旋锁与读写自旋锁的对比:
操作 | 自旋锁(spin lock) | 读写自旋锁(rwlock) |
读 | 互斥 | 不互斥 |
写 | 互斥 | 互斥 |
读+写 | 互斥 | 互斥 |
读写自旋锁涉及的操作
1、定义和初始化读写自旋锁:
rwlock_t my_rwlock = RW_LOCK_UNLOCKED; /* 静态初始化 */ rwlock_t my_rwlock; void rwlock_init(rwlock_t *lock); /* 动态初始化 */
2、读锁定
void read_lock(rwlock_t *lock) int read_trylock(rwlock_t *lock)
3、读解锁
void read_unlock(rwlock_t *lock)
在对共享资源进行读取之前,应该先调用读解锁定函数,完成之后应调用读解锁函数。
4、写锁定
void write_lock(rwlock_t *lock) int write_trylock(rwlock_t *lock)
5、写解锁
void write_unlock(rwlock_t *lock)
在对共享资源进行写之前,应该先调用写解锁定函数,完成之后应调用写解锁函数。
信号量的使用
信号量(semaphore)是用于保护临界区的一种常用方法,它的使用方式和自旋锁类似,只有得到信号量的进程才能执行临界区代码。但也与自旋锁有不同之处,对于获取不到信号量的执行序列将会进入休眠状态而不是原地打转。
信号量相关操作:
1、定义信号量
struct semaphore { spinlock_t lock; /* 用来对count变量起保护作用 */ unsigned int count; /* 大于0,资源空闲;等于0,资源忙,但没有进程等待这个保护的资源;小于0,资源不可用,并至少有一个进程等待资源 */ struct list_head wait_list; /* 存放等待队列链表的地址,当前等待资源的所有睡眠进程都会放在这个链表中 */ }; struct semaphore sem; /* 定义一个名为 sem 的信号量 */
2、初始化信号量
void sema_init(struct semaphore *sem, int val); /* 初始化信号量,并将信号量的值设置为 val */
3、获得信号量
void down(struct semaphore *sem); /* P操作(减),当P操作操作的信号量为0,则休眠等(不允许被信号打断) 不能在中断上下文使用 */ void down_interruptible(struct semaphore *sem); /* 允许在睡眠状态被信号打断 */ void down_trylock(struct semaphore *sem); /* 尝试获取信号量,如果能立刻获得,获得该信号量并返回 0,否者返回非 0 值,不会导致睡眠,可在中断上下文使用 */
4、释放信号量
void up(struct semaphore *sem); /* 释放信号量 sem,唤醒等待者 */
信号量一般这样使用
struct semaphore sem; /* 定义信号量 */
down(&sem); /* 获取信号量,保护临界区 */ ... critical section /*临界区*/ ... up(&sem); /* 释放信号量 */
使用实例:
static struct semaphore sem; /* 定义信号量 */ sema_init(&sem,1); /* 初始化为 1 */ static int hello_open (struct inode *inode, struct file *filep) { if(down_interruptible(&sem)) /* p操作,获得信号量,保护临界区 */ { return -ERESTART; /* 已经被占用 */ } return 0; } static int hello_release (struct inode *inode, struct file *filep) { up(&sem); /* v操作,释放信号量 */ return 0; }
互斥体是一种锁机制,相对于自旋锁它实现了休眠。
互斥体的相关操作
1、定义互斥体
类型:struct mutex
2、初始化互斥体
void mutex_init(struct mutex *); /* 初始化互斥体 */
3、销毁互斥体
void mutex_destroy(struct mutex *lock); /* 释放互斥体 */
4、加锁
void mutex_lock(struct mutex *lock); /* 获取互斥体,若已经被获取则休眠等(不允许被中断和信号打断) */ int mutex_lock_interruptible(struct mutex *lock); /* 允许在睡眠状态被中断打断 */ int mutex_lock_killable(struct mutex *lock); /* 允许在休眠过程状态信号打断 */ int mutex_trylock(struct mutex *lock); /* 尝试获取互斥体,如果能立刻获得,获得该互斥体并返回 0,否者返回非 0 值,不会导致睡眠 */
5、解锁
void mutex_unlock(struct mutex *lock); /* 释放 mutex */
mntex的使用方法和信号量用于互斥的场合完全一样:
struct mutex my_mutex; /* 定义 mutex */ mutex_init(&my_mutex); /* 初始化 mutex */ mutex_lock(&my_mutex); /* 获取 mutex */ ... critical section /*临界区*/ ... mutex_unlock(&my_mutex); /* 是否 mutex */
信号量 | 自旋锁 | |
1、开销成本 | 进程上下文切换时间 | 忙等待获得自旋锁时间 |
2、特性 | a -- 导致阻塞,产生睡眠 b -- 进程级的(内核是代表进程来争夺资源的) |
a -- 忙等待,内核抢占关闭 b -- 主要是用于CPU同步的 |
3、应用场合 | 只能运行于进程上下文 | 还可以出现中断上下文 |
4、其他 | 还可以出现在用户进程中 | 只能在内核线程中使用 |
从以上的区别以及本身的定义可以推导出两都分别适应的场合。只考虑内核态
后记:除了上述几种广泛使用的的并发控制机制外,还有中断屏蔽、顺序锁(seqlock)、RCU(Read-Copy-Update)等等,做个简单总结如下图: