Linux 设备驱动中的并发控制

在Linux设备驱动中,必须解决的一个问题就是多个进程对共享资源的并发访问, 并发的访问会导致竞态。

并发与竞态

多个执行单元同时、并行被执行被称为并发;而并发的执行单元对共享资源
(软硬件上的全局变量、静态变量)的访问则很容易导致竞态。
Linux内核下主要的竞态发生于如下的集中情况
1.对称多处理器SMP的多个CPU
SMP的特点是多个CPU使用同样的系统总线,因此可访问共同的外设和存储器
2.单CPU内进程与抢占它的进程
在支持内核抢占调度之后,一个进程在内核执行的时候可能耗完了自己的时间片,
也可能被另一个高优先级的进程打断,这样会出现进程和抢占它的进程之间访问共享资源的情况。
3.中断(硬中断、软中断、Tasklet、底半部)与进程之间
中断可以打断正在执行的进程,如果中断服务程序(哎,得好好再学下操作系统了)访问进程正在访问的资源
则竞态也会发生
中断也可被更高优先级的中断打断(中断嵌套),因此多个中断之间也可能会引起竞态。但是新版本的内核已经取消了中断嵌套,所以这种情况在新版本(2.6.35)之后就再也不会出现了。

解决竞态问题的途径就是对共享资源进行互斥的访问。即当一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。
访问共享资源的代码区域被称为临界区,临界区需要被某种互斥加以保护。

编译乱序和执行乱序

理解Linux内核的锁机制,还需要理解编译器和处理器的特点。
编译乱序:
现代的高性能编译器在目标代码优化上,具有对指令进行乱序优化的能力。
所以打开编译器优化后,汇编代码没有严格按照源代码的逻辑顺序,是正常的。
如何解决这个编译乱序的问题呢,需要通过barrier()编译屏障进行。
这个屏障可以阻挡编译器的优化
执行乱序:
即使编译乱序没有发生,在处理器执行代码的时候,后面的代码还是可能先执行。
处理器为了解决该问题引入了一些内存屏障指令
DMB(数据内存屏障):
DSB(数据同步屏障):
ISB(指令同步屏障):
Linux内核的自旋锁,互斥体等互斥逻辑,需要用到上述的指令:
在请求获得锁时,调用屏障指令
在解锁的时候,调用屏障指令
这个问题现在暂时不研究,等后续深入研究下

中断屏蔽

在单CPU范围内避免竞态的一种简单有效的方法是在进入临界区之前屏蔽系统的中断,
但是在驱动编程中不值得推荐,因为在中断屏蔽期间,别的中断都无法得到处理,如果长时间中断的话,
数据丢失、系统崩溃都会的。
local_irp_disable()  //屏蔽中断
critical_section    //临界区
local_irq_enable()    //开中断

原子操作

原子操作可以保证对一个整形数据的修改是排他性的。Linux提供了一系列函数来实现内核
中的原子操作
整形原子操作
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)  //原子变量v增加i
    void atomic_sub(int i, atomic_t *v)  //原子变量减i
4. 原子变量的自增/自减
    。。。
位原子操作
1. 设置位
    void set_bit(nr, void*addr)
    上述操作设置addr地址的第nr位,所谓的设置位即是将位写为1
2. 清除位
    void clear_bit(nr, void *addr)
    上述操作设置addr的第nr位为0
。。。。

自旋锁

自旋锁的使用
自旋锁是一种典型的对临界资源进行互斥访问的手段,其名称来源于它的工作方式。
如何理解自旋锁:将其作为标记来看带,标记的是一个临界区是否正在运行。
当一个执行单元正在执行临界区代码时,该执行单元持有自旋锁;如果此时有别的
执行单元想进入临界区,必须等待该执行单元释放自旋锁之后才行。
自旋锁的相关操作函数:
1.定义自旋锁
spinlock_t lock;
2.初始化自旋锁
spin_lock_init(lock)
3.获得自旋锁
spin_lock(lock)
该宏用于获得自旋锁,如果能够理解获得,则立即返回
如果不能获得,则不停的查询直到自旋锁的持有者释放它
spin_trylock(lock)
该宏尝试获得自旋锁lock,如果能立即获得锁,它立刻返回true
如果不能获得,它立刻返回false
4.释放自旋锁
spin_unlock(lock)
让宏释放自旋锁lock,与获得自旋锁函数配对使用

尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,
但是得到自选锁的代码在执行临界区的时候仍然会受到中断和底半部的影响。
所以自旋锁和中断、底半部的结合形成了整套的自旋锁机制
spin_lock_irq() = spin_lock + local_irq_disable()
spin_unlock_irq() = spin_unlock + local_irq_enable()
...等等
这些衍生的函数会帮助自旋锁在使用的时候不被中断和底半部打扰
使用自旋锁的一些问题:
1. 自旋锁的本质其实是忙等待,只有在占用锁的事件较短,使用自旋锁才是合理的
2. 自旋锁可能会导致死锁,最常见的原因是递归使用这个自旋锁,
当一个执行单元已经获得了自旋锁,想第二次获得这个自旋锁,就会死锁
3. 在自旋锁锁定期间,不能调用可能引起进程调度的函数。因为自旋锁为的就是不让别的进程抢占
,现在你内部又要进行进程调度,这样内核会崩溃
4. 在单核下编程的时候,也应该认为自己的CPU是多核的,驱动需要能跨平台
在单CPU的情况下使用spin_lock_irqsave()可以屏蔽一个核的中断和抢占进程,但是
多核的情况下无法屏蔽另一个核的中断,此时就可能出现并发问题
所以在中断服务程序中,我们也是需要调用spin_lock的。
自旋锁使用实例:使设备只能被一个自旋锁打开

int xxx_count = 0; //定义文件打开次数
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);
    ...
    return 0;
}

static int xxx_release()
{
    spin_lock(&xxx_lock);
    xxx_count--; //减少计数
    spin_unlock(&xxx_lock);

    return 0;
}
读写自旋锁
自旋锁不关心锁定的临界区在进行什么操作,所以在多个执行单元在同时读取临界资源的时候也会被锁住。
于是有了自旋锁的衍生,读写自旋锁(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);
顺序锁
顺序锁是对读写锁的一种优化,若使用顺序锁,读执行单元不会被写执行单元阻塞,不必等待写
执行单元完成写操作,写执行单元也不用等待所有读执行单元完成操作才去进行写操作。
这种情况就会发生:
在读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新读取数据,
这样读执行单元可能需要多次重新读取数据,才会督导完整有效的数据。
RCU(read-copy-update)
不同于自旋锁,使用RCU的读端没有锁、内存屏障、原子指令类的开销,几乎可以认为是直接读。
(只是简单的标明读开始和读结束)
而RCU的写执行单元在访问它的共享资源的时候,首先复制一个副本,对副本进行相应的修改,
最后使用一个回调的机制在适当的时机将数据的指针重新指向新的被修改的数据,这个时机就是
所有引用该数据的CPU都退出对共享数据的读操作的时候。等待适当时机的这个时间段称为宽限期。
e.g:
假设原有一个N节点,其中有ab两个元素。RCU的思路就是先搞一个N的复制M节点,在M上修改ab,并用
M代替N的节点。等到对N的所有读操作都结束之后,释放N元素。
这样的话,读进程读写的数据N在读完之前,是没有被修改的。
相比于读写锁:优点是:允许多个读执行单元和多个写执行单元同时访问被保护的数据。
缺点就是数据的复制以及延迟数据的释放
RCU的操作:
1.读锁定
rcu_read_lock()
rcu_read_lock_bh()
2.读解锁
rcu_read_unlock()
rcu_read_unlock_bh()
使用RCU进行读的模式如下:
rcu_read_lock();
...临界区
rcu_read_unlock()
3.同步RCU(等待所有的读操作完成临界区)
synchronize_rcu()
该函数由RCU写执行单元调用,它将阻塞写执行单元,直到当前CPU上所有的已经
存在的读执行单元完成读临界区,写执行单元才可以继续下一步操作。
4.挂接回调
void call_rcu(struct rcu_head *head, void (*func) (struct rcu_head *rcu));
该函数也是由RCU写执行单元调用,与synchronize_rcu不同的是,它不会使写执行单元阻塞,因而可以
在中断上下文和软中断中使用。该函数把函数func挂接到RCU回调函数链上,然后立即返回。挂接的回调函数
会在一个宽限期结束(即所有的已经存在RCU读临界区完成)后被执行
。。。这个东西实在太jb复杂了,后面有时间再细细研究
信号量
信号量是操作系统中最典型的用于同步和互斥的手段,信号量的值可以是0,1或n。信号量
与操作系统中的经典概念PV操作对应
P(S):将信号量的值减一
       如果S >= 0,则该进程继续执行;否则该进程置为等待状态,排入等待队列。
