linux的并发和竞态管理

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.自旋锁

自旋锁要解决的问题场景:

  1. 单核CPU内不同优先级进程之间抢占(内核可抢占)的问题,使用spin_lock/spin_unlock即可。当某进程持有自旋锁时,机制上禁止高优先级进程的抢断(否则有可能死锁)。

  2. 多核CPU间进程抢占的问题,使用spin_lock/spin_unlock即可。

  3. 单核CPU内进程被中断抢占的问题,为了防止死锁,需要在进程中使用自旋锁同时禁用中断,演化为spin_lock_irqsave/spin_unlock_irqrestore

  4. 多核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 */ 

和自旋锁的差异:

  1. 互斥体和自旋锁属于不同层次的互斥手段,前者的实现依赖于后者。在互斥体本身的实现上,为了保证互斥体结构存取的原子性,需要自旋锁来互斥。所以自旋锁属于更底层的手段。

  2. 若临界区比较小,宜使用自旋锁(若使用互斥体,则进程上下文切换会浪费资源),若临界区很大,应使用互斥体(若使用自旋锁,则等待锁的进程“自旋”对CPU消耗会较多)。

  3. 互斥体锁保护的临界区可以包含引起阻塞的代码(如果sleep,copy_to_user)等,而自旋锁则不行。因为阻塞意味着进程切换,如果持有自旋锁阶段进程切换出去后,另一个企图获取该锁的进程就会一直在"旋转",从而发生死锁。

  4. 如果在中断服务中使用互斥体,则很可能会获取不到锁,从而进入睡眠。因此中断服务中只能使用自旋锁,因为自旋锁不会导致睡眠。

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 总结

并发和竞态广泛存在,中断屏蔽、原子操作、自旋锁和互斥体都是解决并发问题的机制。

中断屏蔽很少单独被使用,原子操作只能针对整数进行,因此自旋锁和互斥体应用最为广泛。 自旋锁会导致死循环,锁定期间不允许阻塞,因此要求锁定的临界区小。互斥体允许临界区阻塞,可以适用于临界区大的情况。

你可能感兴趣的:(linux的并发和竞态管理)