一、临界区
临界区就是访问和操作临界资源的代码段。多个线程并发访问共享资源是不安全的,所以操作系统提供了一些方法来避免在临界区中并发访问。如下面这道大疆的面试题:
有一个全局变量 i,两个线程并发地执行 i++,请问在两个线程各执行 100 次后,i 的结果为多少?
答案为 100 ~ 200 之间都有可能(不相信的小伙伴们可以自己去试一下)!因为 i++ 看似只有一段代码,但翻译成汇编代码就有三段!这就是为什么我们需要用一些手段来对临界资源进行保护。
二、原子操作
原子操作可以保证操作不被打断(要么执行成功,要么执行失败)。
通常是内联函数,往往是通过内嵌汇编指令来实现。
原子操作的顺序性通过屏障(barrier)指令来实现。
三、自旋锁(spin lock)
自旋锁最多只能被一个可执行线程持有。其他想要获取该锁的线程会一直进行忙循环-旋转-等待锁重新可用。
自旋锁不应该被长时间持有,因为其他想要获取该锁的线程会循环等待。可以使用信号量机制来使得在发生争用时,等待的线程能投入睡眠,而不是旋转。
体系结构相关代码定义在<asm/spinlock.h>,实际需要用到的接口定义在文件<linux/spinlock.h>中。使用形式如下:
DEFINE_SPINLOCK(mr_lock);
spin_lock(&mr_lock)
/* 临界区 */
spin_unlock(&mr_lock)
自旋锁是不可递归的! 如果一个线程试图获取自己持有的锁,那么它自己会自旋,并且没有机会释放锁,导致自己被永远锁死。
自旋锁可以用在中断处理程序中(此处不能使用信号量,因为信号量机制会导致睡眠,前面说过中断处理程序要求尽快执行完)
但是,使用锁可能会导致死锁,比如上面提到的一个线程试图去请求自己已经持有的锁,又或者是每个线程都持有一个锁,并且请求下一个进程所持有的锁,形成一个循环。所以,预防死锁的发生非常重要:
- 按顺序请求锁。只要所有线程按照一定的顺序去请求锁,就可以避免上面第二种情况的死锁。
- 防止发生饥饿。
- 不要重复请求自己已经拥有的锁。
四、信号量(semaphore)
Linux中信号量是一种睡眠锁。如果有一个线程试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。
信号量适用于锁会被长时间持有的情况。
信号量不能在中断程序中使用。因为在中断上下文中是不能进行调度的。
在请求信号量的时候不能占用自旋锁,因为持有自旋锁时是不能睡眠的。
信号量可以同时允许任意数量的锁持有者(可以自己声明),而自旋锁在一个时刻最多允许一个任务持有它。
P() 和 V() 操作,P为获得一个信号量,计数减一,如果减一后计数大于等于0,则获得锁,进入临界区,若小于0则放入等待队列;V为释放一个信号量,计数加一,若等待队列不为空,则等待的任务在被唤醒的同时获得该信号量。
互斥信号量
在一个时刻仅允许一个锁持有者,相当于可以睡眠的自旋锁。此时计数为1。
计数信号量
可以允许多个锁持有者,计数为大于1的非0值。
信号量被定义在文件<asm/semaphore.h>中,可以通过以下方式静态地声明信号量:
struct semaphore name;
sema_init(&name, count);
其中name为变量名,count为计数值。
创建互斥信号量的快捷方式:
static DECLARE_MUTEX(name);
五、互斥体(mutex)
和互斥信号量一样,静态定义互斥体:
static DECLARE_MUTEX(name);
动态初始化:
mutex_init(&mutex)
上锁和解锁:
mutex_lock(&mutex);
/* 临界区 */
mutex_unlock(&mutex);
当持有一个mutex时,进程不可退出(exit)。
mutex不能在中断或者中断下半部中使用。
六、顺序和屏障
当处理多处理器之间或硬件设备之间的同步问题时,有时需要在程序代码中以指定的顺序发出读内存和写内存指令。但是编译器和处理器为了提高效率,可能对读和写重新排序。所以处理器提供了一种机器指令来确保顺序,并且也可以指示编译器不要给定点周围的指令序列重新排序,这种指令称为屏障(barriers)。