linux设备驱动归纳总结(四):5.多处理器下的竞态和并发
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
这节将在上一节的基础上介绍支持多处理器和内核抢占的内核如何避免并发。除了内核抢占和中断外,由于多处理起的缘故,它可以做到多个程序同时执行。所以,进程除了要防自己的处理器外,还要防别的处理器,这个就是这节要介绍的内容。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
一、多处理器抢占式内核的内核同步需要防什么
1)防内核抢占。
2)防中断打断。
3)防其他处理器也来插一脚。
所以,在上一节讲的防抢占和防中断,接下来的内容实在这两个的基础上说一下如何防其他处理器。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
二、自旋锁
内核中是有很多的锁,自旋锁是其中的一种。它的作用在于,只要代码在进入临界区前加上锁,在进程还没出临界区之前,别的进程(包括自身处理器和别的处理器上的进程)都不能进入临界区。
自旋锁的可以这样理解,每个进程进入上锁的临界区前,必须先获得锁,否则在获得锁这条代码上查询(注意,不是休眠,是忙等待,循环执行指令),知道临界区里面的进程走出临界区,别的进程获得锁后进入临界区。有且只有一个获得锁的进程进入临界区。
也来个生活上的例子,公司有一个上锁的厕所,A在上厕所时,拿到钥匙,把门锁上后欢快地上厕所。这时B也想上厕所,但他看到门锁上了,没办法,只好在门口等待,直到A开门出来,把钥匙交给B,B才能去上厕所。
接下来说一下如何让使用,需要包含头文件<linux/spinlock.h>
1)使用自旋锁需要先定义并初始化自旋锁:
同样的,你可以使用静态定义并初始化:
spinlock_t lock = SPIN_LOCK_UNLOCKED;
也可以使用动态定义并初始化:
spinlock_t lock;
spin_lock_init(&lock);
2)在进入临界区前,必须先获得锁,使用函数:
spin_lock(&lock);
3)在退出临界区后,需要释放锁,使用函数:
spin_unlock(&lock);
所以,一个完整的上锁代码应该这样使用:
#include <linux/spinlock.h>
spinlock_t lock; //1.定义一个自旋锁
spin_lock_init(&my_dev.lock); //2.初始化锁
spin_lock(&lock); //3.获得锁
临界区。。。。。
spin_unlock(&lock); //4.释放锁
我将这段代码加上了驱动程序4th_mutex_5/1st/test.c,注意,这段函数并不是很规范,我只是想举例示范一下这几个函数应该加在代码中的什么位置。其中,代码中的临界区我只是打印了一句话,并不是什么共享数据。
验证一下效果:
[root: 1st]# insmod test.ko
alloc major[253], minor[0]
hello kernel
[root: 1st]# mknod /dev/test c 253 0
[root: 1st]# insmod irq/irq.ko
hello irq
[root: 1st]# cd app/
[root: app]# ./app&
[root: app]# <app> runing
<app> runing
[root: app]# ./app_read
<kernel>[test_open]
<app_read> pid[400]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
key down
key down
key down
key down
key down
<kernel>[test_read]task pid[400], context [app_read]
会发现,因为我在一个死循环上了自旋锁(当然这种做法是不恰当的),程序运行起来就和关了抢占效果一样!内核线程陷入循环,只有中断能够打断。
接着说函数spin_lock()实现了什么操作:
第一步:关抢占。
第二步:获得锁,防止别的处理器访问。
相对的,spin_unlock()实现了相反的操作:
第一步:开抢占。
第二步:释放锁。
所以,如果在单处理器支持内核抢占的内核下,spin_lock()函数会退化成关抢占。在单处理器不支持内核抢占的内核下,这将会是一条空语句。
上面的代码防了两种情况,但还没防中断,防中断有两种方法:
方法一:在需要访问临界区的中断代码也加锁:
do_irq() //中断处理函数
{
spin_lock();
/*临界区。。*/
spin_unlock();
}
方法二:直接在加锁的同时把中断也禁掉:
#include <linux/spinlock.h>
spinlock_t lock;
spin_lock_init(&my_dev.lock);
unsigned long flag = 0;
loacl_irq_save(flag);
spin_lock(&lock);
临界区。。。。。
local_irq_restroe(flag);
spin_unlock(&lock);
当然,贴心的内核工作者将两个函数合成一个函数,只用调用一个函数就能既上锁有关中断了:
spin_lock_irq(spinlock_t *lock) = spin_lock(spinlock_t *lock) + local_irq_disable()
spin_unlock_irq(spinlock_t *lock) = spin_unlock(spinlock_t *lock) + local_irq_enable()
spin_lock_irqsave(spinlick_t *lock, unsigned long falg) = spin_lock(spinlock_t *lock) + local_irq_save(unsigned long flag)
spin_unlock_irqrestore(spinlick_t *lock, unsigned long falg) = spin_unlock(spinlock_t *lock) + local_irq_restorr(unsigned long flag)
自旋锁的一个重要特征是,只要没获得锁,进程会占用CPU查询,直到获得锁,有些人不想查询,可以使用以下函数:
int spin_try_trylock(spinlock_t *lock);
一看函数名字就知道,他是尝试获得锁,成功返回非零,失败返回零。
这个强大的功能必定有他的弊端:
弊端一:持有锁的时间必须尽量的短:
进程在没获得锁前不进入睡眠,而是会占用CPU查询,这样的做法是为了节省进程从TASK_RUNNING切换至TASK_INTERRUPTIBLE后又切换回来消耗的时间。同时也是出于这样的原因,被上锁的临界区代码必须尽量的短。
弊端二:持有锁的期间不能睡眠:
也就是说,在临界区的代码里不能有引起睡眠的操作。譬如,一个进程上锁后睡眠,此时切换执行中断处理函数,可中断处理函数也要获得锁,这样就会使中断自旋,并且没人能打断。
最简单的生活例子,上厕所的时候你锁上门睡觉了,还让别人在门口瞎等!这种事情多不合理!
弊端三:要注意上锁的顺序:
如果进程进入临界区前需要那A、B两把锁,一个进程拿了A,另一个进程拿了B,它们死活也不让步,都不能获得另外一把锁,那只好在临界区代码前死等了。
弊端四:不能嵌套上锁:
简单的说,就是获得锁后后的进程不能再上一次同样的锁。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
三、信号量
上面说了自旋锁的缺点,如不能睡眠,要求临界区执行时间尽可能的短。出于这样的情况,就有了另一种内核同步的机制——信号量。
信号量是一种睡眠锁,当进程试图获取已经被占同的信号量,他就会被放到等待队列中,直到信后信号里释放后被唤醒。
继续刚才上锁的厕所,话说A把门锁上后上厕所,B要来上厕所是看到厕所被占用了,于是,他在门口上贴张纸条“我是B,你出来后叫我上厕所”,然后就离开了。A出来后,看到门口有纸条,就按照纸条所说的去通知B。
所以,信号量就是允许长时间上锁的睡眠锁。
接下来看一下怎么使用信号量,信号量有两种:互斥信号量和计数信号量。互斥信号量,就是说同一时间只能有一个进程获得锁并进入临界区。而计数信号量,那就是锁的数量可以多于一个,允许多个获得锁的进程进入临界区,同时这也是和自旋锁不同的地方。
以下的函数需要包含头文件<asm?semaphore.h>,信号量使用数据类型struct semaphore表示。
一、创建和初始化信号量:
同样有两种方法。
第一种是静态定义并初始化
static DECLARE_SEMAPHORE_GENERIC(name, count)
定义并初始化一个叫name的计数信号量,允许conut个进程同时持有锁。
static DECLARE_MUTEX(name)\
定义并初始化一个叫name的互斥信号量。
第二种是动态定义并初始化
首先你要定义一个信号量结构体:
struct semaphore sem;
然后初始化:初始化是指定信号量的个数
sema_init(&sem, count);
当然也有一些方便定义互斥信号量的函数:
/*初始化一个互斥信号量*/
#define init_MUTEX(sem) sema_init(sem, 1)
/*初始化一个互斥信号量并加锁*/
#define init_MUTEX_LOCKED(sem) sema_init(sem, 0)
二、使用信号量:
一般的获得信号量有三个函数:
1/*获取信号量sem,如果不能获取,切换状态至TASK_UNINTERRUPTIBLE*/
voud down(struct semaphore *sem)
上面的函数不太常用,因为它的睡眠不能被中断打断,一般使用下面的函数
2/*获取信号量sem,如果不能获取,切换状态至TASK_INTERRUPTIBLE,如果睡眠期间被中断打断,函数返回非0值*/
int down_interruputible(struct semaphore *sem)
3/*尝试获得信号量,如果获得信号量就返回零,不能获得也不睡眠,返回非零值*/
int down_try_lock(struct semaphore *sem)
因为上面的函数在睡眠时会被中断打断,一般会如下使用:
if (down_interruptible(&sem)){
return – ERESTARTSYS;
}
即如果在睡眠期间被中断打断,返回-ERESTARTSYS给用户,告知用户重新执行。如果是被唤醒,则会往下执行。
释放信号量函数:
void up(struct semaphore *sem);
所以,信号量一般这样使用:
#include <asm/semaphore.h>
struct semaphore sem;
sema_init(&sem, 1);
if (down_interruptible(&sem)){
return – ERESTARTSYS;
}
临界区代码。。。。。
up(&sem);
这4th_mutex_5/2nd/test.c我写了加上信号量的代码,还是那一句,代码不规范(在死循环加信号量无疑是自杀),我只是想告诉大家这几条函数一般使用在什么地方。在抢占式内核的情况下,使用信号量和使用自旋锁保护代码会不一样。
[root: /]# cd review_driver/4th_mutex/4th_mutex_5/2nd/
[root: 2nd]# insmod test.ko //加载模块
alloc major[253], minor[0]
hello kernel
[root: 2nd]# mknod /dev/test c 253 0
[root: 2nd]# insmod irq/irq.ko //加载中断
hello irq
[root: 2nd]# cd app/
[root: app]# ./app_read& //先后台运行app_read
<kernel>[test_open]
<app_read> pid[400] //注意进程号400
<kernel>[test_read]task pid[400], context [app_read]
[root: app]# <kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
[root: app]# ./app_read& //再后台运行一个app_read
<kernel>[test_open]
<app_read> pid[401] //注意进程号401,后面的打印没有一个是401!!!
[root: app]# <kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
[root: app]# ./app //后台运行app
<app> runing
<kernel>[test_read
key down
key down
key down
<kernel>[test_read]task pid[400], context [app_read]
<app> runing
<kernel>[test_read]task pid[400], context [app_read]
<app> runing
]task pid[400], context [app_read]
<app> runing //app能打印!!!!!
<kernel>[test_read]task pid[400], context [app_read]
<kernel>[test_read]task pid[400], context [app_read]
<app> runing
key down //中断也能执行!!!
key down
key down
key down
key down
<kernel>[test_read]task pid[400], context [app_read]
<app> runing
<kernel>[test_read]task pid[400], context [app_read]
<app> runing
不知道各位注意到上面的现象与自旋锁的有什么区别。
第一:信号量没有关抢占,如果别的进程没有访问上锁的临界区(如app),这个进程照样可以运行。
第二:访问了上锁临界区的进程,就不能执行了(如第二次运行的app_read)。
第三:临界区还是可以被中断打断的,因为信号量根本没关中断,如果临界区的资源不能被中断访问,那就像之前说的处理,要不在中断处理函数在进入临界区前获得锁,要不就把中断也关了。
所以,简单的说,信号量就是一个数,你获得这个数了,你就可以进去临界区。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
四、信号量与自旋锁的区别:
既然上面介绍了两种锁的机制和使用的方法,接下来就到对比一下两种锁的区别,应该在哪里使用。
区别一:实现方式
自旋锁是自旋等待,进程状态始终处于TASK_RUNNING。
信号量是睡眠等待,进程在等待是处于TASK_INTERRUPTIBLE。
区别二:睡眠死锁陷阱:
在自旋锁的临界区中,进程是不能陷入睡眠的。
而信号量可以睡眠。
同时,基于上面的原因,中断上下文中只能使用自旋锁(中断里不能休眠),在有睡眠代码的临界区只能使用信号量
区别三:CPU的使用情况:
明显的,信号量对系统的负载小,因为它睡眠了。
区别四:执行的效率方面:
自旋锁的效率比较高,因为它少了进程状态切换的消耗。
相对的信号量的效率比较低,因为进程的等待需要切换进程状态。
区别五:上锁的时间长短:
因为自旋锁是忙等待,所以临界区的代码不能太长。
而信号量可以使用在运行时间较长的临界区代码。
区别六:是否关抢占:
自旋锁是关抢占的,所以在单处理器非抢占的内核下,自旋锁是没用的。是空操作。
信号量并没有关抢占,所以,只有需要获得锁的进程才会睡眠,其他进程还可以继续运行,如上面的例子。
居于上面的区别,有这样的一个表:
需求 | 建议的加锁方法 |
低开销的加锁 | 优先考虑自旋锁 |
短时间的加锁 | 优先考虑自旋锁 |
长时间的加锁 | 优先是使用信号量 |
中断上下文中加锁 | 必须使用自旋锁 |
上锁后会有睡眠 | 必须使用信号量 |
还是那一句,个人喜好与需求,像我这种小白一般是不需要用到内核同步的机制的,因为我的开发板是单处理器非抢占内核。
接下来介绍一下其他的内核同步方法,但是我全都没用过。。。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
五、互斥量
这是2.6内核新加的,是互斥信号量的升级版。
其实上面介绍了两种锁使用的情况,其实,可以睡眠的临界区,都可以使用信号量,这就是信号量强大的地方。然而,越强大的功能,内核实现起来就越是困难。所以。内核开发者实现了轻量级的睡眠锁——互斥量。
使用互斥量使用结构体struct mutex来表示:
一、定义并初始化,两种方法:
静态定义:
DEFINE_MUTEX(name)
动态定义并初始化:
struct mutex mutex;
mutex_init(&mutex);
二、互斥量的操作:
获得互斥里
void inline __sched mutex_lock(struct mutex *lock) //不能获得锁是进入不可中断睡眠
int __sched mutex_lock_interruptible(struct mutex *lock) //进入可中断睡眠
int __sched mutex_trylock(struct mutex *lock) //尝试获得锁
这三个函数的用法的信号量的三个完全一样,返回值也是,所以我就不细讲了。
释放信号量:
void __sched mutex_unlock(struct mutex *lock)
当然,互斥量是升级版的轻量级信号量,它必然会有限制:
1)同一时间只能有一个进程获得锁,这是互斥的概念。
2)只能在同一进程上锁和解锁,而信号量不一样,可以在这个进程上锁,另外的进程解锁。
3)同一个进程获得锁后这段期间在获得这个锁,也就是说不能递归使用,原因很简单,因为是互斥,上锁的只有一次,只能解锁有在上锁。
4)进程持有锁是不能退出。
5)中断上下文不能使用锁,即使是mutex_trylock()。
6)互斥锁只能通过内核提供的API接口来操作。
内核推荐,在能使用互斥锁的情况下优先考虑,而不是使用信号量。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
六、原子操作(atomic_t)
所谓的原子操作,就是这段代码不会被其它进程打断,所以,加上自旋锁等锁之后的操作也算是原子操作。
而这里要介绍的原子操作和上面的不一样。考虑一下,如果你加锁只是为了保护一个整数,你有必要大费周章的使用自旋锁了,只要你把操作这个数的代码浓缩成一条指令,不就可以了吗?
内核提供了两种的原子操作:原子整数操作和原子位操作。顾名思义,就是在操作这个整数或者设置一个数的位数时,是不会被打断的。
具体的函数操作我就不讲了,我也没用过,书上也讲得很详细:《linux内核设计与实现(第三版)》P175页。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
七、总结:
这节讲了在多处理器的情况下如何实现内核同步,避免临界区并发访问,当然,上面介绍的方法需要用在真正需要的地方,因为我使用的是单处理器非抢占式内核,所以也没有太多的例子和代码,只能粗略的描述各种锁机制的优缺点和实现的机制。可能讲得不好,如果有疑问可以提出,我尽量改善。
同时,内核同步的机制还有很多,譬如读写锁等,都在书上有详细的描述。
当我还是个小小白的时候,我一直在纳闷自旋锁信号量究竟使用在什么地方,现在才发现,在我开发板如此低级的内核上,只要防中断就可以了。
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx