【Linux驱动编程】并发与竞态(如何选择合适的保护机制)

1 场景

  假设地铁上有一个空座位(在大深圳就没见过),路人甲、乙、丙上车都有同等机率获得座位,但是最终只有一个人能够获得。“座位”就是共享资源,甲乙丙就是并发“访问”的“共享资源”的对象。事实上,即使只有一个座位,哪怕有更多人上车也不会引发“冲突”(竞态),因为大家潜意识里,获得座位的机制是“先到先得”或者“礼让”。

  类似的,linux系统中提供了完整的并发保护机制,避免竞态现象。


2 并发与竞态

  并发(concurrency)指的是多个执行单元在宏观上同时被执行,从宏观角度看起来就像是并行执行的。竞态(race conditions)则是由并发引的对共享资源(包括软件资源和硬件资源)同时访问引起的一种现象。


2.1 并发带来的影响

  并发访问直接引入的问题就是资源竞争。linux系统是一个多用户、多任务的操作系统,存在多个任务同时访问共享资源的情况,共享资源可以是内存、设备驱动、外设,多任务同时访问可能导致内存数据混乱,文件描述符重复操作,甚至可能引发进程和系统崩溃。


2.2 引起并发的场景

  • 多线程访问,linux内核态与用户态一样,支持多线程,多线程访问是最基本的引起并发原因;

  • 抢占式访问,linux内核从2.6版本开始,线程支持抢占式调度,高优先级线程可以抢占正在运行的低优先级线程;

  • 中断,中断具有最高优先级,可以抢占当前运行的线程;包括硬中断、软中断、tasklet、中断下半部;

  • 多核(SMP)并发访问,目前CPU基本是多核的,多核访问达到真正意义的“并行”执行。


2.3 竞态

  并发访问引入竞态现象,竞态是可通过保护机制来规避的。


3 并发与竞态处理机制

  并发访问引入竞态现象,引起资源竞争,解决竞态问题的本质是保证对共享资源的互斥访问。linux内核提供了几个共享资源互斥访问的保护措施,分别是屏蔽中断、原子操作、自旋锁、信号量、互斥体。


3.1 屏蔽中断

  linux内核的线程调度是依赖中断来实现,在访问共享资源时,屏蔽中断,资源访问完毕再开启中断,竞态问题也就得以解决。

 local_irq_disable()      /* 屏蔽中断 */
 /* todo 访问共享资源 */ 
     
 local_irq_enable()      /* 开中断 */

  屏蔽中断,在RTOS、裸机开发等功能比较单一的系统上比较常见。在linux系统下一般不推荐使用,linux内核功能庞大,屏蔽/开启中断不应该由用户操作(屏蔽中断应该交给内核在合适的时候操作),影响系统性能。


3.2 原子操作

  原子指的是化学反应中不可再分的基本微粒。原子操作继承原子的含义,指的是当前操作是不可再细分的,只有一步操作。在操作系统中,原子操作指的是cpu执行一条不可再细分的指令。


原子操作特点

  原子操作表示步骤不可再细分,因此原子操作不存在竞态问题。但原子操作只适用于共享资源是整型变量的场景下,应用范围比较局限。


整型原子操作

  linux驱动开发,除了与cpu底层相关,大部分采用C语言开发,C语言通过预处理、编译、汇编、链接生成可执行的二进制文件。因此,C语言的一个赋值语句不是原子操作。比如int i = 0;经过汇编后,生成取址、变量取值、赋值等汇编指令,cpu在执行几条汇编指令完成赋值操作过程,也是有可能被抢占的。

  C语言基本的赋值语句不是原子操作,多个线程共享一个变量就存在竞态的可能性。此时,可以使用linux内核提供的”原子操作“机制。linux的原子操作使用atomic_t(64位系统使用atomic64_t)结构体描述,位于“include/linux/types.h”下。

typedef struct {
	int counter;
} atomic_t;

