1、背景
编写内核代码或驱动代码时需要留意共享资源的保护,防止共享资源被并发访问
。所谓并发访问,就是指多个内核路径同时访问和操作相同地址的数据,有可能发生相互覆盖共享数据的情况,造成被访问数据的不一致,可能会造成系统不稳定或产生错误。通俗点来讲,就是不同的人去修改同一个东西,当某一个人想要去重新获取这个东西时,其实跟前面他写的值不一样了,但是他却不知道,拿回来就用,造成出错。
2、有什么解决办法?
Linux内核提供了多种并发访问的保护机制,例如原子操作
、Per-CPU变量
、自旋锁
、信号量
、互斥体
、读写锁
、RCU
等,我们需要了解Linux内核中各种锁的实现机制,并且要想清楚哪些地方是临界区,该用什么机制来保护这些临界区。
2.1、原子操作与内存屏障
2.1.1、原子操作
原子操作
是指保证指令以原子的方式执行,执行过程中不会被打断。
假设定义了一个全局变量i
,该变量是一个临界资源,CPU0和CPU1都有可能同时访问,发生并发访问。 从CPU角度来看,变量i
是一个静态全局变量,存储在数据段中,首先读取变量的值到通用寄存器中,然后在通用寄存器内做i++运算,最后把寄存器的数字写回变量i所在的内存中。在多处理器架构中,上述动作有可能同时进行。
这个问题该怎么解决呢?大部分人都能说出来:加锁 , 来保证i++操作的原子性,但是加锁操作会导致比较大的开销,用在变量原子操作上有点浪费。所以Linux内核就提供了atomic_t类型
的原子变量。
2.1.2、Per-CPU变量
当系统中的所有CPU都访问共享的一个变量v时,如果CPU0修改这个值,而CPU1也在同时修改这个值,就会导致这个变量v的值不正确。一种可行的方法就是在CPU0访问变量v时使用原子加锁,这样CPU1要访问变量v时,看到有加锁,就只能等待,不会造成变量值的紊乱。但是这样做是有弊端的:原子操作比较耗时
;每个CPU都有L1缓存。当某个CPU对共享数据进行修改后,其他CPU需要将缓存中对应的数据缓存行做无效处理,这样对性能是有损耗的
Per-CPU变量就是为了解决上述问题而出现的。它为系统中的每个处理器都分配了自身的副本
。这样在多处理器的系统中,当处理器只能访问属于自己的那个变量副本时,就不需要考虑与其他CPU的竞争问题了,还能充分利用处理器本地的硬件缓存来提升性能。
2.1.2、原子定义和原子操作
[include/linux/types.h]
typedef struct {
int counter;
} atomic_t;
#define ATOMIC_INIT(i) { i } //声明一个原子变量并初始化为i
#define atomic_read(v) READ_ONCE((v)->counter) //读取原子变量的值
#define atomic_set(v, i) WRITE_ONCE(((v)->counter), (i)) //设置变量v的值为i
static inline void atomic_inc(atomic_t *v) //原子地给v加1
static inline void atomic_dec(atomic_t *v) //原子地给v减1
static inline void atomic_add(int i, atomic_t *v) //原子地给v加i
static inline void atomic_sub(int i, atomic_t *v) //原子地给v减i
#define atomic_dec_return(v) atomic_sub_return(1, (v)) //原子地给v减1并返回v的最新值
#define atomic_inc_return(v) atomic_add_return(1, (v)) //原子地给v加1并返回v的最新值
#define atomic_sub_and_test(i, v) (atomic_sub_return((i), (v)) == 0) //原子地给v减i,结果为0返回true,否则返回false
#define atomic_dec_and_test(v) (atomic_dec_return(v) == 0) //原子地给v减1,结果为0返回true,否则返回false
#define atomic_inc_and_test(v) (atomic_inc_return(v) == 0) //原子地给v加1,结果为0返回true,否则返回false
上述原子操作函数在内核代码中很常见,特别是对一些引用计数进行操作
2.1.2、内存屏障
内存屏障
的内容请参考这篇文章。
2.2、自旋锁
开始讲自旋锁之前,先想想下面几个问题
- 1、原子操作和自旋锁有什么区别?
- 2、自旋锁有什么缺点,又是如何改善优化这些缺点的
- 3、为什么自旋锁的临界区不能睡眠?
- 4、为什么自旋锁的临界区不能发生抢占
如果临界区只是一个变量,那么原子变量就可以解决问题。但临界区如果是一个数据操作的集合,比如先从一个数据结构中移除数据并对其进行解析,然后再将它写回到该数据结构或者其他数据结构中,也就是说涉及到较大数据量
的操作,其整个执行的过程需要保证原子性,不能有其他人来对它访问和改写,涉及较多数据的,使用原子变量就显得不合适了,(不可能把链表中涉及到的所有数据都用atomic_t来定义成原子变量吧)
需要用锁机制
来完成。自旋锁
是Linux内核中最常见的锁机制。
自旋锁同一时刻只能被一个内核代码路径持有,如果有另一个内核代码路径试图获取一个已经被持有的自旋锁,那么这个内核代码路径需要一直忙等待,直到前面持有该所的人释放了该锁。 通俗点来讲,就是有一个房间,这个房间只有一把钥匙,这个时候A过来了,看见钥匙没人拿,就拿钥匙进了房间,然后B在A后面,也想进这个房间,看见钥匙不在了,B就需要一直在那里等,自旋自旋,按照字面理解就是自己一直在那旋转等待,然后等到A完事了,把钥匙拿出来了,这个时候B拿到了钥匙就可以进房间了。
2.2.1、自旋锁的特性
忙等待
:字面意思,就是当来了发现锁已经被别人拿了,就在拿不断尝试不断等待,知道拿到锁为止只有一把锁
:同一时刻只能有一个内核代码路径可以获得该锁要求持锁者要尽快完成工作
:如果拿到锁的人执行任务的时间太长,在锁外面等待的CPU比较浪费,特别是自旋锁临界区里不能睡眠。自旋锁可以在中断上下文使用
2.2.2、自旋锁定义
typedef struct spinlock {
union {
struct raw_spinlock rlock;
};
} spinlock_t;
typedef struct raw_spinlock {
arch_spinlock_t raw_lock;
} raw_spinlock_t;
//下面是arm64上的 arch_spinlock_t数据结构定义
typedef struct {
#ifdef __AARCH64EB__
u16 next;
u16 owner;
#else
u16 owner;
u16 next;
#endif
} __aligned(4) arch_spinlock_t;
//这下面是arm32上面的 arch_spinlock_t数据结构定义
typedef struct {
union {
u32 slock;
struct __raw_tickets {
#ifdef __ARMEB__
u16 next;
u16 owner;
#else
u16 owner;
u16 next;
#endif
} tickets;
};
} arch_spinlock_t;
在Linux 2.6.25之前。自旋锁数据结构就是一个简单的无符号类型变量,slock的值为1表示锁未被持有,值为0或者负数表示锁被持有。但是这样会有一个问题:
当很多CPU在争用一个自旋锁时,会导致严重的不公平性及性能下降(有可能刚刚释放了该锁的CPU马上又可以获得该锁,这对于那些等待了很久的CPU是不公平的)。
后来新增了个"排队自旋锁(ticket-based)"
, slock
被拆分成了两个部分:
ower
:表示锁持有者的等号牌next
:表示外面排队队列中末尾者的等号牌。
a、第一个客户A希望能来拿这个自旋锁,看到next和owner都为0,说明这个锁现在没人用,A直接用餐,next++;
b、第二个客户B来了,看到next是1,代表里面有人正在用餐,服务员给B发了一个等号牌等于1,next++;
c、第三个客户C来了,一看next等于2,说明前面有两个人在等了,服务员给C发了一个等号牌等于2;
d、当A吃完后,owner++,值变为了1,而B的等号牌等于1,所以客户B就餐。
2.2.3、申请自旋锁
static __always_inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
}
#define raw_spin_lock(lock) _raw_spin_lock(lock)
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
spin_lock()函数
最终调用__raw_spin_lock()函数
来实现。可以看到__raw_spin_lock()函数
中第一句实现的是preempt_disable()
,该函数的作用是关闭内核抢占, 这是spinlock锁的实现关键点之一。还记得我们自旋锁开篇问的问题吗:为什么spinlock的临界区不允许发生抢占呢?
- 抢占调度相当于持有自旋锁的进程进入睡眠,这与我们前面说的自旋锁的特性是矛盾的(
自旋锁不允许睡眠,并且拿有锁的进程需要快速执行
) - 抢占调度进程也有可能会去申请自旋锁,而前面的进程还未解锁,就会导致死锁
这里引出一个附带问题:我在kernel-4.14中进行搜索,发现有的驱动里面直接用的raw_spin_lock函数,这个函数不是由spin_lock调用的吗?为什么要直接用它?
因为有一些打上了RT-patch的Linux内核,spin_lock()函数
已经变成了可抢占可睡眠的锁,这样会导致spin_lock的语义被修改,但是当时内核中有很多处使用了spin_lock,直接修改spin_lock工作量巨大,所以就直接把那些真正不允许抢占和休眠的地方,修改为raw_spin_lock函数
.
也就是:在绝对不允许被抢占和睡眠的临界区,应该使用raw_spin_lock,否则使用spin_lock
2.2.4、自旋锁变种
在驱动代码编写过程中常常会遇到一个问题:假设某个驱动中有一个链表,在驱动中很多操作都需要访问和更新该链表,操作链表的地方就是一个临界区,需要自旋锁来保护。但是
,当处于临界区时发生了外部硬件中断,此时系统会暂停当前的任务去处理该中断,假设该中断处理的事务也需要来获取这个自旋锁
,那是完蛋了
,一查询,发现这个自旋锁被别人用了,因此它就一直等一直等,希望这个锁能快点释放,但是本来负责释放这个锁的人,现在在这里等,也就是没人去释放锁了,死锁就这发生了。
为了避免这个问题,Linux内核的自旋锁的变种spin_lock_irq()函数
就出现了,它在获取自旋锁时,顺便关闭了本地CPU的中断
。它的具体实现如下:
static __always_inline void spin_lock_irq(spinlock_t *lock)
{
raw_spin_lock_irq(&lock->rlock);
}
#define raw_spin_lock_irq(lock) _raw_spin_lock_irq(lock)
static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{
local_irq_disable();
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
2.3、信号量
自旋锁是实现一种忙等待 锁,信号量则允许进程进入睡眠状态。是操作系统中最常用的同步原语之一
简单来说, 信号量
是一个计数器,它支持两个操作,P
和V
,分别表示减少和增加,现在改成了down
和up
。
信号量中最经典的例子莫过于生产者和消费者问题, 它是一个操作系统发展历史上最经典的进程同步问题。
假设生产者生产商品,消费者购买商品,通常消费者需要到实体商店或者网上商城购买。用计算机来模拟这个场景,一个线程代表生产者,另一个线程代表消费者,内存代表商店。生产者生产的商品被放置到内存中供消费者消费,消费者线程从内存中获取商品,然后释放内存。当生产者线程生产商品时发现没有空闲内存可用,那么生产者必须等待消费者线程来释放一个空闲内存。当消费者线程购买商品时,发现商店没货了, 那么消费者必须等待,直到新的商品生产出来。
这种情况,如果是自旋锁,当消费者来买东西的时候,发现商店(内存)没有东西了,消费者就搬把凳子坐这里,等到有商品上架。如果是信号量,商店服务员会记录消费者的电话,等到货了就通知消费者来买。
信号量适合用于一些情况复杂、加锁时间比较长的应用场景。
2.3.1、信号量的数据结构
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
lock
:lock是自旋锁变量,用于对信号量数据结构中的count
和wait_list
成员的保护count
:表示允许进入临界区的内核执行路径个数(公共厕所的容纳量)wait_list
:该链表用于管理所有在该信号量上睡眠的进程,没有成功获取锁的进程会睡眠在这个链表上。
2.3.2、信号量的操作
2.3.2.1、信号量的初始化
void sema_init(struct semaphore *sem, int val)
当count等于1时,同一时刻仅允许一个人持有锁,操作系统书中把这种信号量称为互斥信号量。
2.3.2.2、信号量的down操作
void down(struct semaphore *sem); //争用信号量失败时进入不可中断的睡眠状态
int down_interruptible(struct semaphore *sem); //争用信号量失败时进入可中断的睡眠状态
int down_killable(struct semaphore *sem);
int down_trylock(struct semaphore *sem); //返回0表示成功获取锁,返回1表示获取锁失败
int down_timeout(struct semaphore *sem, long jiffies);
2.3.2.3、信号量的up操作
void up(struct semaphore *sem);
2.4、互斥体
互斥体
是一个类似信号量的实现。根据书籍上著名的“洗手间理论”
,信号量相当于一个可以同时容纳N个人的洗手间,只要人不满就可以进去,如果人满了就要在外面等待。 互斥体类似街边的移动洗手间,每次只能进去一个人,里面的人出来后才能让排队的下一个人使用。那么问题来了,互斥体和信号量这么类似,为什么还要重新开发互斥体,而不是复用信号量的机制呢?
总的来说就是:互斥锁比信号量的实现要高效地多。
2.5、读写信号量
上面说的信号量有一个明显的缺点:没有区分临界区的读写属性。
读写锁
通常允许多个线程并发地读访问临界区,但是写访问只限制于一个线程。读写锁
能有效地提高并发性,在多处理器系统中允许同时有多个读者访问共享资源,但写着是排他的,也就是对于大部分线程来说,你们可以同时看,但是有人在看的时候,你就不能写。
2.5.1、读写信号量的特性
允许多个读者同时进入临界区,但同一时刻写者不能进入
同一时刻只允许一个写者进入临界区
读者和写者不能同时进入临界区
2.6、RCU
RCU
的全称是read-copy-update
,是Linux内核中一种重要的同步机制。Linux内核中已经有了原子操作、自旋锁、信号量、互斥锁、读写锁等锁机制,为什么要单独设计一个比它们的实现复杂得多的新机制呢?
未完待续…