V (S):将信号量的值加1
        如果S>0,唤醒队列中等待信号量的进程
信号量相关的操作如下所示:
1.定义信号量
struct semaphore sem;
2.初始化信号量
void sema_init(struct semaphore *sem, int val)
3.获得信号量
void down(struct semaphore *sem)
该函数用于获得信号量sem,它会导致睡眠,因此不能在中断上下文中使用
int down_interruptible(struct semaphore *sem)
该函数与down函数功能类似,不同的是该函数导致进程睡眠之后是可以被信号打断的,与上函数
相反。如果是被信号打断也会返回,不过返回值不是0
int down_trylock(struct semaphore *sem)
该函数尝试获得信号量sem,如果能获得,它就获得该信号量并返回0,否则返回非0值。它不会导致
调用者睡眠,可以在中断上下文中使用
4.释放信号量
void up(struct semaphore *sem)
该函数释放信号量sem,唤醒等待者
信号量与自旋锁类似,只有得到信号量的才能执行临界区代码。
与自旋锁不同的是:如果得不到信号量,进程不会原地打转而是进入到睡眠状态
互斥体
尽管信号量已经可以实现互斥的功能但是正宗的mutex在Linux内核中也是存在的
下面是mutex(互斥体)操作
//定义以及初始化
struct mutex my_mutex;
mutex_init(my_mutex);
//获取互斥体
void mutex_lock(struct mutex*lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_trylock(struct mutex *lock);

mutex_lock()与mutex_lock_interruptible区别在于:
后者引起的进程睡眠是可以被打断的,这一点与down 和 down_interruptible
区别是一样的

//释放互斥体
void mutex_unlock(struct mutex *lock);
mutex和semaphore的使用场合完全一样
互斥体与自旋锁
互斥体的实现依赖于自旋锁,因为为了保证互斥体的原子性,需要自旋锁来互斥。所以自旋锁
属于更加底层的手段。互斥体是进程级别的,当进程占用的资源的事件比较长的时候,用互斥
体更为好
下面是互斥体和自旋锁选用的三大原则(一个通用情况,2个特殊情况)
1.当锁不能被获取到的时候,使用互斥体的开销是进程上下文的切换时间,使用自旋锁是等待
获取自旋锁的时间(临界区的执行时间决定)。看哪个时间少,就切换到哪个时间去。
2.互斥体所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来这样的代码。
因为如果阻塞,代表着进程会睡眠,别的进程会工作。如果此时这个进程正好要获取本自旋锁,
那么就会出现死锁。
3.互斥体存在于进程上下文,因此,如果被保护的共享资源需要在中断或者软中断的情况下使
用,则在互斥体和自旋锁之间只能选择自旋锁。如果一定要用互斥体,则可以通过mutex_trylock()
方式进行,不能获取就立即返回避免阻塞
为啥会阻塞:因为在使用互斥体之后,该共享资源被保护不允许别的进程获取,此时如果有中断来获取该共享资源
会被阻塞。
完成量
Linux提供了完成量(Completion),用于一个执行单元等待另外一个执行单元来执行完某件事情
1.定义完成量
struct completion my_completion;
2.初始化完成量
init_completion(&my_completion)
reinit_completion(&my_completion) //重新初始化完成量为0(未完成的状态)
3.等待完成量
void wait_for_completion(struct completion *c);
4.唤醒完成量
void complete(struct completion *c);
void complete_all(struct completion *c);

总结

并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁、互斥体都可以解决这些问题
中断屏蔽很少单独使用,原子操作也只是针对整数进行,所以正真常用的是自旋锁
和互斥体。
自旋锁会导致死循环,锁定临界区之中不允许阻塞,所以临界区尽量小;
而互斥体允许临界区阻塞,实用与临界区大的情况,但是不适用于临界区中资源
在中断的时候也会被访问的情况。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值