Linux中的同步机制

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、信号量

自旋锁是实现一种忙等待 锁,信号量则允许进程进入睡眠状态。是操作系统中最常用的同步原语之一 简单来说, 信号量是一个计数器,它支持两个操作,PV,分别表示减少和增加,现在改成了downup

信号量中最经典的例子莫过于生产者和消费者问题, 它是一个操作系统发展历史上最经典的进程同步问题。

假设生产者生产商品,消费者购买商品,通常消费者需要到实体商店或者网上商城购买。用计算机来模拟这个场景,一个线程代表生产者,另一个线程代表消费者,内存代表商店。生产者生产的商品被放置到内存中供消费者消费,消费者线程从内存中获取商品,然后释放内存。当生产者线程生产商品时发现没有空闲内存可用,那么生产者必须等待消费者线程来释放一个空闲内存。当消费者线程购买商品时,发现商店没货了, 那么消费者必须等待,直到新的商品生产出来。

这种情况,如果是自旋锁,当消费者来买东西的时候,发现商店(内存)没有东西了,消费者就搬把凳子坐这里,等到有商品上架。如果是信号量,商店服务员会记录消费者的电话,等到货了就通知消费者来买。

信号量适合用于一些情况复杂、加锁时间比较长的应用场景。

2.3.1、信号量的数据结构

struct semaphore {
	raw_spinlock_t		lock;
	unsigned int		count;
	struct list_head	wait_list;
};
  • lock:lock是自旋锁变量,用于对信号量数据结构中的countwait_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内核中已经有了原子操作、自旋锁、信号量、互斥锁、读写锁等锁机制,为什么要单独设计一个比它们的实现复杂得多的新机制呢?

未完待续…

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值