#ifdef CONFIG_64BIT
typedef struct {
	long counter;
} atomic64_t;
#endif

  使用原子类型变量,与其他基本变量类型使用方式类似,涉及到原子变量值访问时,调用系统定义的函数API。


  • 定义原子变量
atomic_t i;
  • 原子变量赋初始值
atomic_t i = AIOMIC_INIT(0);
  • 获取原子变量值
int atomic_read(atomic_t *v)
  • 原子变量自增、自减
void atomic_inc(atomic_t *v);
void atomic_dec(atomic_t *v);

  • 原子操作常用API

引用头文件

#include<asm/atomic.h>

常用函数

函数 功能
AIOMIC_INIT(name) 定义原子变量并赋初始值
int atomic_read(atomic_t *v) 获取取原子变量v的值
void atomic_inc(atomic_t *v) 原子变量v自增
void atomic_dec(atomic_t *v) 原子变量v自减
void atomic_set(atomic_t *v,int i) 向原子变量v写入i值
void atomic_add(atomic_t *v,int i) 向原子变量v加上i值
void atomic_sub(atomic_t *v,int i) 向原子变量减去i值
int atomic_inc_return(atomic_t *v) 原子变量v自增,并返回当前值
int atomic_dec_return(atomic_t *v) 原子变量v自减,并返回当前值
int atomic_inc_and_test(atomic_t *v) 原子变量v自增,检查结果为0,返回真,否则返回假
int atomic_dec_and_test(atomic_t *v) 原子变量v自减,检查结果为0,返回真,否则返回假
int atomic_add_negative(int i, atomic_t *v) 原子变量v加上i值,检查结果为0,返回真,否则返回假
int atomic_sub_and_test(int i, atomic_t *v) 原子变量v减去i值,检查结果为0,返回真,否则返回假

  以上函数用于访问32位原子变量,对于64位cpu,需使用64位的原子变量atomic64_t和相关函数。64位原子变量操作函数与32位原子变量函数只是前缀不一样,前缀由atomic替换为atomic64

int atomic64_read(atomic_t *v)	/* 获取取原子变量v的值 */
void atomic64_inc(atomic_t *v)  /* 原子变量v自增 */

  • 例子
#include <asm/atomic.h>

atomic_t i = AIOMIC_INIT(0), j;

atomic_set(5);
atomic_inc(&i);
j = atomic_read(&i);

位原子操作

  顾名思义,位原子操作就是访问变量的某一位。linux位原子操作直接用于一内存地址,不需使用原子变量。位原子操作也有专门的访问API。

函数 功能
int test_bit(int nr, void *addr) 获取地址addr第nr位的值
void set_bit(int nr, void *addr) 将地址addr第nr位置1
void clear_bit(int nr, void *addr) 将地址addr第nr位置0
void change_bit(int nr, void *addr) 将地址addr第nr位取反
int test_and_set_bit(int nr, void *addr) 将地址addr第nr位置1,并返回nr位原来的值
int test_and_clear_bit(int nr, void *addr) 将地址addr第nr位置0,并返回nr位原来的值
int test_and_change_bit(int nr, void *addr) 将地址addr第nr位置1,并返回nr位原来的值

  位原子操作函数没有区分32位和64位,因为对于64位来数,地址自然就是64位了。


  • 例子
#include <asm/atomic.h>

inti = 0, j = 0;

set_bit(0, &i);
change_bit(1, &i);
j = test_bit(1, &i);

3.3 自旋锁

  原子操作只能用于整型变量或者变量中某一位的保护,实际场景中,共享资源往往多样的,不仅仅是整型变量,还包括自定义的结构体变量,内存块区域或者系统外设等。对于后者的场景,可以考虑使用linux内核“自旋锁”机制。

  自旋锁,通俗理解就是一把锁,线程访问资源时,先上锁,防止其他线程抢占,共享资源操作完毕再解锁,避免竞态问题。假如某个场景,线程A和线程B的共享一个块内存区域,线程A正在访问共享内存区域,自旋锁被A持有(上锁);此时,线程B也也需要访问共享内存区域,但由于A持有自选锁,所以B暂时无法访问,直至A释放自旋锁(开锁),B获取到自旋锁才继续执行。


自旋锁特点

  自旋锁的特点与其命名匹配,获取不到锁时就是一直在忙等待(原地打转?),占用cpu的同时又不能处理任何任务。比如上述场景,线程B会一直在等待自旋锁,占用cpu。根据自旋锁的特点,自旋锁适用于占用锁时间极短的场景,长时间占用自旋锁会降低系统性能。如果访问资源比较耗时,需长时间持有锁的场景,则需考虑其他保护机制。

  • 自旋锁适用于锁持有时间短的场景
  • 自旋锁可以在中断函中使用,因为自旋锁不会引起阻塞或者睡眠
  • 自旋锁无法保证公平性,不保证先到先获得锁,可能造成“线程饥饿”
  • 自旋锁需保证各个本地缓存数据的一致性;在多核处理器上,每个线程对应的处理器都对同一内存区域读写,每次读写都需同步每个处理器缓存,这可能会影响性能

自旋锁描述

  linux内核使用spinlock_t结构体描述自旋锁,位于"include/linux/spinlock_types.h"中。

typedef struct spinlock {
	union {
		struct raw_spinlock rlock;

#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
		struct {
			u8 __padding[LOCK_PADSIZE];
			struct lockdep_map dep_map;
		};
#endif
	};
} spinlock_t;

自旋锁使用

  自旋锁使用比较简单,首先定义一个自旋锁变量,可以使用DEFINE_SPINLOCK动态申请并初始化一个自旋锁变量;然后使用自旋锁API操作自旋锁,在合适的地方,对共享资源进行加锁访问处理。

  • 自旋锁常用API

引用头文件

#include <linux/spinlock.h>

常用函数

函数 功能
DEFINE_SPINLOCK(x) 定义一个自旋锁变量并初始化
int spin_lock_int(spinlock_t lock) 初始化自选锁
void spin_lock(spinlock_t lock) 获取自旋锁(上锁)
void spin_unlock(spinlock_t lock) 释放自旋锁(解锁)
int spin_trylock(spinlock_t lock) 尝试获取自旋锁,如果没获取到返回0
int spin_is_locked(spinlock_t lock) 查询自旋锁是否被获取,已被获取返回0,否则返回非0

  上述API用于线程并发访问时对共享资源进行保护,但是cpu的中断具有最高优先级,存在抢占线程的可能性。如果中断函数中也需访问共享资源,此时自旋锁就没有保护共享资源的作用。对于这种场景,我们可以关闭本地中断,直接不让中断响应。linux内核提供了关闭本地中断的自旋锁操作API。

函数 功能
void spin_lock_irq(spinlock_t lock) 禁止本地中断,获取自旋锁
void spin_unlock_irq(spinlock_t lock) 激活本地中断,释放自旋锁
void spin_lock_irqsave(spinlock_t lock, unsigned long flags) 保存中断状态,禁止本地中断,获取自旋锁
void spin_lock_irqrestore(spinlock_t lock, unsigned long flags) 回复之前的中断状态,激活本地中断,释放自旋锁
void spin_lock_bh(spinlock_t lock) 关闭中断下半部,获取自旋锁
void spin_unlock_bh(spinlock_t lock) 打开中断下半部,释放自旋锁

  实际应用中,推荐使用“spin_lock_irqsave"spin_lock_irqrestore,因为linux系统功能复杂,存在各类中断,宏观上同一时刻可能存在多个中断,以及中断优先级问题,这样能保存当前中断状态,执行完再恢复中断前状态。


  • 中断中使用自旋锁会怎样?

  既然中断抢占了运行线程,可不可以在中断函数中使用自旋锁来保护共享资源呢?答案是可以的,但要谨慎!因为稍不注意易造成“死锁”。假如线程A持有自旋锁,在访问共享资源,此时,cpu中断到来,转而去执行中断事件;在中断函数内,由于获取不到自旋锁,中断函数一直不退出,线程A又被中断抢占,自旋锁一直在持有。这样就导致了死锁问题。

  中断中使用自旋锁避免死锁问题,应该有两个前提条件。

【1】线程使用spin_lock_irq或者spin_lock_irqsave持有自旋锁
【2】 中断函数使用spin_lock持有自旋锁
【3】如果是中断下半部,则使用spin_lock_bh持有自旋锁


  • 例子
#include <linux/spinlock.h>

DEFINE_SPINLOCK(lock)

void fun0(void)
{
	unsigned long flag = 0;
	
	spin_lock_irqsave(&lock, flag);	/* 获取自旋锁 */
	/* todo 访问共享资源 */
	
	spin_lock_irqrestore(&lock, flag); /* 释放自旋锁 */
}

void irq0(void)
{
	spin_lock(&lock);	/* 获取自旋锁 */
	/* todo 访问共享资源 */
	
	spin_unlock(&lock); /* 释放自旋锁 */
}

自旋锁死锁问题

 使用自旋锁应关注引发死锁的情况。

  • 中断使用自旋锁和线程自旋锁引起的死锁

  • 自旋锁内调用可引起线程睡眠的函数引起死锁,如kmalloccopy_to_user

  • 递归持有自旋锁引起死锁


自旋锁使用注意事项

  • 持有锁的时间不能过长,否则更换为其他机制
  • 线程使用自旋锁应关闭本地中断
  • 自旋锁内不能调用引起睡眠的函数,如用get_free_page()代替kmalloc()申请内存
  • 禁止递归持有和释放自选锁
  • 驱动程序应以多核cpu为标准,提高可移植性

3.4 信号量

  linux内核下的信号量与用户态的信号量类似,本质也是一样的,只不过一个在内核态使用,一个在用户态使用。信号量可以理解为一个资源计数器,存在一个信号计数值,计数值表示资源可用属性,计数值为大于0时才能访问资源,计数值为0表示资源不可访问。


信号量特点

  • 信号量用于线程同步,不能用于互斥访问,资源可以用时,多个线程可同时访问
  • 与自旋锁不同,信号量可以引起线程睡眠,线程获取不到信号量时,线程进入睡眠状态直至获取到信号量

信号量描述

  linux内核使用struct semaphore结构体描述信号量,位于"include/linux/semaphore.h"中。

struct semaphore {
	raw_spinlock_t		lock;
	unsigned int		count;
	struct list_head	wait_list;
};

信号量使用

  信号量使用也比较简单,首先定义一个信号量,可以使用DEFINE_SEMAPHORE动态申请并初始化一个信号量;然后使用信号量API操作信号量,在合适的地方,对信号量进行操作,以达到线程同步的目的。

信号量常用API

引用头文件

#include <linux/semaphore.h>
函数 作用
DEFINE_SEMAPHORE(name) 定义一个信号量,并将信号量值设置为1
void sema_init(struct semaphore *sem, int val) 初始化信号量设置指定值
void down(struct semaphore *sem) 获取信号量,获取不到会使线程睡眠,不能被信号中断
void up(struct semaphore *sem) 释放信号量
int down_trylock(struct semaphore *sem) 尝试获取信号量,获取到信号量返回0,否则返回非0,不会引起线程睡眠
int down_interruptible(struct semaphore *sem) 获取信号量,线程进入休眠后可以被信号中断

例子

#include <linux/semaphore.h>

DEFINE_SEMAMPHORE(sema)	/* 定义并初始化信号量 */

void fun0(void)
{
	unsigned long flag = 0;
		
	down(&sema);		/* 获取信号量 */
	/* todo 访问共享资源 */
	
}

