并发与竞态(自旋锁&信号量)

并发与竞态


并发与竞态(自旋锁&信号量)

  • 并发与竞态
  • 前言
    • 举一个例子
  • 一、竞态发生的情形
    • 1、对称多处理器(SMP)的多个CPU
    • 2、单CPU内进程间的抢占
    • 3、中断
  • 二、解决竞态的方法
    • 1.原子操作
    • 1)整型原子操作
    • 2)位原子操作
    • 2.自旋锁
    • 3、读写锁
    • 4、顺序锁
    • 5、信号量
  • 三、自旋锁vs信号量


前言

前几篇博客主要讲解了Linux驱动的基础概念以及字符型设备驱动的模板, 并且编写了一个实例证明了该模板的可用性。那么这里引出了一个问题, 试验时是由一个进程操作globalmem设备,那么当由两个甚至多个进程 同时操作globalmem设备时,会发生什么状况?

举一个例子

假设当a进程正在向globalmem设备中写入数据,写完后再从中把数据读取出来。而此时b进程上来也
向globalmem中写入数据。那么当a进程读取数据时,就会读到b进程写入的数据而不是a进程写入的
数据(a的数据被b冲掉了),此时当然会出现问题。这种情况就是没有处理好并发导致的竞态问题。

比以上情况更复杂,更混乱的并发大量存在于设备驱动中,那么为什么成熟的设备驱动没有出现竞态
问题呢?这就说明前人已经找到了解决它的办法。以下咱们一一讲解。


一、竞态发生的情形

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

SMP是一种紧耦合,共享存储的系统模型,它的特点是多个CPU使用共同的系统总线,
因此可以访问共同的外设和存储器。

2、单CPU内进程间的抢占

由于Linux内核支持抢占调度,一个进程在内核执行的时候可能被另一高优先级进程打断。

3、中断

当一个进程正在访问资源时,此时过来一个中断打断了此进程,则也会导致竞态。此外,
如果一个低优先级的中断被高优先级的中断打断后,也有可能导致竞态。

二、解决竞态的方法

解决竞态的方法本质上来说就是保证对共享资源的互斥访问,即是指一个进程在访问共享资源时,
其他进程禁止访问。要想做到这一点,可以使用以下方法:

1.原子操作

原子操作指的是在执行过程中不会被别的进程中断的操作。总共有两种类型的原子操作,分别是
针对位和针对整型变量的原子操作。Linux内核提供了以下调用接口:

1)整型原子操作

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);	//原子变量增加i
void atomic_sub(int i, atomic_t *v);	//原子变量减少i

4、原子变量自增/自减
void atomic_inc(atomic_t *v);			//原子变量增加1
void atomic_dec(atomic_t *v);			//原子变量减少1

5、操作并测试(对原子执行自增、自减、减操作后测试其是否为0,为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);

6、操作并返回(对原子进行加/减和自增/自减操作,并返回新的值)
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);

2)位原子操作

1、设置位
void set_bit(nr, void *addr);		//将addr地址的第nr位置1

2、清除位
void clear_bit(nr, void *addr);		//将addr地址的第nr位清0

3、改变位
void change_bit(nr, void *addr);	//将addr地址的第nr位反置

4、测试位
void test_bit(nr, void *addr);		//返回addr地址的第nr位

5、测试并操作位(执行完测试位后再对位进行不同操作)
int test_and_set_bit(nr, void *addr);
int test_and_clear_bit(nr, void *addr);
int test_and_change_bit(nr, void *addr);

2.自旋锁

自旋锁(spin lock)是一种典型的对临界资源进行互斥访问的手段。通过它的名称可知,申请使用自旋锁的进程为了获得一个自旋锁,调用相关接口函数时在接口函数内部首先会执行一个原子操作进行锁状态的判断,当自旋锁被占用的情况下,会循环执行锁状态判断的操作(判断原子变量值),这就是“自旋”的由来。当自旋锁的持有者通过设置该变量释放这个自旋锁后(主要是对原子变量进行加减操作),当前“自旋”等待的进程感知到原子变量值的改变后,开始取锁,取锁成功后,取消自旋操作。
《Linux设备驱动开发详解》中有一段比喻有助于理解自旋锁的意义:

理解自旋锁最简单的方法是把它当作一个变量看待,该变量把一个临界区或者标记为“我当前在运行,清稍等一会”或者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将拥有自旋锁;当B执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。

Linux中与自旋锁相关的定义和宏有以下几种:

1、spinlock_t lock;			//定义自旋锁lock

2spin_lock_init(lock);	//初始化自旋锁lock

3spin_lock(lock);			//获得自旋锁lock,如果获得锁返回为真,否则他将自旋在那,直到该自旋锁lock的保持者释放。
   spin_trylock(lock);		//尝试获取自旋锁lock,如果能立即获得锁,他获得锁并返回真,否则立即返回假,不会陷入循环状态。

4spin_unlock(lock);		//释放自旋锁lock,它与spin_lock或spin_trylock配对使用

自旋锁一般这样使用

spinlock_t lock;		//定义自旋锁

spin_lock_init(&lock);

spin_lock(&lock);		//获取自旋锁,保护临界区
/*----------临界区-----------*/

spin_unlock(&lock);		//解锁

使用自旋锁可以保证临界区不被别的CPU和本CPU内的抢占进程打扰,但是这是不是就万无一失了呢?其实并不是,还记得上面写的中断这一现象吗?单单使用自旋锁是无法屏蔽掉中断(包括中断的底半部)的,那么又怎么能屏蔽掉中断或中断的底半部呢?可以使用以下宏:

1local_irq_disable();		//屏蔽中断
   local_irq_save();		//屏蔽中断并保存目前CPU的中断信息
   
2local_irq_enable();		//开中断
   local_irq_restore();		//开中断并还原保存的CPU的中断信息

3local_bh_disable();		//禁止中断的底半部
   local_bh_enable();		//开中断的底半部

将上面对中断的操作与对自旋锁的操作结合起来就完美了。那么有人会问了保护个临界区要调用两套接口太麻烦,有没有一种简单点的方法?其实内核还封装了另一套宏,其本质上就是把对自旋锁的操作与对中断的操作封装到一起了。如下所示:

1spin_lock_irq() = spin_lock()+local_irq_disable()

2spin_unlock_irq() = spin_unlock()+local_irq_enable()

3spin_lock_irqsave() = spin_lock()+local_irq_save()

4spin_unlock_irqrestore() = spin_unlock()+local_irq_restore()

5spin_lock_bh() = spin_lock()+local_bh_disable()

6spin_unlock_bh() = spin_unlock()+local_bh_enable()

自旋锁+中断屏蔽看似能解决所有竞态问题,但是它们的使用仍有局限性,以下是我在实际过程中遇到的和资料中查到的使用局限性:

1、自旋锁其实是盲等锁,当锁不可用时,CPU一直循环执行“测试并设置”该锁直到可用而取得该锁。CPU在等待自旋锁时不做任何有用的工作,仅仅是等待。因此,只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。当临界区很大,或者有共享设备时,需要较长时间占用,使用自旋锁会降低系统性能。
2、自旋锁可能导致系统死锁。引发这个问题最常见的情况是递归使用一个自旋锁,即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁,该CPU将死锁。这里再插一句,尽管递归方法编程有一些好处,但是我还是不建议用它,因为我认为它的缺陷导致的损失远大于它的好处。导致死锁只是其中一个问题,另一个常见的问题是导致栈溢出。这两种问题在实际项目中很大概率都是非必现bug,排查难度相当大。在实际项目中尽量只用迭代方法编程。
3、 自旋锁锁定期间不能调用可能引起进程调度的函数。如果进程获得自旋锁之后再阻塞,如调用copy_from_user()、copy_to_user()、kmalloc()和msleep()等函数,则可能导致内核的崩溃。

3、读写锁

自旋锁不关心锁定的临界区究竟进行怎样的操作,不管是读还是写,他都一视同仁。但是,如果仅仅是多个进程同时读取一块临界区时并不会导致什么问题,此时如果再把临界区锁住,会导致系统性能不必要的降低。为了解决这种问题,读写锁(rwlock)就应运而生了。读写锁其实是自旋锁的优化,它允许读的并发。读写锁是一种比自旋锁粒度更小的锁机制。它保留了“自旋”的概念,但是在写操作上,只能最多有1个写进程;在读操作方面,同时可以有多个读执行单元。当然,读和写也不能同时进行。
内核提供的读写锁相关接口如下:

1、定义和初始化读写锁
rwlock_t my_rwlock = RW_LOCK_UNLOCKED;	//静态初始化
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);				//动态初始化

2、读锁定
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);

3、读解锁
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);		//类似上面提到的spin_trylock(),无论成功或失败,都会立即返回

5、写解锁
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);

在对共享资源进行读之前,应该先调用写锁定函数,完成之后应调用写解锁函数。

4、顺序锁

当有了读写锁读临界区动作可以并行后,经过新能测试,系统性能得到了很大提升。这个时候有一些大牛又不满与现状了,他们认为既然读性能都能提升,那写性能也应该可以提升,这样,读写锁的优化版–顺序锁(seqlock)就诞生了。顺序锁(seqlock)的实现原理是:读动作绝不会被写动作阻塞,也就是说,读动作在写动作对被顺序锁保护的共享资源进行写操作时仍然可以继续读,而不必等待写动作完成;同样的,写动作也不必等到所有读动作完成后才开始写。

