Linux设备驱动中的并发控制

并发与竞态

并发指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态。只要并发的多个执行单元存在对共享资源的访问,竞态就可能发生。
在Linux内核中,主要的竞态发生在如下3种情况。

1. 对称多处理器(SMP)的多个CPU

SMP是一种紧耦合、共享存储的系统模型,特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。
SMP体系结构
在SMP情况下,多个核的竞态可能发生在不同CPU之间的进程和中断之间,情况复杂。

2. 单CPU内进程之间的抢占

Linux内核支持抢占调度,一个进程在耗完自己的时间片或被另一高优先级进程打断时,如果另一进程访问了共享资源,则很容易出现竞态。

3. 中断(软硬中断、Tasklet、底半部)与进程之间

中断可以打断正在执行的进程,如果中断服务程序访问进程正在访问的资源,则竞态也会发生。
由于Linux最新的内核版本已经不支持中断嵌套,因此不讨论中断被中断打断导致的竞态。

解决竞态问题的方法:保证对共享资源的互斥访问。
临界区:访问共享资源的代码区域,需要被某种互斥机制加以保护。

Linux设备驱动中可采用的互斥机制有中断屏蔽、原子操作、自旋锁、信号量、互斥体、Completion等。

编译乱序和执行乱序

为了更好的理解Linux内核的锁机制,还需要理解编译器和处理器的特点。造成程序出错的两个可能原因分别是:编译乱序和执行乱序。
编译乱序是编译器在对代码编译时进行乱序优化,减少逻辑上不必要的访存来提高效率的行为,但有时候也会因此导致运行时的结果与期望结果不一致的情况。解决编译乱序问题,需要通过barrier( )编译屏障进行,该屏障可保证屏障前后的语句不交换编译
执行乱序是处理器运行时为提高缓存命中率而重新排序的行为。对于单核系统,这种乱序是不可见的,但有时候存寄存器的书写顺序是固定的。而在SMP处理器上,一个核的访问内存行为对另一个核是不可见的,容易导致异常。为了解决单核内存操作时序的确定性和多核间内存操作的安全性,引入了内存屏障指令。Arm处理器的屏障指令包括:

  • DMB(数据内存屏障):在DMB之后的显式内存访问执行前,保证所有在DMB指令之前的内存访问完成
  • DSB(数据同步屏障):等待所有在DSB指令之前的指令完成
  • ISB(指令同步屏障):Flush流水线,使得所有ISB之后执行的指令都是从缓存或内存中获得的

linux内核的自旋锁、互斥体等互斥逻辑,都需要用到上述指令:在请求获取锁和解锁时,调用屏障指令。
在Linux内核中,定义了读写屏障mb( )、读屏障rmb()、写屏障wmb( )、以及作用于寄存器读写的__iormb()、iowmb()这样的屏障API。有屏障的读写寄存器API有:readb()/readw()/readl()、writeb()/writew()/writel();而无屏障的有:readb/w/l_relaxed()、writeb/w/l_relaxed()。

中断屏蔽

单CPU避免竞态的简单有效方法就是在进入临界区之前屏蔽系统的中断。由于Linux内核的进程调度等操作都依赖中断来实现,因此该方式既屏蔽了中断与进程间的竞态,又屏蔽了进程之间的竞态。该方式不能解决SMP多CPU引发的竞态,且要求临界区尽快执行完成。
常用的API有:

local_irq_disable()/local_irq_enable() :屏蔽、使能本CPU中断
local_irq_save(flags)/local_irq_restore(flags):屏蔽并保存当前CPU中断信息、使能并恢复当前CPU中断信息。
local_bh_disable()/local_bh_enable():禁止、使能中断底半部

原子操作

原子操作可以保证对一个整型数据的修改是排他性的,Linux Arm底层使用LEDEX和STREX指令配对使用来实现原子操作,适用于单核和多核之间的并发。

整型原子操作

  1. 设置原子变量的值
void atomic_set(atomic_t *v, int i); /* 设置原子变量的值为i */
atomic_t v = ATOMIC_INIT(i);  /* 定义原子变量v并初始化为i */
  1. 获取原子变量的值
int atomic_read(atomic_t *v);   /* 返回原子变量的值 */
  1. 原子变量加/减
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 */
  1. 操作并测试
/* 对原子变量执行自增、自减和减操作后,测试其是否为0,是则返回true,否则返回false */
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);
  1. 操作并返回
/* 对原子变量执行自增、自减和加、减操作并返回新值 */
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);

实例:使用原子变量使设备只能被一个进程打开

static atomic_t xxx_available = ATOMIC_INIT(1);  //定义原子变量
static int xxx_open(struct inode* node, struct file* filp)
{
	...
	if (!atomic_dec_and_test(&xxx_available)) {
		atomic_inc(&xxx_available);		//已被打开
		return -EBUSY;
	}
	...
	return 0;
}
static int xxx_release(struct inode* node, struct file* filp)
{
	atomic_inc(&xxx_available);		//释放设备
	return 0;
}

位原子操作

  1. 设置位
void set_bit(nr, void *addr); /* 设置addr地址的第nr位为1 */
  1. 清除位
void clear_bit(nr, void *addr); /* 清除addr地址的第nr位为0 */
  1. 改变位
void change_bit(nr, void *addr); /* 对addr地址的第nr位进行取反 */
  1. 测试位
int test_bit(nr, void *addr); /* 获取addr地址的第nr位的值 */
  1. 测试并操作位
/* test_and_xxx_bit() ==> test_bit() + xxx_bit(),返回值为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);

自旋锁

自旋锁机制及使用

Arm处理器使用了ldrex、strex、dmb、dsb、wfe、sev指令,实现了Linux内核的自旋锁机制。自旋锁通常实现为某个整数值中的单个位,只有“锁定”和“解锁”两种状态。希望获得某特定锁的代码测试相关的位,如果锁可用,则该位被设置为“锁定”,代码继续进入临界区,退出临界区时将改为设置为“解锁”;如果锁被其他线程获得,则代码进入忙循环并重复检查这个锁(测试并设置的原子操作),直到该所锁可用为止。这个循环就是自旋锁的“自旋”部分。
自旋锁主要针对SMP或者单CPU可抢占内核的情况。在单CPU内核可抢占系统中,自旋锁持有期间内核的抢占被禁止;在多核SMP系统中,拿到自旋锁的核,其抢占调度也被禁止,但另外的核仍可以抢占调度。纯粹的自旋锁仅能解决进程间的竞态问题,仍有可能受到中断和底半部的影响,因此自旋锁结合中断和底半部的开关,即可解决进程与中断和底半部之间的竞态问题。
相关的API及其描述如下:

  1. 定义并初始化自旋锁
spinlock_t lock = SPIN_LOCK_UNLOCKED; //定义并静态初始化自旋锁
//动态初始化自旋锁
void spin_lock_init(spinlock_t *lock);
  1. 获得自旋锁
/* 用于获取自旋锁lock,如果能够立即获得锁,直接返回,否则,它将自旋,直到该自旋锁的持有者释放 */
void spin_lock(spinlock_t *lock);  
void spin_lock_irq(spinlock_t *lock); //==> spin_lock() + local_irq_disable()
spin_lock_irqsave(spinlock_t *lock, unsigned long flags); //==> spin_lock() + local_irq_save()
void spin_lock_bh(spinlock_t *lock); //⇒ spin_lock() + local_bh_disable()
/* 尝试获取自旋锁lock,如果能够立即获得锁,它获得锁并返回true,否则,立即返回false,不自旋 */
int spin_trylock(spinlock_t *lock);
int spin_trylock_bh(spinlock_t *lock); //⇒ spin_try_lock() + local_bh_disable()
  1. 释放自旋锁
/* 释放自旋锁lock,与spin_trylock或spin_lock配对使用 */
void spin_unlock(spinlock_t *lock);  
void spin_unlock_irq(spinlock_t *lock); //==> spin_unlock() + local_irq_enable()
spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags); //==> spin_unlock() + local_irq_restore()
void spin_unlock_bh(spinlock_t *lock); //⇒ spin_unlock() + local_bh_enable()

一般地,在进程上下文中调用spin_lock_irqsave()/spin_unlock_irqrestore(),在中断上下文中调用spin_lock()/spin_unlock()。

使用自旋锁应注意:
(1)只有在占用锁的时间极短的情况下,使用自旋锁才合理,否则会降低系统性能。
(2)禁止在递归中使用一个自旋锁,否则可能导致死锁。
(3)在自旋锁锁定期间不能调用可能引起进程调度的函数。
(4)如果中断和进程中有对同一内存的访问,在进程和中断中都需要使用自旋锁,以适配SMP情况。

实例:使用自旋锁使设备只能被一个进程打开

static int count = 0;
static spinlock_t xxx_lock;
static int xxx_open(struct inode *node, struct file *filp)
{
	...
	spin_lock_irqsave(&xxx_lock);
	if(xxx_count) {
		spin_unlock_irqrestore(&xxx_lock);
		return -EBUSY;
	}
	count ++;
	spin_unlock_irqrestore(&xxx_lock);
	...
	return 0;
}
static int xxx_release(struct inode *node, struct file *filp)
{
	...
	spin_lock_irqsave(&xxx_lock);
	count --;
	spin_unlock_irqrestore(&xxx_lock);
	...
	return 0;
}
static int __init xxx_init(void) 
{
	...
	spin_lock_init(&xxx_lock);
	...
	return 0;
}

读写自旋锁

在实际应用中,对共享资源的读取总是允许同时进行的,而写操作只能允许一个进程进行。为此,由自旋锁衍生出了读写自旋锁,它允许读操作的并发,而只能有一个写进程,且读和写不能同时进行。
相关的API及其描述如下:

  1. 定义和初始化读写自旋锁
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);   /* 动态初始化 */
  1. 读锁定
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
  1. 读解锁
void read_unlock(rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);

对共享资源读取时,应先调用读锁定,完成后调用读解锁。
4. 写锁定

void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
  1. 写解锁
void write_unlock(rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);

对共享资源写入时,应先调用写锁定,完成后调用写解锁。

读写自旋锁一般使用情况:

rwlock_t lock;			/*定义读写锁*/
rwlock_init(&lock);		/*初始化读写锁*/
unsigned long flags;	/*中断标志保存*/

/*读时获取锁*/
read_lock(&lock);
...						/*临界资源*/
read_unlock(&lock);

/*写时获得锁*/
write_lock_irqsave(&lock, flags);
...						/*临界资源*/
write_unlock_irqrestore(&lock, flags);

顺序锁

顺序锁优化了读写锁存在的一个问题:读写操作不应该互相阻塞。解决这个问题,就需要读取操作在写入操作发生之后,重新读取数据。因此,读端可能反复读取同样的区域才能得到有效数据。
相关的API及描述如下:

  1. 获得写顺序锁
void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
void write_seqlock_irqsave(seqlock_t *sl, unsigned long flags);
void write_seqlock_irq(seqlock_t *sl);
void write_seqlock_bh(seqlock_t *sl);
  1. 释放写顺序锁
void write_sequnlock(seqlock_t *sl);
void write_sequnlock_irqrestore(seqlock_t *sl, unsigned long flags);
void write_sequnlock_irq(seqlock_t *sl);
void write_sequnlock_bh(seqlock_t *sl);
  1. 读取开始
/* 在对顺序锁保护的共享资源进行访问前调用该函数,返回值为顺序锁的当前顺序号 */
unsigned read_seqbegin(const seqlock_t *sl);
unsigned read_seqbegin_irqsave(const seqlock_t *sl, unsigned long flags);
  1. 重新读取
/* 在对顺序锁保护的共享资源进行访问后调用该函数来检测读访问期间是否有写操作,
** 如果有写操作,需要重新读操作 
*/
int read_seqretry(const seqlock_t *s1, unsigned iv);
int read_seqretry_irqrestore(const seqlock_t *s1, unsigned iv, unsigned long flags);

顺序锁一般使用情况:

/*定义并初始化顺序锁*/
seqlock_t sl;
seqlock_init(&sl);
int seqnum;

/*写操作时使用顺序锁保护临界资源*/
write_seqlock(&sl);
...		/*临界资源*/
write_sequnlock(&sl);

/*读操作时使用顺序锁检测期间是否有读操作*/
do {
	seqnum = read_seqbegin(&sl);
	...		/*读操作代码*/
} while (read_seqretry(&sl, seqnum));

读-复制-更新(RCU)

读取-复制-更新(Read-Copy-Update,RCU)是一种高级的互斥机制,它针对经常发生读取而很少写入的情形做了优化,可看成读写锁的高性能版本,但不能代替读写锁。优点是既允许多个读执行单元同时访问被保护的数据,又允许多个读执行单元和多个写执行单元同时访问被保护的数据,但仍需使用某种锁机制来同步并发的其他写执行单元的修改操作。
不同于自旋锁,使用RCU的读端没有锁、内存屏障、原子指令的开销,而RCU的写端在(通过指针)访问共享资源前先复制一个副本,然后对副本进行修改,最后使用回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,这个时机就是所有引用该数据的CPU都退出对共享数据读操作的时候。等待适当时机的这一时期称为宽限期。
相关的API和描述如下:

  1. 读锁定
rcu_read_lock();
rcu_read_lock_bh();
  1. 读解锁
rcu_read_unlock();
rcu_read_unlock_bh();
  1. 同步RCU
