文章目录
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); /* 释放自旋锁 */
}
自旋锁死锁问题
使用自旋锁应关注引发死锁的情况。
-
中断使用自旋锁和线程自旋锁引起的死锁
-
自旋锁内调用可引起线程睡眠的函数引起死锁,如
kmalloc
、copy_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种机制特点,在编写驱动时,可参考下面场景选择。
场景 | 参考使用机制 |
---|---|
低开销加锁 | 自旋锁 |
短期加锁 | 自旋锁 |
长期加锁 | 互斥体/信号量 |
中断上下文中加锁 | 自旋锁 |
线程支持睡眠 | 互斥体/信号量 |
线程资源同步 | 信号量 |