01-内核的互斥与同步概述

 本系列文章主要讲述内核中的互斥与同步操作,主要包括内核中的锁机制,信号量和互斥体,讲述了基础概念和常用的API函数接口和代码示例,详细目录如下:
01 - 内核中的互斥与同步概述
02 - 原子变量应用示例
03 - 自旋锁应用示例
04 - 信号量的应用示例
05 - 互斥量的应用示例


本文摘录自《嵌入式Linux驱动开发教程》一书。

1. 原子变量

 如果一个变量的操作是原子性的,即不能再被分割,类似于在汇编代码也只要一条指令就能完成那么对于这样的变量就根本不需要考虑并发带来的影响。

typedef struct {
	int counter;
} atomic_t;

 由上可知,原子变量其实是一个整型变量。需要说明的是对于非整型变量不能使用原子变量来操作。但是在能够使用原子变量时就应该尽量使用原子变量而不要使用复杂的锁机制,因为相比于锁机制他的开销小。主要API接口如下:

int atomic_read(const atomic_t * v)				// 读取原子变量的值
atomic_set(atomic_t * v, int i)					// 设置原子变量v的值为i

void atomic_add(int i, atomic_t * v)			// 将原子变量v的值加i
void atomic_sub(int i, atomic_t * v)			// 将原子变量v的值减i
int atomic_add_return(int i, atomic_t * v)		// 加 _return 表示要返回修改后的值
int atomic_sub_return(int i, atomic_t * v)		
int atomic_add_negative(int i, atomic_t * v)	// 加 _negative 表示当结果为负时返回真

void atomic_inc(atomic_t * v)					// 自加1
void atomic_dec(atomic_t * addr)				// 自减1
int atomic_inc_and_test(atomic_t * v)			// 加 _test 表示当结果为0时返回真
int atomic_dec_and_test(atomic_t * v)
int atomic_sub_and_test(i, v)

void atomic_xchg(atomic_t * v, int new)			// 交换数据

2. 自旋锁

 自旋锁:在访问共享资源之前,首先要获得自旋锁,访问共享资源之后解锁,其他内核路径如果没有竞争到锁,只能忙等待,所以自旋锁是一种忙等锁,最多只能被一个线程持有。
 内核中自旋锁的类型是 spinlock_t,相关的API如下:

void spin_lock_init(spinlock_t * lock)		// 初始化自旋锁,在使用自旋锁之前必须初始化

void spin_lock(spinlock_t * lock)			// 获取自旋锁,如果不能获取自旋锁则进行忙等待
void spin_lock_irq(spinlock_t * lock)		// 获取自旋锁并禁止中断
void spin_lock_irqsave(spinlock_t * lock, unsigned long flags)	// 获取自旋锁并禁止中断,保存中断屏蔽状态到flags中
void spin_lock_bh(spinlock_t * lock)		// 获取自旋锁并禁止下半部

int spin_trylock(spinlock_t * lock)			// 尝试获取自旋锁(非阻塞获取自旋锁),即使不能获取也立即返回,返回值为0表示成功获取自旋锁否则表示没有获得自旋锁
int spin_trylock_bh(spinlock_t * lock)
int spin_trylock_irq(spinlock_t * lock)

void spin_unlock(spinlock_t * lock)			// 释放自旋锁
void spin_unlock_irq(spinlock_t * lock)		// 释放自旋锁并激活中断
void spin_unlock_bh(spinlock_t * lock)		
void spin_unlock_irqrestore(spinlock_t * lock, unsigned long flags)	// 释放自旋锁并让本地中断恢复到之前状态

3. 读写锁 (读共享,写独占)

 在并发的方式中有读——读并发,读——写并发和写——写并发三种,显然一般的读操作并不会修改它的值,因此读和读之间是允许并发的。但是使用自旋锁读操作也会被加锁从而阻止了另外一个读操作,为了提高并发的效率必须要提高锁的粒度以允许读和读之间的并发。因此内核中提供了一种允许读和读并发的锁,叫读写锁,其数据类型是 rwlock_t,常用的API如下:

rwlock_init(lock)				// 初始化自旋锁,在使用自旋锁之前必须初始化

read_lock(lock)					// 获取读锁
write_lock(lock)				// 获取写锁

read_lock_irq(lock)				// 获取读锁并关闭中断
read_lock_irqsave(lock, flags)	// 获取读锁并禁止中断,保存中断屏蔽状态到flags中
read_lock_bh(lock)

write_lock_irq(lock)
write_lock_irqsave(lock, flags)
write_lock_bh(lock)

read_unlock(lock) 
write_unlock(lock)

read_unlock_irq(lock)
read_unlock_irqrestore(lock, flags)	// 读解锁并恢复中断
read_unlock_bh(lock)

write_unlock_irq(lock)
write_unlock_irqrestore(lock, flags)
write_unlock_bh(lock)

 读写锁的使用也需经历定义、初始化、加锁和解锁的过程。当一个内核路径在获取变量的值时,如果另一条路径也要获取变量的值,则读锁可以正常获得,从而另一条路径也能获取变量的值;但如果有一个写正在进行,则不管是读锁还是写锁都不能正常获得只有当写锁释放后才可以。
 注意:读写锁需要比spinlocks更多的访问原子内存操作,如果读临界区不是很大,最好别使用读写锁。读写锁比较适合链表等数据结构,特别是查找远多于修改的情况。RCU比读写锁更适合遍历list,但需要更关注细节。目前kernel社区正在努力用RCU代替读写锁。

4. 顺序锁

 自旋锁不允许读和读之间的并发,读写锁则更进了一步,允许读和读之间的并发,顺序锁则又更进了一步允许读和写之间的并发。
 为了实现这一需求,顺序锁在读时不上锁,也就意味着在读的期间允许写,但是在读之前先读取一个顺序值,读操作完成后再次读取顺序值,如果两者相等说明在读的过程中没有发生写操作否则要重新读。显然写操作要上锁并要更新顺序值。
 顺序所特别适合读很多而写比较少的场合,否则由于反复的读操作也不一定能够获取较高的效率。顺序锁的数据类型是 seqlock_t,其类型定义如下:

typedef struct {
	struct seqcount seqcount;
	spinlock_t lock;
} seqlock_t;

 显然顺序锁使用了自旋锁的机制,并且有一个顺序值 seqcount,主要API如下:

seqlock_init(x)		// 初始化顺序锁
unsigned read_seqbegin(const seqlock_t * sl)	// 读之前获取顺序值,函数返回顺序值
unsigned read_seqretry(const seqlock_t * sl, unsigned start)	// 读之后验证顺序值是否变化,返

void write_seqlock(seqlock_t * sl)		// 写之前加锁
void write_seqlock_irq(seqlock_t * sl)
void write_seqlock_irqsave(lock, flags)
void write_seqlock_bh(seqlock_t * sl)

void write_sequnlock(seqlock_t * sl)	// 写之后解锁
void write_sequnlock_irq(seqlock_t * sl)
void write_sequnlock_irqrestore(seqlock_t * sl, unsigned long flags)
void write_sequnlock_bh(seqlock_t * sl)

示例:
int i = 5;
unsigned long flags;

seqlock_t lock;				/* 定义一个顺序锁 */
seqlock_init(&lock);		/* 初始化顺序锁 */

int v;
unsigned start;
do
{
	start = read_seqbegin(&lock);			/* 读之前获得顺序值 */
	v = i;
} while ( read_seqretry(&lock, start) );	/* 读完之后检查顺序值是否发生变化,变化则要重读 */

write_seqlock_irqsave(&lock, flags);	/* 写之前获取顺序锁              */
i++;
write_sequnlock_irqrestore(&lock, flags);	/* 写之后释放顺序锁         */

5. 信号量

 前面讨论的锁机制都有一个限制,那就是在锁获得期间不能调用调度器,即不能引起进程切换。但是内核中很多函数都可能会触发对调度器的调用,这给驱动开发带来了一定的麻烦。另外我们也知道对于忙等锁来说,当临界的代码执行的比较长的时候会降低系统的效率,为此内核中专门提供了一种叫信号量的机制来取消这一限制,他的数据类型定义如下:

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

 可以看到,他有一个 count 成员,是用来记录信号量资源情况的,当count的值不为0时是可以获得信号量的,当count的值为0时信号量就不能被获取,这也说明信号量可以同时被多个进程所持有。我们还看到一个wait_list成员,不难猜想当信号量不能获取时当前的进程就应该休眠了。最后lock成员在提示我们信号量在底层其实是使用了自旋锁的机制。常用的API接口如下:

void sema_init(struct semaphore * sem, int val)			// 初始化信号量,val是给count成员的初值这样就有val个进程可以同时获得信号量
void down(struct semaphore * sem)						// 获取信号量(count值减1),当信号量的值不为0时,可以立即获取信号量,否则进程休眠
int down_interruptible(struct semaphore * sem)			// 同down,但是能被信号唤醒
int down_trylock(struct semaphore * sem)				// 非阻塞获取信号量,不能获取立即返回,返回0表示成功获取,返回1表示获取失败
int down_timeout(struct semaphore * sem, long timeout)	// 同down,但是在timeout个时钟周期内如果还没有获取信号量则超时返回。返回0表示成功获取信号量,返回负值表示超时
void up(struct semaphore * sem)							// 释放信号量(count加1),如果有进程等待信号量则进程被唤醒

 1. 信号量可以被多个进程所持有,当给信号量赋初值1时,信号量成为二值信号量,也成为互斥信号量。
 2. 如果不能获取信号量,则进程休眠,调度其他的进程执行不会忙等待。
 3. 信号量的获取可能会引起进程切换,不能用在中断上下文中。
 4. 信号量开销比较大,在不违背自旋锁的使用规则下应该优先使用自旋锁。

 相比于自旋锁,信号量可以使线程进入休眠状态,比如 A 与 B、C 合租了一套房子,这个房子只有一个厕所,一次只能一个人使用。某一天早上 A 去上厕所了,过了一会 B 也想用厕所,因为 A 在厕所里面,所以 B 只能等到 A 用来了才能进去。B 要么就一直在厕所门口等着,等 A 出来,这个时候就相当于自旋锁。B 也可以告诉 A,让 A 出来以后通知他一下,然后 B 继续回房间睡觉,这个时候相当于信号量。可以看出,使用信号量会提高处理器的使用效率,毕竟不用一直傻乎乎的在那里“自选”等待。但是,信号量的开销要比自旋锁大,因为信号量使线程进入休眠状态以后会切换线程,切换线程就会有开销。总结一下信号量的特点:
 ①、因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场合。
 ②、因此信号量不能用于中断中,因为信号量会引起休眠,中断不能休眠。
 ③、如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的那点优势。

 信号量的使用如下所示:

struct semaphore sem; /* 定义信号量 */
sema_init(&sem, 1)/* 初始化信号量 */

down(&sem); /* 申请信号量 */
/* 临界区 */
up(&sem); /* 释放信号量 */

6. 互斥量

 信号量除了不能用于中断的上下文,还有一个缺点就是不是很智能。在获取信号量的代码中只要信号量的值为0,进程就马上休眠了。但是更一般的情况是,在不用等待很长的时间后信号量马上就可以获得,那么信号量的操作就要经历使进程先休眠再被唤醒的一个漫长过程。可以在信号量不能获取的时候稍微耐心等待一小段时间,如果在这段时间能够获取信号量,那么获取信号量的操作就可以立即返回,否则再将进程休眠也不迟。
 为了实现这种比较智能化的信号量,内核提供了另外一种专门用于互斥的高效率信号量,也就是互斥量,也叫互斥体,类型为 struct mutex,相关的API如下:

mutex_init(mutex)						// 初始化互斥量
void mutex_lock(struct mutex * lock)	// 获取互斥量
int mutex_lock_interruptible(struct mutex * lock)	
int mutex_trylock(struct mutex * lock)	// 非阻塞获取互斥量,返回1表示成功
void mutex_unlock(struct mutex * lock)	// 释放互斥量

在使用 mutex 之前要先定义一个 mutex 变量。在使用 mutex 的时候要注意如下几点:
 ①、mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁。
 ②、和信号量一样,mutex 保护的临界区可以调用引起阻塞的 API 函数。
 ③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 不能递归上锁和解锁。

7. RCU机制

 RCU(Read-Copy Update)机制即 读——复制——更新。RCU机制对共享内存的访问机制是通过指针来进行性的。读者通过对该指针解引用来获取想要的数据;写着在发起写访问操作的时候,并不是去写以前共享资源内存而是另起炉灶,重新分配一片内存空间,复制以前的数据到新开辟的内存空间,然后修改新分配
的内存空间里面的内容;当写结束后,等待所有的读者完成了对原有内存空间的读取后,将读的指针更新
指向新的内存空间,之后的读操作将会得到更新后的数据。
 常用API接口如下:

void rcu_read_lock(void)	// 读者进入临界区
rcu_dereference(p)			// 读者用于获取共享资源的内存区指针
void rcu_read_unlock(void)	// 读者退出临界区
rcu_assign_pointer(p, v)	// 用新指针更新老指针
void synchronize_rcu(void)	// 等待之前的读者完成读操作

8. 完成量

 同步是指内核中的执行路径要按照一定的顺序来进行。同步可以用信号量来表示,例如对于一个ADC设备来说,可以先初始化一个值为0的信号量,做转换操作的执行路径先用down来获取这个信号量,如果在这之前没有采集到数据那么做转换的操作就会休眠,当采集数据完成之后调用up释放信号量那么做转换的操作将会被唤醒,这就保证了采样和转换的同步。
 内核中转成提供了一个完成量来实现该操作,完成量的结构类型定义如下:

struct completion {
	unsigned int done;
	wait_queue_head_t wait;
};

 变量done表示是否完成的状态,是一个计数值,为0表示未完成,wait是一个等待队列头。当done为0时进程阻塞,当内核中的其他路径使done的值大于0时,负责唤醒被阻塞在这个完成量上的进程。主要API如下:

void init_completion(struct completion * x)
void wait_for_completion(struct completion * x)
int wait_for_completion_interruptible(struct completion * x)
unsigned long wait_for_completion_timeout(struct completion * x, unsigned long timeout)
bool try_wait_for_completion(struct completion * x)
void complete(struct completion * x)
void complete_all(struct completion * x)
发布了57 篇原创文章 · 获赞 64 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_36310253/article/details/102895871
01-
今日推荐