互斥与同步
一、概述
- 如果内核中有多条路径都要访问同一个资源,那么可能会导致数据的相互覆盖,并造成紊乱。例如,我们在驱动代码中定义了一个全局变量i,驱动中某个例程中执行了i++操作,而在中断服务程序中也执行了i++操作,在这种情形下我们来分析一下可能造成的数据紊乱情况。i++在ARM汇编中展开如下:
ldr r1,[r0]
add r1,r1,#1
str r1,[r0]
假设变量i所存放的值是放在r0所指向的内存中,也就是说,r0寄存器中保存了变量i的地址,并且变量i的初值为5。假设当我们执行到第一条会汇编指令的时候,也就是从r0所指向的地址的内存中取值放入r1。产生了一个硬件中断,这个时候程序需要去处理中断处理程序,外部产生了一个硬件中断,此时将保存此时的程序执行状态,转而去执行中断处理程序。不巧的是,中断处理程序也要对这个全局变量进行自增操作,自增完后,i的值就变成6了。执行完中断处理程序,我们会进行恢复现场的操作,此时r1寄存器的值会被恢复成5,因为在跳转之前我们保存了它的值嘛,所以此时恢复后,它的值仍是5。此时你就会发现一个很奇怪的现象,我们明明对其进行了自增操作,为什么它的值还是5呢。这个时候就会产生紊乱。这种事情虽然不常发生,但是一旦发生了,就很难去查明原因。即使你找到了原因,最后肯定又会掉落一头秀发。那么有没有一种比较好的机制来预防这种情况的发生呢?我们接着往下看。
二、内核中的并发
- 对前面的情况做一个概述,当内核中有多条执行路径同时访问同一个共享资源时,就会造成竞态。
- 常见的共享资源有:全局变量、静态变量、硬件的寄存器和共同使用的动态分配的同一段内存等。
- 造成竞态的根本原因就是内核中的代码对共享资源发生了并发(同时)的访问。
内核中常见一下这些并发的情况:
- 硬件中断—当处理器允许中中断的时候,一个内核执行路径可能在任何一个时候都能被一个外部中断打断。
- 软中断和tasklet—上篇博文提到过,内核可以在任意硬中断快要返回之前执行软中断及tasklet,也有可能唤醒软中断线程,并执行tasklet。
- 抢占内核的多线程环境—如果一个进程在执行时发生系统调用,进入到内核,由内核代替该进程完成相应的操作,此时如果有一个更高优先级的进程准备就绪,内核判断在可抢占的条件成立的情况下可以抢占当前进程,然后去执行更高优先级的进程。
- 普通的多进程环境—当一个进程因为等待的资源暂时不可用时,就会主动放弃CPU,内核汇调度另外一个进程来执行。
- 多处理器或多核CPU—可以在多个处理器上并发执行多个程序,这就是真正意义上的并发。
并发对共享资源的访问就会引起竞态,解决竞态的一个方法就是互斥,也就是对共享资源的串行化访问,即在一条内核执行路径上访问共享资源时,不允许其他内核执行路径来访问共享资源。
共享资源有时候又叫临界资源,而访问共享资源的这段代码又叫做临界代码或临界区。
三、内核中的互斥
1.中断屏蔽
前面提到的造成竞态的一个原因就是硬件中断。如果我们在访问临界资源的前将中断屏蔽掉,是不是就可以避免这种情况的发生了。
一般我们只是屏蔽本地CPU的中断而不是全局CPU的中断,这样其他中断就可以照常执行。
屏蔽本地中断,我们一般使用local_irq_save和local_irq_restore这对宏,因为如果使用local_irq_disable和local_irq_restore这对宏,如果中断在屏蔽之前本身就是屏蔽的,那么local_irq_enable会将本来就屏蔽的中断错误的使能,从造成中断使能状态前后不一致的现象。
同时中断屏蔽到中断重新使能之间的这段代码不宜过长,如果过长就会影响的系统的性能,即我们无法及时响应中断请求。
在文章开头,我们提到的i++的例子中,我们可以在i++之前屏蔽中断,在i++之后重新使能中断,代码的形式如下:
unsigned long flags;
local_irq_save(flags);
i++;
local_irq_restore(flags);
-
总结
利用中断屏蔽来做互斥时,我们一般需要注意一下几点:
- 对解决中断引起的并发而带来的竞态简单而高效。
- 应该尽量使用local_irq_save和local_irq_restore来屏蔽和使能中断。
- 中断屏蔽的时间不宜过长
- 执行屏蔽本地CPU的中断,对多CPU系统,众怒单也可能会在其他CPU上产生。
2.原子变量
-
原子性:即不能再分割
-
如我们对一个变量的操作是原子性的,即不能子在分割的,也就类似于在汇编上也只要一条汇编指令就能完成,那么对这样的变量的访问就根本不需要考虑并发带来的影响。
-
内核专门为我们提供一种数据类型atomic_t,用它来定义变量为原子变量。
/*inlcude/linux/types.h*/ typedef struct{ int counter; }atomic_t;从其定义可知原子变量其实是一个整型变量。对于整型变量,有的处理器专门提供一些指令来实现原子操作(比如ARM处理器中的SWP指令),内核就会使用这些指令来对原子变量进行访问,但是有些处理器不具备这样的指令,内核将会使用这些指令来对原子变量进行访问,但是有些处理器不具备这样的指令。内核会使用其他的手段来保护对它的原子性操作,比如中断屏蔽。听起来很麻烦,所幸内核已经为我们提供了一系列API结构,下面只罗列常见的几个。
atomic_read(v); //读取原子变量的值 atomic_set(v,i); //设置原子变量的值为i int atomic_add_return(int i,atomic_t *v); //将原子变量加上i后返回 int atomic_sub_return(int i,atomic_t *v); //将原子变量减去i后返回 ... -
需要注意的一点是,虽然原子变量的本质是一个整型变量,对于非整型变量就不能使用这一套方法来操作,而需要使用另外的方法。一般在能够使用原子变量的时候,尽可能的使用原子变量,而不使用复杂的锁机制。因为原子变量的开销更小。
3. 自旋锁
- 在访问共享资源之前,首先要获得自旋锁,访问完资源后解锁。其他内核执行路径如果没有竞争到锁,只能忙等待,所以自旋锁是一种忙等锁。
- 自旋锁的类型低spinlock_t,相关的API如下。
spin_lock_init(lock); //初始化自旋锁,使用自旋锁之前必须要初始化
void spin_lock(spinlock_t *lock); //获取自选锁,如果不能获得自旋锁,则进行忙等待
void spin_lock_irq(spinlock_t *lock); //获取自旋锁并禁止中断
spin_lock_irqsave(lock,flags);
//获取自旋锁并竞争中断,保存中断屏蔽状态到flags中。
void spin_lock_bh(spinlock_t *lock);
//获取自旋锁并禁止下半部
int spin_trylock(spinlock_t *lock);
//尝试获取自旋锁,及时不能获取,也立即返回,返回值为0表示成功获得自旋锁,否则表示没有获得自旋锁。下面两个和它的意义相同
int spin_trylock_bh(spinlock_t *lock);
int spin_trylock_irq(spinlock_t *lock);
void spin_unlock(spinlock_t *lock);
//释放自旋锁,及时不能获取,也立即返回。返回值为0表示成功获得自旋锁,否则表示没有获得自旋锁。下面三个与其意义相同。
void spin_unlock_irq(spinlock_t *lock);
void spin_unlock_irqrestore(spinlock_t *lock,unsigned long flags);
void spin_unlock_bh(spinlock_t *lock);
- 利用自旋锁的机制对我么开篇所提到的i++进行互斥操作。
int i = 5;
/*定义自旋锁*/
spinlock_t lock;
/*用于保存中断屏蔽状态的变量*/
unsigned long flags;
/*使用自旋锁之前必须初始化自旋锁*/
spin_lock_init(&lock);
/*访问共享资源之前获得自旋锁,禁止中断,并将之前的中断屏蔽状态保存在flags变量中*/
spin_lock_irqsave(&lock,flags);
/*访问共享资源*/
i++;
/*共享资源访问完成后释放自旋锁,用flags的值恢复中断屏蔽状态*/
spin_unlock_irqrestore(&lock,flags);
- 从上面的例子可以看到,自旋锁的使用还是比较直观的,基本的步骤是定义锁、初始化锁、访问共享资源之前获得锁、访问完成之后释放锁。
关于自旋锁的一些注意事项如下:
- 获得自旋锁的临界代码段执行时间不宜过长,因为是忙等锁,如果临界代码段执行时间过长,就意味着其他想要获得锁的内核执行路径会进行长时间的忙等待,这会影响系统的工作效率。
- 在获得锁的期间,不能够调用可能会引起进程切换的函数,因为这会增加持锁的时间,导致其他要获取锁的代码进行更长时间的等待,更糟糕的情况是,如果新调度的进程也要获取同样的自旋锁,那么会导致死锁。
- 自旋锁是不可递归的,即获得锁之后不能在获得自旋锁,否则会因为等待一个不能获得的锁而将自己锁死。
- 自旋锁可用于中断上下文中,因为它是忙等锁,所以并不会引起进程的切换。
- 如果中断也要访问共享资源,则在非中断处理代码中访问共享资源之前应该先禁止中断再获取自旋锁,即应该使用spin_lock_irq或spin_lock_irqsave来获得自旋锁。如果不是这样的话,即使得获得了锁中断也会发生,在中断中访问共享资源之前,中断也要获得一个已经被获得的自旋锁,那么中断将会被锁死。中断的下半部也有类似的情况。另外,推荐使用spin_lock_irqsave而不是spin_lock_irq,原因同中断屏蔽中相关的描述。
- 虽然一直都在说自旋锁是忙等待,但是在单处理器的无抢占内核中,单纯的自旋锁(指不是禁止中断,禁止下半部的一些变体)获取操作其实是一个空操作,而在单处理器的可抢占内核中也仅仅是禁止抢占而已(但是这会使高优先级的就绪进程执行时间稍微推后一些)。真正的忙等待的特性只有在多处理器中才会体现出来,不过作为驱动开发者,我们不应该来假设驱动的运行环境,或者说都应该假设成运行在多处理器的可抢占系统上。
4. 读写锁
- 在并发方式中有读-读并发、读-写并发、写-写并发三种。很显然,读-读并发是可以接受的,也就是我们允许两条路径同时去读。因为读并不会修改资源的值。但是自旋锁,在加锁后会禁止读与读之间的并发。为了提高并发的效率,必须要降低锁的粒度,以允许读和读之间的并发。为此,内核提供了一种允许读和读并发锁,叫读写锁,其数据类型为rwlock_t。
- 常用的API如下:
rwlock_init(lock);
read_trylock(lock);
write_trylock(lock);
read_lock_irq(lock);
read_lock_irqsave(lock,flags);
read_lock_bh(lock);
write_lock_irq(lock);
write_lock_irqsave(lock,flags);
write_lock_bh(lock);
read_unlock(lock);
write_unlock(lock);
read_unlock_irq(lock);
read_unlock_irqrestore(lock,flags);
read_unlock_bh(lock);
write_unlock_irq(lock);
write_unlock_irqrestore(lock,flags);
write_unlock_irqrestore(lock,flags);
write_unlock_bh(lock);
- 基本和自旋锁相关的API 差不多,结合字面意思和前面自旋锁提到的API,我们可以很轻松的推断出每个API的意思。这里给出一个例子,还是对开篇提到的例子用自旋锁来改写。
int i = 5;
unsigned long flags;
rwlock_t lock;
/*使用之前先初始化读写锁*/
rwlock_init(lock);
/*要改变变量的值之前获取写锁*/
write_lock_irqsave(&lock,flags);
i++;
write_unlock_ireqrestore(&lock,flags);
int v;
/*只是获取变量的值先获得读锁*/
read_lock_irqsave(&lock,flags);
v = i;
read_unlock_irqrestore(&lock,flags);
- 读写锁的使用也需要经过定义、初始化、加锁和解锁的过程,只是要改变变量的值需要先获取写锁,值改变完成后在解除邪所,读操作则用读锁。但是,当写正在进行的时候,不论是写锁还是读锁都不能获得,只有当写锁释放了之后才可以。
5. 顺序锁
- 自旋锁不允许读与读之间的并发,读写锁则更进一步,允许读与读之间的并发,顺序所则又进了一步允许读和写之间并发。
- 顺序锁在读时不上锁,也就意味着在读的期间允许写,但是在读之前需要先获取一个顺序值,读操作完成后,再次读取顺序值,如果两者相等,说明在读的过程中没有发生过写操作,否则重新读取。
- 顺序锁特别适合读很多而写比较少的场合,否则由于反复的读操作,也不一定能够获取较高的效率。很显然,写操作是需要上锁的并且更新顺序值。
- 顺序锁的数据类型是seqlock_t,其类型定义如下:
typedef struct{
struct seqcount seqcount; //顺序值
spinlock_t lock; //自旋锁
}seqlock_t;
- 可以看到,顺序锁使用了自旋锁的机制。其主要API如下:
seqlock_init(x); //初始化顺序锁
unsigned read_seqbegin(const seqlock_t *s1);
//读之前获取顺序值,函数返回顺序值
unsigned read_seqretry(const seqlock_t *s1,unsigned start);
//读之前验证顺序值是否发生了变化,返回1表示需要重读返回0表示读成功
void write_seqlock(seqlock_t *s1);
//写之前加锁
void write_sequnlock(seqlock_t *s1);
//写之后解锁
/*参照自旋锁和读写锁的API理解*/
void write_seqlock_bh(seqlock_t *s1);
void write_sequnlock_bh(seqlock_t *s1);
void write_seqlock_irq(seqlock_t *s1);
void write_sequnlock_irq(seqlock_t *s1);
write_seqlock_irqsave(lock,flags);
void write_sequnlock_irqrestore(seqlock_t *s1,unsigned long flags);
- 再是调用文章开头的例子
int i = 5;
unsigned long flags;
/*定义顺序锁*/
seqlock_t lock;
/*使用之前必须初始化顺序锁*/
seqlock_inti(&lock);
int v;
unsigned star;
do{
star = read_seqbegin(&lock);
v = i;
/*读完后检查顺序值是否发生了变化,如果是,则要重读*/
}while(read_seqretry(&lock,star));
/*写之前获取顺序锁*/
write_seqlock_irqsave(&lock,star);
i++;
/*写完后释放顺序锁*/
write_sequnlock_irqrestore(&lock,flags);
6. 信号量
- 前面所讲到的锁的机制都有一个限制,那就是在锁获得期间不能调用调度器,即不能引起进程切换。
- 另外,我们也知道对于忙等锁来说,当临界代码段执行的时间比较长的时候,会降低系统的效率。为此内核提供了信号量的机制来取消这一限制。其数据类型如下:
struct semaphore{
raw_spinlock_t lock; //其底层仍是用自旋锁来实现的
unsigned int count; //描述信号量资源的情况,为0时表示则不能获取
struct list_head wait_list; // 当信号量不能获取时,进程就会休眠进入等待队里
};
- 使用信号量常见的API
void sema_init(struct semaphore *sem,int val);
void down(struct semaphore *sem);
int down_interruptible(struct semphore *sem);
int down_trylock(struct semphore *sem);
int down_timeout(struct semphore *sem);
void up(struct semaphore *sem);
@semainit: 用于初始化信号量,val是赋值给count成员的初值,这样就可以有val个进程同时获得信号量
@down: 获取信号量(获取成功后,信号量的count值会减一),当信号量的值不为0时,可以立即获取信号量否则进程休眠。
@down_interruptible: 同down,但是当有信号可获取去时能够被信号唤醒。
@down_timeout: 同down,但是在jifies个时钟周期后如果还没有获取信号量,则超时返回,返回0表示成功获取信号量,返回负值表示超时。
@up: 释放信号量(信号量的值加1),如果有进程等待信号量,则唤醒这些进程。
- 下面来讲一种特殊的情况,当信号量初值为1的时候,则表示在同一时刻只能有一个进程获得信号量,这种信号量叫二值信号量。显然,根据这个性质我们就可以用它来做互斥。其实现也很简单,就是调用dema_init函数的时候指定val为1。
信号量的使用过程中应该注意的一些点:
- 信号量可以被多个进程同时持有,当给信号量赋初值1时,信号量称为二值信号量,也称为互斥信号量,可以用来互斥。
- 如果不能获得信号量,则进程休眠,调度其他的进程执行,不会进行忙等待。
- 因为获得信号量可能会引起进程切换,所以不能用在中断上下文中,如果必须要用,只能使用down_trylock。不过在中断上下文文可以使用up释放信号量,从而唤醒其他进程。
- 持有信号量期间可以调用调度器,但是需要特别注意是否会产生死锁。
- 信号量开销比较大,在不违背自旋锁使用规则的情况下,应该优先使用自旋锁。
7. 读写信号量
- 前面提到了读写锁,其是相对于自旋锁而言的。和其相似,信号量中也有读写信号量。其和信号量的本质是一样的,知识将读写分开了,从而能获取更好的并发性。常用的API如下:
init_rwsem(sem);
void down_read(struct rw_semaphore *sem); //该类型和信号量类型一样,这里不再赘述
int down_read_trylock(struct rw_semaphore *sem);
void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);
void up_read(struct rw_semaphore *sem);
void up_write(struct rw_semaphore *sem);
- 结合见面讲到的读写锁,很好理解其相关API的含义。
8. 互斥量
- 信号量除了不能用于中断上下文,还有一个缺点就是不是很智能。试想一下,一般情况下如果我们不能获取限号量,进程立即就会进行休眠,那么信号量的操作就要经历休眠再被唤醒等一些列的操作了。其实很多时候,我们只需要等待一小会儿就能立即获得,信号量,而就不需要进入休眠了。或则说我们等一小会儿,若是仍不能获取先后量再进入休眠,这样也为时不晚。针对这种情况,为了实现这种比较智能的信号量,内核提供了另外一种专门用于互斥的高效率信号量,也就是互斥量,也叫互斥体,类型为struct mutex。其相关的API如下:
mutex_init(mutex);
void mutex_lock(struct mutex *lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_trylock(struct mutex *lock);
int mutex_uplock(struct mutex *lock);
- 利用这些API,我们还是提到开篇提到的那个问题,利用互斥量来实现互斥。
int i = 5;
/*定义互斥量*/
struct mutex lock;
/*使用之前初始化*/
mutex_init(&lock);
/*访问共享资源之前获得互斥量*/
mutex_lock(&lock);
i++;
/*访问完释放*/
mutex_unlock(&lock);
互斥量使用过程中的关键点总结:
- 要在同一上下文对互斥量上锁和解锁,比如不能再读进程中上锁,也不能在写进程中解锁。
- 互斥量的上锁是不能递归,这和自旋锁是一样的。
- 当有互斥量时,不能退出进程。
- 不能用于中断上下文,即使mutex_trylock也不行。
- 持有互斥量期间,可以调用可能会引起进程切换的函数。
- 在不违背自旋锁的使用规则时,应该优先使用自旋锁。
- 在不能使用自旋锁但不违背互斥量的使用规则时,应该优先使用互斥量,而不是信号量。
9. RCU机制
- RCU(read-Copy Update)机制即读-复制-更新。RCU机制对共享资源的访问都是通过指针来进行的。
- 原理大体为:读操作通过该指针进行解引用,来获取想要的数据。写操作在发起访问操作的时候,并不是去写以前的共享资源,而是另起炉灶,重新分配一片内存空间,复制以前的数据到新开辟的内存空间(有时不用复制),然后修改新分配的内存空间里面的内容。当写结束后,等待所有的读操作都完成了对原有内存空间的读取后,将读的指针更新,指向新的内存空间,之后的读操作将会得到更新后的数据。这种机制非常符合读访问少、写访问少的情况,它尽可能地减少了对锁的使用。(图画的有些许潦草,请将就着看)

- 内核使用RCU机制实现了对数组、链表和NMI(不可屏蔽中断)操作的大量API,不过要能立即RCU,通过下面几个最简单的API即可。
void rcu_read_lock(void);
//读者进入临界区
rcu_dereference(p);
//读者用于获取共享资源的内存指针
void rcu_read_unlock(void);
//读者退出临界区
rcu_assign_pointer(p,v);
//用新指针更新老指针
void synchronize_rcu(void);
//等待之前的读者完成读操作
- 下面使用简单的代码来完成一个RCU机制的示例
struct foo{
int a;
char b;
long c;
};
DEFIEN_SPINLOCK(foo_mutex);
struct foo *gbl_foo;
void foo_update_a(int new_a)
{
struct foo *new_fp;
struct foo *old_fp;
new_fp = kmalloc(sizeof(*new_fp),GFP_KERNEL); //在内存的其他位置重新分配
spin_lock(&foo_mutex);
old_fp = gbl_foo;
*new_fp = *old_fp; //赋值原共享资源到新的内存空间
new_fp->a = new_a;
rcu_assign_pointer(gbl_foo,new_fp); //新指针更新老指针
spin_unlock(&foo_mutex);
synchronize_rcu();
kfree(old_fp);
}
int foo_get_a(void)
{
int retval;
rcu_read_lock();
retval = rcu_dereference(gbl_foo)->a; //读者获取共享资源
rcu_read_unlock();
return retval;
}
三、内核中的同步
- 同步是指内核中的执行路径需要按照一定的顺序来执行。
- 同步可以用信号量。比如,我们要求内核要按照A->B->C的执行路径,那么当A执行完成后,即使B还没有就绪,那么C也要等待,直到B执行完了它才能执行。这在有些驱动中非常重要。我们利用信号量的机制可以来实现,先初始化一个值为0的信号量,做转换操作的执行路径先用down来获取这个信号量,如果B操作没有执行完成,那么C操作将会休眠等待。除了这种做法,内核中提供了完成量来实现这种操作。
- 完成量的结构类型如下:
struct completion{
unsigned int done;
wait_queue_head_t wait;
};
@done: 表示是否完成的状态,是一个计数值,为0表示未完成
@wait: 等待队列头
-
我们回想一下前面阻塞的知识,就不难想到完成量的工作原理:当done为0 时进程阻塞,当内核的其他执行路径使done的值大于0时,负责唤醒被阻塞在这个完成量上的进程。
-
完成量的API如下:
void init_completion(struct conpletion *x);
wait_for_completion(struct conpletion *);
wait_for_completion_interruptible(struct conpletion *x);
unsigned long wait_for_completion_timeout(struct conpletion *x,unsigned long timeout);
unsigned long wait_for_completion_interruptible(struct conpletion *x,unsigned long timeout);
bool try_wait_for_completion(struct completion *x);
void complete(struct completion *); //唤醒一个休眠进程
void complete_all(struct completion *);//唤醒所有休眠进程
- 例子
/*定义完成量*/
struct completion comp;
/*使用之前初始化完成量*/
init_completion(&comp);
/*等待其他任务完成某一个操作*/
wait_for_completion(&comp);
/*某个操作完成后,唤醒等待任务*/
complete(&comp);
四、总结
-
前面讲到了驱动中的互斥与同步。看到这里,你对每种机制的原理还记得多少。下面一起来总结一下:
内核中的互斥机制:
- 中断屏蔽
- 原子变量
- 自旋锁
- 读写锁
- 顺序锁
- 信号量
- 读写信号量
- 互斥量
- RCU机制
内核中的同步机制:
- 利用信号量来实现
- 完成量
-
内核驱动中,同步与互斥的处理时非常重要的,我们在编写驱动的时候一定要考虑到这些,才能编写出稳定可靠的驱动程序。
本文详细介绍了内核中的互斥与同步机制,包括中断屏蔽、原子变量、自旋锁、读写锁、顺序锁、信号量、读写信号量、互斥量、RCU机制和完成量等。每种机制的原理、使用场景和注意事项都被深入探讨,旨在帮助驱动开发者编写稳定可靠的驱动程序。
1540

被折叠的 条评论
为什么被折叠?