但是,写动作与读动作之间仍然互斥,如果有一个写执行单元在进行写操作,其余写执行单元必须自旋在那里,直到写执行单元释放了自旋锁。如果读执行单元在读操作期间,写执行单元已经发生写操作,那么读执行单元必须重新读取数据。这种锁对于读写操作同时进行情况发生概率比较小的时候性能有较大提升,因而提高了并发性。

另外还要说明的一点是,顺序锁有一个限制,它必须要求被保护的临界区中不包含指针,因为写写操作可能会使指针失效,但读执行单元如果正要读取该指针所指内容时,将导致系统挂死(oops)。

内核提供的顺序锁相关接口如下:

1、获取顺序锁
void wirte_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
write_seqlock_irqsave(lock, flags)
write_seqlock_irq(lock)
write_seqlock_bh(lock)

2、释放顺序锁

void write_sequnlock(seqlock_t *sl);
write_sequnlock_irqrestore(lock, flags)
write_sequnlock_irq(lock)
write_sequnlock_bh(lock)

写操作使用顺序锁的简略模板如下:

write_seqlock(&seqlock);
/*写操作代码*/
write_sequnlock(&seqlock);

读操作使用顺序锁的接口如下:
1、读开始

unsigned read_seqbegin(const seqlock_t *sl);	//开始读之前调用该接口,返回值为顺序锁s1的当前顺序号
read_seqbegin_irqsave(lock, flags) = local_irq_save() + read_seqbegin()

2、重读

int read_seqretry(const seqlock_t *sl, unsigned iv);//读完之后调用该接口检查,检查读访问期间是否有写操作。
													//如果有写操作,读执行单元就要重新进行读操作
read_seqretry_irqrestore(lock, iv, flags) = read_seqretry() + local_irq_restore()

读操作简略模板如下:

do{
	seqnum = read_seqbegin(&seqlock);
	/*读操作代码*/
	...
}while(read_seqretry(&seqlock, seqnum));

5、信号量

信号量(semaphore)是另一种保护临界区的方法,它的使用与自旋锁类似,都是只有得到信号量的进程才能执行临界区代码。但是,与自旋锁不同的是,当获取不到信号量时,进程不会原地打转而是进入休眠状态。

Linux中与信号量相关的操作主要有:
1、定义信号量

struct semaphore sem;

2、初始化信号量

void sema_init(struct semaphore *sem, int val);		//初始化并设置信号量sem的值为val

#define init_MUTEX(sem)		sema_init(sem, 1)		//初始化并设置信号量sem的值为1(此接口已废弃)
#define init_MUTEX(sem)		sema_init(sem, 0)		//初始化并设置信号量sem的值为0(此接口已废弃)

3、获得信号量

void down(struct semaphore *sem);				//获得信号量,但会导致进程睡眠,不能被信号打断
int down_interruptible(struct semaphore *sem);	//获得信号量,会进入睡眠但可以被信号打断,打断后函数返回非0
int down_trylock(struct semaphore *sem);		//尝试获取信号量,立即获得返回0,否则返回非0,不会导致进程睡眠

4、释放信号量

void up(struct semaphore *sem);		//释放信号量,唤醒等待者

信号量使用简单模板

struct semaphore sem;
init_MUTEX(sem)down(&sem);		//获取信号量,保护临界区
/*----------临界区----------*/
up(&sem);		//释放信号量

三、自旋锁vs信号量

既然自旋锁(包括其变种读写锁、顺序锁)和信号量都是解决竞态问题的基本手段,那么在实际项目中,我们应该选用哪种方法进行互斥呢?这就要依据临界区的性质和系统的特点来进行取舍了。

信号量和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者。在信号量本身的实现上,为了保证信号量结构存取的原子性,在多CPU中需要自旋锁来互斥。以下借用《Linux设备驱动开发详解》来说明自旋锁与信号量的选用原则:

(1)当锁不能被获取到时,使用信号量的开销是进程上下文切换时间 T s w % \f is defined as #1f(#2) using the macro Tsw Tsw,使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定) T c s % \f is defined as #1f(#2) using the macro Tcs Tcs,若 T c s % \f is defined as #1f(#2) using the macro Tcs Tcs比较小,宜使用自旋锁,若 T c s % \f is defined as #1f(#2) using the macro Tcs Tcs很大,应使用信号量。
(2)信号量所保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区。因为阻塞意味着要进行进程的切换,如果进程被切换出去,另一个进程企图获取本自旋锁,死锁就会发生。
(3)信号量存在于进程上下文,因此,如果被保护的共享资源需要在中断或软中断情况下使用,则在信号量和自旋锁之间只能选择自旋锁。当然,如果一定要使用信号量,则只能通过down_trylock()方式进行,不能获取就立即返回以避免阻塞。

你可能感兴趣的:(多线程,linux,并发编程)