/* 由RCU写执行单元调用,它将阻塞写执行单元,
** 直到当前CPU上所有的已存在的读执行单元完成读临界区,
** 写执行单元才可以继续下一步操作。 */
synchronize_rcu();
  1. 挂接回调
/* 由RCU写执行单元调用,但不会阻塞写执行单元,可用于中断上下文或软中断。
** 该函数把函数func挂接到RCU回调函数链上,然后立即返回。
** 回调时间同synchronize_rcu()
*/
void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *rcu));
  1. 写端发布
/* 在写端给RCU保护的指针赋新值 */
rcu_assign_pointer(void *point, void *val);
  1. 读端订阅
/* 在读端读取RCU保护的指针值 */
void *rcu_dereference(void *point);

RCU一般使用示例:

struct foo {
	int a;
	int b;
	int c;
};
struct foo *gp = NULL;

/* 写端操作*/
struct foo *p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
rcu_assign_pointer(gp, p); /* rcu发布新数据 */

/* 读端操作 */
rcu_read_lock();
struct foo *p = rcu_dereference(gp); /* rcu订阅数据 */
if(!p) {
	do_something_with(p);
}
rcu_read_unlock();

信号量

信号量(semaphore)是操作系统中用于同步和互斥的手段,信号量的值可以是0,1或者n。信号量与操作系统中的经典概念PV操作对应。

P(S): ① 将信号量S的值减1,即S = S - 1; ② 如果S≥0,则该进程继续执行;否则该进程为等待状态,排入等待队列
V(S): ① 将信号量S的值加1,即S = S + 1; ② 如果S>0,唤醒队列中等待信号量的进程。

信号量相关的API如下:

  1. 定义信号量
struct semaphore sem;
  1. 初始化信号量
/* 初始化信号量,并设置信号量sem的值为val */
void sema_init(struct semaphore *sem, int val);
  1. 获得信号量
/* 获得信号量,会导致休眠,不能用于中断上下文,不能被信号打断 */
void down(struct semaphore *sem);
/* 获得信号量,会导致休眠,不能用于中断上下文,可被信号打断,被打断时返回0 */
int down_interruptible(struct semaphore *sem);
/* 尝试获得信号量,如果能立刻获得,则获得信号量并返回0,否则返回非0,。不会导致休眠,可用于中断上下文 */
int down_trylock(struct semaphore *sem);
  1. 释放信号量
/* 唤醒等待者 */
void up(struct semaphore *sem);

作为一种互斥手段,信号量可以保护临界区,使用方式类似于自旋锁。但与自旋锁不同的是,当获取不到信号量时,进程不会原地打转,而是进入休眠等待状态。

互斥体

互斥体可以看作是信号量值为1时的情况,使用场合和信号量完全一样。
自旋锁和互斥体都是解决互斥问题的基本手段。但严格来讲,互斥体和自旋锁属于不用层次的互斥手段,前者的实现依赖于后者。互斥体本身的实现需要保证互斥体结构存取的原子性,需要自旋锁来互斥,所以自旋锁属于更底层的手段。
互斥体是进程级的,用于多个进程之间对资源的互斥,在内核中代表进程来争夺资源。如果竞争失败,会发生进程上下文切换,当前进程进入睡眠状态,CPU将运行其他进程。鉴于进程上下文切换开销大,因此,只有当进程占用资源时间较长时,用互斥体才是较好选择。
相关的API有:

  1. 定义并初始化互斥体
struct mutex my_mutex;
mutex_init(&my_mutex);
  1. 互斥体上锁
void mutex_lock(struct mutex *lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_trylock(struct mutex *lock);
  1. 互斥体解锁
void mutex_unlock(struct mutex *lock);

自旋锁和互斥体的选用原则:

  1. 若临界区较小时,宜使用自旋锁;若临界区较大,宜使用互斥体。
  2. 若临界区内有引起阻塞的代码,则使用互斥体,绝不能使用自旋锁,以免发生死锁。
  3. 在中断或软中断中保护共享资源,只能选择自旋锁或者使用互斥体的mutex_trylock()方式,避免阻塞。

Completion

Completion用于一个执行单元等待另一个执行单元执行完某事。
相关的API有:

  1. 定义completion
struct completion my_completion;
  1. 初始化completion
init_completion(&my_completion);
reinit_completion(&my_completion);
  1. 等待completion
void wait_for_completion(struct completion *c);
  1. 唤醒completion
/* 唤醒一个等待的执行单元 */
void complete(struct completion *c);
/* 唤醒所有等待的执行单元 */
void complete_all(struct completion *c);

总结

linux内核并发控制总结
以上内容,如有问题,请大佬指出!

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值