void fun1(void)
{
	up(&sema);			/* 释放信号量 */
	/* todo 访问共享资源 */

}

信号量使用注意事项

  • 信号量一般用于线程同步,当信号量值不大于1时,即是二值信号量,可以实现互斥访问
  • 信号量会引起线程睡眠,引起睡眠的信号量函数不能用于中断中

3.5 互斥体

  互斥体也称为互斥锁,与linux用户态的互斥锁也是类似的。互斥体可以理解为一个二值信号量,互斥体机制使得同一时刻只有一个线程可以访问共享资源,与自旋锁相似,但又有本质的区别。


互斥体特点

  • 互斥访问
  • 可以引起线程睡眠,线程获取不到互斥体(被其他线程占用),线程进入睡眠状态

互斥体描述

  linux内核使用struct mutex结构体描述互斥体,位于"include/linux/mutex.h"中。

struct mutex {
	/* 1: unlocked, 0: locked, negative: locked, possible waiters */
	atomic_t		count;
	spinlock_t		wait_lock;
	struct list_head	wait_list;
#if defined(CONFIG_DEBUG_MUTEXES) || defined(CONFIG_MUTEX_SPIN_ON_OWNER)
	struct task_struct	*owner;
#endif
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
	struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
#ifdef CONFIG_DEBUG_MUTEXES
	void			*magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
	struct lockdep_map	dep_map;
#endif
};

互斥体使用

  互斥体使用,首先定义一个互斥体变量,可以使用DEFINE_MUTEX动态申请并初始化一个互斥体;然后使用互斥体API操作互斥体,在合适的地方,通过互斥体持有/释放,以达到线程共享资源互斥访问的目的。

互斥体常用API

引用头文件

#include <linux/mutex.h>
函数 作用
DEFINE_MUTEX(mutexname) 定义一个互斥体,并初始化
void mutex_init(struct mutex *lock) 初始化一个互斥体
void mutex_lock(struct mutex *lock) 获取互斥体(上锁),获取不到线程睡眠
void mutex_unlock(struct mutex *lock) 释放互斥体(解锁)
int mutex_trylock(struct mutex *lock) 尝试获取互斥体,获取到返回1,否则返回0
int mutex_is_locked(struct mutex *lock) 判断互斥体是否被获取,被获取返回1,否则返回0
int mutex_lock_interruptible(struct mutex *lock) 获取互斥体,线程进入休眠后可以被信号中断

例子

#include <linux/mutex.h>

DEFINE_MUTEX(mutex)	/* 定义并初始化信号量 */

void fun0(void)
{
	unsigned long flag = 0;
		
	mutex_lock(&mutex);			/* 获取互斥体 */
	/* todo 访问共享资源 */
	mutex_unlock(&mutex);		/* 释放互斥体 */
}

互斥体使用注意事项

  • 互斥体会引起线程睡眠,不能用于中断中
  • 持有互斥体必须由持有者释放
  • 互斥体不能递归持有(上锁)和递归释放(解锁)

4 总结

  关于4种竞态资源保护机制,各有优缺点,适用于不同的场景。4种方式使用起来都比较简单,难点在于如何判定不同的场景以选用合适的机制。我们把几种机制罗列为一个表,以方便对比。

机制 是否使线程睡眠 保护资源 适用场景
原子操作 整型变量 整型变量保护
自旋锁 各类资源 加锁时间短
信号量 各类资源 线程同步
互斥体 各类资源 加锁时间较长

  根据4种机制特点,在编写驱动时,可参考下面场景选择。

场景 参考使用机制
低开销加锁 自旋锁
短期加锁 自旋锁
长期加锁 互斥体/信号量
中断上下文中加锁 自旋锁
线程支持睡眠 互斥体/信号量
线程资源同步 信号量
原创文章 128 获赞 147 访问量 36万+

猜你喜欢

转载自blog.csdn.net/qq_20553613/article/details/105566344
今日推荐