临界区和竞争条件
临界区:访问和操作共享数据的代码段。
竞争条件:两个或多个执行线程处于同一个临界区中同时执行,那么会存在线程竞争,即竞争条件。
所谓同步,其实防止在临界区中形成竞争条件。
如果临界区里是原子操作(即整个操作完成前不会被打断),那么自然就不会出竞争条件。
但在实际应用中,临界区中的代码往往不会那么简单,所以为了保持同步,引入了锁机制。
保护机制
原子操作
对单个变量保护一般是使用原子操作。
原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换,原子操作通常是内联函数,往往通过内嵌汇编实现。多数处理器都提供了指令来原子地读变量、增加变量,然后再写回变量。两个原子操作交错执行根本就不可能发生,因为处理器会从物理上保证这种不可能。
内核提供了两组原子操作接口:①对整数操作 ②对位进行操作
-
原子整数操作,有32位和64位。头文件分别为
<asm/atomic.h>
和<asm/atomic64.h>
-
原子位操作。头文件
<asm/bitops.h>
原子操作头文件与具体的体系结构有关,比如x86架构的相关头文件在 arch/x86/include/asm/*.h
加锁
原子操作只适合单个变量或比较简单的操作,若需要保护的操作是一段比较复杂的程序,那就需要用到加锁机制了。
可以使用锁对临界区标记,当一个线程已经获取锁后,其它线程将被禁止访问。
Linux中的锁有多种多样的形式,加锁的粒度范围也各不相同,各种锁机制区别主要在于:当锁已经被其他线程持有,因为不可用时的行为表现(一般都是等待)。
锁是采用原子操作实现的,所以加锁解锁过程中不存在竞争。
自旋锁
自旋锁是Linux最常见的锁,自旋锁有如下特点:
- 自旋锁最多只能被一个可执行线程持有。
- 线程试图获取一个已被持有的自旋锁,那么会一直处于等待(自旋)。
- 由于2的特点,所以自旋锁不应该被长时间持有,持有自旋锁的时间最好小于完成两次上下文切换的耗时。
- 自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。
- 线程获取自旋锁之前,要禁止当前处理器上的中断。(防止获取锁的线程和中断形成竞争条件)。
自旋锁和下半部
由于下半部可以抢占进程上下文的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中的共享数据进行保护,即需要在加锁的同时禁止下半部的执行。
中断处理程序也会抢占下半部,所以下半部和中断处理程序有共享数据时,就必须在获取恰当的锁的同时还要禁止中断。
同一种tasklet不能同时运行,所以同类tasklet中的共享数据不需要保护。
不同类tasklet中共享数据时,其中一个tasklet获得锁后,不用禁止其他tasklet的执行,因为同一个处理器上不会有tasklet相互抢占的情况
同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,因为同一个处理器上不会有软中断互相抢占的情况
自旋锁方法列表如下:
spin_lock()
获取指定的自旋锁
spin_lock_irq()
禁止本地中断并获取指定的锁
spin_lock_irqsave()
保存本地中断的当前状态,禁止本地中断,并获取指定的锁
spin_unlock()
释放指定的锁
spin_unlock_irq()
释放指定的锁,并激活本地中断
spin_unlock_irqstore()
释放指定的锁,并让本地中断恢复到以前状态
spin_lock_init()
动态初始化指定的spinlock_t
spin_trylock()
试图获取指定的锁,如果未获取,则返回0
spin_is_locked()
如果指定的锁当前正在被获取,则返回非0,否则返回0
读写自旋锁
读写自旋锁除了和普通自旋锁一样有自旋特性以外,还有以下特点:
读锁之间是共享的
即一个线程持有了读锁之后,其他线程也可以以读的方式持有这个锁
写锁之间是互斥的
即一个线程持有了写锁之后,其他线程不能以读或者写的方式持有这个锁
读写锁之间是互斥的
即一个线程持有了读锁之后,其他线程不能以写的方式持有这个锁
信号量
Linux中的信号量是一种睡眠锁,当一个线程试图获取一个不可用的信号量时,信号量会将线程推进一个等待队列,然后让其睡眠,直到信号量释放时,线程才会被唤醒。
信号量有如下特点:
由于线程获取已被获取的信号量会导致睡眠,不会占用CPU时间,所以信号量可以用于长时间锁的情况。
相反,短时间锁信号量就不太合适了,因为睡眠、维护等待队列以及唤醒需要花费一些开销。
由于信号量可能会导致线程睡眠,所以信号量只能在进程上下文中使用,中断上下文无法使用,因为中断上下文是不能进行调度的。
占用信号量的同时不能占用自旋锁。因为等待信号量时可能会导致睡眠,而持有自旋锁时不允许睡眠的。
二值信号量表示信号量只有2个值,即0和1。信号量为1时,表示临界区可用,信号量为0时,表示临界区不可访问。
二值信号量表面看和自旋锁很相似,区别在于争用自旋锁的线程会一直循环尝试获取自旋锁,
而争用信号量的线程在信号量为0时,会进入睡眠,信号量可用时再被唤醒。
计数信号量有个计数值,比如计数值为5,表示同时可以有5个线程访问临界区。
读写信号量
读写信号量和信号量之间的关系 与 读写自旋锁和普通自旋锁之间的关系 差不多。
读写信号量都是二值信号量,即计数值最大为1,增加读者时,计数器不变,增加写者,计数器才减一。
也就是说读写信号量保护的临界区,最多只有一个写者,但可以有多个读者。
读写信号量的相关内容参见:<asm/rwsem.h> 具体实现与硬件体系结构有关。
互斥体
互斥体(mutex)是一种可以睡眠的锁,相当于二值信号量,但是比信号量简单些。
互斥体特点:
任何时刻中只有一个任务可以持有mutex,即mutex的使用计数永远是1.
给mutex上锁者必须负责给其再解锁
递归地上锁和解锁是不允许的。
当持有一个mutex时,进程不可以退出。
mutex不能在中断或者下半部中使用,只能在进程上下文中使用。
mutex只能通过官网API管理,不能自己实现代码操作。
以上各种锁的关系如下:
需求 | 建议的加锁方法 |
---|---|
低开销加锁 | 优先使用自旋锁 |
短期锁定 | 优先使用自旋锁 |
长期加锁 | 优先使用互斥体 |
中断上下文中加锁 | 使用自旋锁 |
持有锁需要睡眠 | 使用互斥体 |
并发执行的原因
内核中造成并发的原因:
中断 中断随时会发生,也就会随时打断当前执行的代码。如果中断和被打断的代码在相同的临界区,就产生了竞争条件
软中断和tasklet 软中断和tasklet也会随时被内核唤醒执行,也会像中断一样打断正在执行的代码
内核抢占 内核具有抢占性,发生抢占时,如果抢占的线程和被抢占的线程在相同的临界区,就产生了竞争条件
睡眠及用户空间的同步 用户进程睡眠后,调度程序会唤醒一个新的用户进程,新的用户进程和睡眠的进程可能在同一个临界区中
对称多处理 2个或多个处理器可以同时执行相同的代码
我们加锁的对象并不是程序,而是数据,辨认出真正需要共享的数据和相应的临界区,才是真正有挑战的地方。
所以在开始写代码之前就需要考虑加入锁,而不是事后才想到,切勿亡羊补牢。
什么数据需要保护?
如果有其他执行线程可以访问这些数据,那么就给这些数据加上某些形式的锁:如果任何其他什么东西都能看到它,那么就要锁住它。
中断安全代码:在中断处理程序中能避免并发访问的安全代码。
抢占安全代码:在内核抢占是能避免并发访问的安全代码。
SMP安全代码:在对称多处理器的机器中能避免并发访问的安全代码。
死锁
死锁:一个或多个执行线程和一个或多个资源,每个线程都在等待其中一个资源,但所有资源都已经被占用了。所有线程都在相互等待,但它们永远不会释放已经占有的资源。于是任何线程无法继续,这便意味着死锁的发生。
条件:①互斥 ②不可抢占 ③持有并等待 ④循环等待
例子:
最简单的例子就是自死锁:一个执行线程试图去获得一个自己已经持有的锁,则它将会进入死锁状态。
ABBA死锁:A加锁后调用B,B里面再次加锁,则导致死锁
如:
mutex; //代表一个全局互斥对象
void A()
{
mutex.lock();
//这里操作共享数据
B(); //这里调用B方法
mutex.unlock();
return;
}
void B()
{
mutex.lock();
//这里操作共享数据
mutex.unlock();
return;
}
只调用了lock而没有调用unlock就return也会导致死锁。
static int A(void)
{
pthread_mutex_lock(&m);
printf("A:\n");
usleep(100);
return 1;
pthread_mutex_unlock(&m);
}
循环申请锁导致死锁
死锁预防:
按顺序加锁,如线程1以cat、dog,然后是fox的顺序获得了锁,那么其他任何线程都必须以同样的顺序来获得这些锁,也是cat、dog和fox的顺序,若先获取fox然后再dog,就有发生死锁的可能。
防止发生饥饿
不要重复请求同一个锁
设计应力求简单