Linux 设备驱动中必须要解决的一个问题是多个进程对共享的资源的并发访问,并发的访问会导致竞态,即使是经验丰富的驱动工程师也常常设计出包含并发问题bug 的驱动程序。
一、基础概念
1、Linux 并发相关基础概念
a -- 并发(concurrency):并发指的是多个执行单元同时、并发被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race condition);
b -- 竞态(race condition) :竞态简单的说就是两个或两个以上的进程同时访问一个资源,同时引起资源的错误;
c -- 临界区(Critical Section):每个进程中访问临界资源的那段代码称为临界区;
d -- 临界资源 :一次仅允许一个进程使用的资源称为临界资源;多道程序系统中存在许多进程,它们共享各种资源,然而有很多资源一次只能供一个进程使用;
在宏观上并行或者真正意义上的并行(这里为什么是宏观意义的并行呢?我们应该知道“时间片”这个概念,微观上还是串行的,所以这里称为宏观上的并行),可能会导致竞争; 类似两条十字交叉的道路上运行的车。当他们同一时刻要经过共同的资源(交叉点)的时候,如果没有交通信号灯,就可能出现混乱。在linux 系统中也有可能存在这种情况:
2、并发产生的场合
a -- 对称多处理器(SMP)的多个CPU
SMP 是一种共享存储的系统模型,它的特点是多个CPU使用共同的系统总线,因此可访问共同的外设和储存器,这里可以实现真正的并行;
b -- 单CPU内进程与抢占它的进程
一个进程在内核执行的时候有可能被另一个高优先级进程打断;
c -- 中断和进程之间
中断可以打断正在执行的进程,如果中断处理函数程序访问进程正在访问的资源,则竞态也会发生;
3、解决竞态问题的途径
解决竞态问题的途径最重要的是保证对共享资源的互斥访问,所谓互斥访问是指一个执行单元在访问共享资源的时候,其他的执行单元被禁止访问。
Linux 设备中提供了可采用的互斥途径来避免这种竞争。主要有原子操作,信号量,自旋锁。
那么这三种有什么相同的地方,有什么区别呢?适用什么不同的场合呢?会带来什么边际效应?要彻底弄清楚这些问题,要从其所处的环境来进行细化分类处理。是UP(单CPU)还是SMP(多CPU);是抢占式内核还是非抢占式内核;是在中断上下文不是进程上下文。似交通信号灯一样的措施来避免这种竞争。
先看一下三种并发机制的简单概念:
原子锁:原子操作不可能被其他的任务给调开,一切(包括中断),针对单个变量。
自旋锁:使用忙等待锁来确保互斥锁的一种特别方法,针对是临界区。
信号量:包括一个变量及对它进行的两个原语操作,此变量就称之为信号量,针对是临界区。
二、并发处理途径详解
1、中断屏蔽
在单CPU范围内避免静态的一种简单而省事的方法是在进入临界区之前屏蔽系统的中断,这项功能可以保证正在执行的内核执行路径不被中断处理程序所抢占,防止某些竞争条件的发生。具体而言
a -- 中断屏蔽将使得中断和进程之间的并发不再发生;
b -- 由于Linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也得以避免;
中断屏蔽的使用方法:
- local_irq_disable()
- local_irq_enable()
- 只能禁止和使能本地CPU的中断,所以不能解决多CPU引发的竞态
- local_irq_save(flags)
- local_irq_restore(flags)
- 除了能禁止和使能中断外,还保存和还原目前的CPU中断位信息
- local_bh_disable()
- local_bh_disable()
- 如果只是想禁止中断的底半部,这是个不错的选择。
但是要注意:
a -- 中断对系统正常运行很重要,长时间屏蔽很危险,有可能造成数据丢失乃至系统崩溃,所以中断屏蔽后应尽可能快的执行完毕。
b -- 宜与自旋锁联合使用。
所以,不建议使用中断屏蔽。
2、原子操作
原子操作(分为原子整型操作和原子位操作)就是绝不会在执行完毕前被任何其他任务和时间打断,不会执行一半,又去执行其他代码。原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都在include/asm/atomic.h中,使用汇编语言实现。
在linux中,原子变量的定义如下:
typedef struct {volatile int counter;} atomic_t;
关键字volatile用来暗示GCC不要对该类型做数据优化,所以对这个变量counte的访问都是基于内存的,不要将其缓冲到寄存器中。存储到寄存器中,可能导致内存中的数据已经改变,而寄存其中的数据没有改变。
原子整型操作:
1)定义atomic_t变量:
#define ATOMIC_INIT(i) ( (atomic_t) { (i) } )
atomic_t v = ATOMIC_INIT(0); //定义原子变量v并初始化为0
2)设置原子变量的值:
#define atomic_set(v,i) ((v)->counter = (i)) void atomic_set(atomic_t *v, int i);//设置原子变量的值为i
3)获取原子变量的值:
#define atomic_read(v) ((v)->counter + 0) atomic_read(atomic_t *v);//返回原子变量的值
4)原子变量加/减:
static __inline__ void atomic_add(int i, atomic_t * v); //原子变量增加i
static __inline__ void atomic_sub(int i, atomic_t * v); //原子变量减少i
5)原子变量自增/自减:
#define atomic_inc(v) atomic_add(1, v); //原子变量加1 #define atomic_dec(v) atomic_sub(1, v); //原子变量减1
6)操作并测试:
//这些操作对原子变量执行自增,自减,减操作后测试是否为0,是返回true,否则返回false
#define atomic_inc_and_test(v) (atomic_add_return(1, (v)) == 0) static inline int atomic_add_return(int i, atomic_t *v)
原子操作的优点编写简单;缺点是功能太简单,只能做计数操作,保护的东西太少。下面看一个实例:
- 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;
- }
3、自旋锁
自旋锁是专为防止多处理器并发而引入的一种锁,它应用于中断处理等部分。对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,不需要自旋锁。
自旋锁最多只能被一个内核任务持有,如果一个内核任务试图请求一个已被争用(已经被持有)的自旋锁,那么这个任务就会一直进行忙循环——旋转——等待锁重新可用(忙等待,即当一个进程位于其临界区内,任何试图进入其临界区的进程都必须在进入代码连续循环)。要是锁未被争用,请求它的内核任务便能立刻得到它并且继续进行。自旋锁可以在任何时刻防止多于一个的内核任务同时进入临界区,因此这种锁可有效地避免多处理器上并发运行的内核任务竞争共享资源。
1)自旋锁的使用:
spinlock_t spin; //定义自旋锁
spin_lock_init(lock); //初始化自旋锁
spin_lock(lock); //成功获得自旋锁立即返回,否则自旋在那里直到该自旋锁的保持者释放
spin_trylock(lock); //成功获得自旋锁立即返回真,否则返回假,而不是像上一个那样"在原地打转"
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或单CPU但内核可抢占的情况,对于单CPU和内核不支持的抢占的系统,自旋锁退化为空操作(因为自旋锁本身就需进行内核抢占)。在单CPU和内核可抢占的系统中,自旋锁持有期间内核的抢占将被禁止。由于内核可抢占的单CPU系统的行为实际很类似于SMP系统,因此,在这样的单CPU系统中使用自旋锁仍十分重要。
尽管用了自旋锁可以保证临界区不受别的CPU和本CPU内的抢占进程打扰,但是得到锁的代码路径在执行临界区的时候,还可能受到中断和底半部的影响。为了防止这种影响。为了防止影响,就需要用到自旋锁的衍生。
2)注意事项:
a -- 自旋锁是一种忙等待。它是一种适合短时间锁定的轻量级的加锁机制。
b -- 自旋锁不能递归使用。自旋锁被设计成在不同线程或者函数之间同步。这是因为,如果一个线程在已经持有自旋锁时,其处于忙等待状态,则已经没有机会释放自己持有的锁了。如果这时再调用自身,则自旋锁永远没有执行的机会了,即造成“死锁”。
【自旋锁导致死锁的实例】
1)a进程拥有自旋锁,在内核态阻塞的,内核调度进程b,b也要或得自旋锁,b只能自旋,而此时抢占已经关闭了,a进程就不会调度到了,b进程永远自旋。
2)进程a拥有自旋锁,中断来了,cpu执行中断,中断处理函数也要获得锁访问共享资源,此时也获得不到锁,只能死锁。
3)内核抢占
内核抢占是上面提到的一个概念,不管当前进程处于内核态还是用户态,都会调度优先级高的进程运行,停止当前进程;当我们使用自旋锁的时候,抢占是关闭的。
4)自旋锁有几个重要的特性:
a -- 被自旋锁保护的临界区代码执行时不能进入休眠。
b -- 被自旋锁保护的临界区代码执行时是不能被被其他中断中断。
c -- 被自旋锁保护的临界区代码执行时,内核不能被抢占。
从这几个特性可以归纳出一个共性:被自旋锁保护的临界区代码执行时,它不能因为任何原因放弃处理器。
4、信号量
linux中,提供了两种信号量:一种用于内核程序中,一种用于应用程序中。这里只讲属前者
信号量和自旋锁的使用方法基本一样。与自旋锁相比,信号量只有当得到信号量的进程或者线程时才能够进入临界区,执行临界代码。信号量和自旋锁的最大区别在于:当一个进程试图去获得一个已经锁定的信号量时,进程不会像自旋锁一样在远处忙等待。
信号量是一种睡眠锁。如果有一个任务试图获得一个已被持有的信号量时,信号量会将其推入等待队列,然后让其睡眠。这时处理器获得自由去执行其它代码。当持有信号量的进程将信号量释放后,在等待队列中的一个任务将被唤醒,从而便可以获得这个信号量。
1)信号量的实现:
在linux中,信号量的定义如下:
struct semaphore {spinlock_t lock; //用来对count变量起保护作用。
unsigned int count; // 大于0,资源空闲;等于0,资源忙,但没有进程等待这个保护的资源;小于0,资源不可用,并至少有一个进程等待资源。
struct list_head wait_list; //存放等待队列链表的地址,当前等待资源的所有睡眠进程都会放在这个链表中。
};
2)信号量的使用:
static inline void sema_init(struct semaphore *sem, int val); //设置sem为val
#define init_MUTEX(sem) sema_init(sem, 1) //初始化一个用户互斥的信号量sem设置为1 #define init_MUTEX_LOCKED(sem) sema_init(sem, 0) //初始化一个用户互斥的信号量sem设置为0
DECLARE_MUTEX(name); //该宏定义信号量name并初始化1
DECLARE_MUTEX_LOCKED(name); //该宏定义信号量name并初始化0
当信号量用于互斥时(即避免多个进程同是在一个临界区运行),信号量的值应初始化为1。这种信号量在任何给定时刻只能由单个进程或线程拥有。在这种使用模式下,一个信号量有时也称为一个“互斥体(mutex)”,它是互斥(mutual exclusion)的简称。Linux内核中几乎所有的信号量均用于互斥。
使用信号量,内核代码必须包含<asm/semaphore.h> 。
3)获取(锁定)信号量:
void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_killable(struct semaphore *sem);
4)释放信号量
void up(struct semaphore *sem);
下面看一个实例:
- //定义和初始化
- static struct semaphore sem;
- sema_init(&sem,1);
- static int hello_open (struct inode *inode, struct file *filep)
- {
- // p操作,获得信号量,保护临界区
- if(down_interruptible(&sem))
- {
- //没有获得信号量
- return -ERESTART;
- }
- return 0;
- }
- static int hello_release (struct inode *inode, struct file *filep)
- {
- //v操作,释放信号量
- up(&sem);
- return 0;
- }
三、自旋锁与信号量的比较
信号量 | 自旋锁 | |
1、开销成本 | 进程上下文切换时间 | 忙等待获得自旋锁时间 |
2、特性 | a -- 导致阻塞,产生睡眠 b -- 进程级的(内核是代表进程来争夺资源的) | a -- 忙等待,内核抢占关闭 b -- 主要是用于CPU同步的 |
3、应用场合 | 只能运行于进程上下文 | 还可以出现中断上下文 |
4、其他 | 还可以出现在用户进程中 | 只能在内核线程中使用 |
从以上的区别以及本身的定义可以推导出两都分别适应的场合。只考虑内核态
后记:除了上述几种广泛使用的的并发控制机制外,还有中断屏蔽、顺序锁(seqlock)、RCU(Read-Copy-Update)等等,做个简单总结如下图: