1 并发和竞态产生的原因
并发是操作系统编程中的核心问题之一。我们必须要能解决对共享资源的并发访问。
并发产生资源竞争的情况如下:
- 中断和进程之间
- 不同优先级的进程之间
- 不同优先级的中断之间
- 多核的进程之间
访问共享资源(硬件资源,静态变量,全局变量)的代码区域称为临界区(critical section)。临界区需要用某种互斥机制加以保护。包括中断屏蔽,原子操作,自旋锁,信号量,互斥体等。
2 中断屏蔽
local_irq_disable();
// critical section
local_irq_enable();
这种中断的保护,只适用于单核CPU,如果多核CPU,则改中断屏蔽无法屏蔽其它核上的中断调度。也就起不到保护作用。当前,大部分的CPU都是多核CPU,因此使用这种中断屏蔽的方式往往意味着bug。这种中断屏蔽方式跟自旋锁联合使用,才能起到作用。
3 原子操作
列出几个原子操作API:
该函数给原子类型的变量v增加值i。
void atomic_add(int i, atomic_t *v);
该函数从原子类型的变量v中减去i。
atomic_sub(int i, atomic_t *v);
该函数从原子类型的变量v中减去i,并判断结果是否为0,如果为0,返回真,否则返回假。
int atomic_sub_and_test(int i, atomic_t *v);
该函数对原子类型变量v原子地增加1。
void atomic_inc(atomic_t *v);
该函数对原子类型的变量v原子地减1。
void atomic_dec(atomic_t *v);
该函数对原子类型的变量v原子地减1,并判断结果是否为0,如果为0,成功,否则失败。
int atomic_dec_and_test(atomic_t *v);
该函数对原子类型的变量v原子地增加1,并判断结果是否为0,如果为0,成功,否则失败。
int atomic_inc_and_test(atomic_t *v);
原子操作保证当前对于数据的操作是一个不可分割的整体,原子操作既适用于多核的情况也适用于单核的情况。但是原子操作只适用于整型和位的并发操作保护。并且需要CPU的指令集支持这样的锁内存单元操作。
4.自旋锁
自旋锁要解决的问题场景:
单核CPU内不同优先级进程之间抢占(内核可抢占)的问题,使用spin_lock/spin_unlock即可。当某进程持有自旋锁时,机制上禁止高优先级进程的抢断(否则有可能死锁)。
多核CPU间进程抢占的问题,使用spin_lock/spin_unlock即可。
单核CPU内进程被中断抢占的问题,为了防止死锁,需要在进程中使用自旋锁同时禁用中断,演化为spin_lock_irqsave/spin_unlock_irqrestore
多核CPU之间的进程被中断抢占的问题,需要在中断服务程序中加spin_lock/spin_unlock。
总结一句话:进程和中断可能访问同一片临界资源,我们一般需要在进程上下文中调用spin_lock_irqsave/spin_unlock_irqrestore,在中断上下文中调用spin_lock/spin_unlock。
4.1 读写自旋锁
多数并发读数据的场景下,其实不需要加锁,加锁反而会降低效率。因此衍生出了读写自旋锁。 它的原理也很简单,保证只有一个进程在写(写之间不会并发),在读锁碰到读锁时放行,读锁碰到写锁时等待。
部分函数
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);
// 读锁
void read_lock(rwlock_t *lock);
void read_unlock(rwlock_t *lock);
// 写锁
void write_lock(rwlock_t *lock);
void write_unlock(rwlock_t *lock);
4.2 顺序锁
相对于读写锁,顺序锁放开了读和写的并发,但是仍然要保证写和写之间时互斥的。此时可能读到不完整的数据,因此需要根据本次读操作的结果自行进行数据重读。
部分函数
// 写锁
void write_seqlock(seqlock_t *sl);
void write_sequnlock(seqlock_t *sl);
// 读锁
unsigned read_seqbegin(const seqlock_t *sl);
int read_seqretry(const seqlock_t *sl, unsigned iv);
// 重读
do {
seqnum = read_seqbegin(&seqlock_a);
/* 读操作代码块 */
...
} while (read_seqretry(&seqlock_a, seqnum));
5 信号量(Semaphore)
struct semaphore sem; // 定义名称为sem的信号量,是一种面向对象的思想,定义了一个semaphore对象。 void sema_init(struct semaphore *sem, int val); // 初始化信号量
void down(struct semaphore * sem); // 获得信号量
void up(struct semaphore * sem); // 释放信号量
其中,val的值为信号量的个数,也就是同时允许多少个进程共享这个资源。
- 如果val = 0,表示同时只允许1个进程共享某个资源;
- 如果val = 1,表示同时只允许2个进程共享某个资源;
- 依次类推,如果val = n,表示同时只允许n个进程共享某个资源。
其中
val = 0的情况,一般用于互斥锁。
val >= 1的情况,一般用于多个进程间保持同步,即保证时序问题(例如,A、B进程必须等待C进程初始完资源池后才能正常运行),类似一种生产者/消费者模式,如果当前进程操作完成了,通过释放信号量来广播通知其它等待进程去执行各自操作。
由于新的Linux内核倾向于直接使用mutex作为互斥手段,信号量用作互斥不再被推荐使用。
对于进程间的时序问题(例如,一个进程的某些执行必须位于另一个进程之后),信号量仍然是一种不错的选择。
6 互斥体(mutex)
mutex是使用最多的场景之一。
struct mutex my_mutex; /* 定义mutex */
mutex_init(&my_mutex); /* 初始化mutex */
mutex_lock(&my_mutex); /* 获取mutex */
... /* 临界资源 */
mutex_unlock(&my_mutex); /* 释放mutex */
和自旋锁的差异:
互斥体和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者。在互斥体本身的实现上,为了保证互斥体结构存取的原子性,需要自旋锁来互斥。所以自旋锁属于更底层的手段。
若临界区比较小,宜使用自旋锁(若使用互斥体,则进程上下文切换会浪费资源),若临界区很大,应使用互斥体(若使用自旋锁,则等待锁的进程“自旋”对CPU消耗会较多)。
互斥体锁保护的临界区可以包含引起阻塞的代码(如果sleep,copy_to_user)等,而自旋锁则不行。因为阻塞意味着进程切换,如果持有自旋锁阶段进程切换出去后,另一个企图获取该锁的进程就会一直在"旋转",从而发生死锁。
如果在中断服务中使用互斥体,则很可能会获取不到锁,从而进入睡眠。因此中断服务中只能使用自旋锁,因为自旋锁不会导致睡眠。
7 完成量(Completion)
struct completion my_completion; // 定义完成量
init_completion(&my_completion); // 初始化完成量
void wait_for_completion(struct completion *c); // 等待完成量
void complete(struct completion *c); // 唤醒完成量
void complete_all(struct completion *c); // 唤醒所有等待的完成量
跟信号量很类似。
8 总结
并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁和互斥体都是解决并发问题的机制。
中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和互斥体应用最为广泛。 自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。互斥体允许临界区阻塞,可以适用于临界区大的情况。