9 内核同步介绍
防止共享资源并发访问造成系统不稳定。
9.1 临界区和竞争条件
措施:保证临界区代码执行的原子性——也就是说操作在执行结束前不可被打断。
竞争条件:两个执行线程有可能处于同一个临界区中同时执行。
同步:避免并发和防止竞争条件称为同步。
临界区中的事务必须完整的发生,要么干脆不发生,但是绝不能打断。
锁是采用原子操作实现的。
9.2 加锁
在一个时刻只能有一个线程持有锁,所以在一个时刻只有一个线程可以操作队列。
9.2.1造成并发执行的原因
用户空间:因为用户程序可能在任何时刻被调度程序抢占和重新调度,而新调度的进程和之前的进程存在共享数据。
内核空间:
需要特别注意的情况:
- 如果在一段内核代码操作某资源的时候系统产生了一个中断,而且该中断的处理程序还要访问这一资源,这就是一个bug。
- 如果一段内核代码在访问一个共享资源期间可以被抢占,这也是一个bug。
- 如果内核代码在临界区里睡眠,这也是一个bug。(hy:加锁了就不是bug)
- 两个处理器绝对不能同时访问同一共享数据。
9.2.2 了解要保护些什么
任何可能被并发访问的代码都几乎无例外的需要保护。
不需要加锁的情况:
- 执行线程的局部数据。
- 只被特定进程访问的数据。(因为进程一次只在一个处理器上执行)
大多数内核数据结构都需要加锁!
数据加锁经验:如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁;如果任何其他什么东西都能看到它,那么就要锁住它。记住:要给数据而不是给代码加锁。
9.3 死锁
避免死锁的规则:
如果一个函数按照某个顺序获得了锁,那么其他任何函数都必须以同样的顺序来获取这些锁(或是它们的子集)。
只要嵌套的使用多个锁,就必须按照相同的顺序去获取它们。
9.4 争用和扩展性
锁的争用(lock contention)是指当锁正在被占用时,有其他线程试图获得该锁。
由于锁的作用是使程序以串行方式对资源进行访问,所以使用锁无疑会降低系统的性能。
锁加的过粗或过细,差别往往只在一线之间。当锁争用严重时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细会加大系统开销,带来浪费,这两种情况都会造成系统性能下降。但要记住:设计初期加锁方案应该力求简单,仅当需s要时再进一步细化加锁方案。
10 内核同步方法
10.1 原子操作
原子操作是其他同步方法的基石。原子操作可以保证指令以原子的方式执行——执行过程不被打断。
两组原子操作接口:一组针对整数进行操作,另一组针对单独的位进行操作。
有些体系结构确实缺少简单的原子操作指令,但是也为单步执行提供了锁内存总线的指令,确保其他改变内存的操作不能同时发生。
10.1.1 原子整数操作
在大部分体系结构上,读取一个字本身就是一种原子操作,也就是说,在对一个字进行写入操作期间不可能完成对该字的读取。
一个字长的读取总是原子的发生,绝不可能对同一个字交错的进行写;读总是返回一个完整的字,这或者发生在写操作之前,或者之后,绝不可能发生在写的过程中。
顺序性是指读写顺序的要求,通过屏障(barrier)指令实施。
10.1.2 64位原子操作
10.1.3 原子位操作
内核还提供了一组与上述操作对应的非原子位函数。非原子位函数名字前缀多两个下划线。例如,与test_bit()对应的非原子形式是__test_bit().
10.2 自旋锁
自旋锁最多只能被一个可执行线程持有。如果一个执行线程试图获取一个被已经持有的自旋锁,那么该线程就会一直进行忙循环——等待锁重新可用。
在任意时间,自旋锁都可以防止多于一个的执行线程同时进入临界区。
一个被争用的自旋锁使得请求它的线程在等待锁重新可用时自旋(特别浪费处理器时间),所以自旋锁不应该被长时间持有。持有自旋锁的时间最好小于完成两次上下文切换的耗时。
自旋锁的初衷:在短时间内进行轻量级加锁。
10.2.1 自旋锁方法
DEFINE_SPINLOCK(mr_lock); spin_lock(&mr_lock); /*临界区...*/ spin_unlock(&mr_lock); |
自旋锁为多处理器机器提供了防止并发访问所需的保护机制。
单处理器编译的时候并不会加入自旋锁;如果禁止内核抢占,那么在编译时自旋锁会被完全剔除出内核。
自旋锁不可递归!
在中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁止本地中断(hy:进入中断后,arm架构硬件上会自动屏蔽掉所有中断),否则,中断处理程序就会打断正持有锁的内核代码。注意,需要关闭的只是当前处理器上的中断。如果中断发生在不同的处理器上,即使中断处理程序在同一锁上自旋,也不会妨碍锁的持有者(在不同处理器上)最终释放锁。
内核提供禁止中断同时请求锁的接口,如下。
DEFINE_SPINLOCK(mr_lock); unsigned long flags; spin_lock_irqsave(&mr_lock,flags); /*临界区...*/ spin_unlock_irqrestore(&mr_lock,flags); |
函数spin_lock_irqsave()保存中断的当前状态,并禁止本地中断,然后再去获取指定的锁。
DEFINE_SPINLOCK(mr_lock); spin_lock_irq(&mr_lock); /*临界区...*/ spin_unlock_irq(&mr_lock); |
如果可以确定中断在加锁前是激活的,可以使用spin_lock_irq()和spin_unlock_irq()。
10.2.2 其他针对自旋锁的操作
10.2.3 自旋锁和下半部
spin_lock_bh()和spin_unlock_bh(): 获取锁的同时会禁止所有下半部的执行。
下半部和进程共享数据时,由于下半部可以抢占进程,必须对进程中共享的数据进行保护。
同类型的tasklet共享数据 |
无需加锁 |
两个相同类型的tasklet不允许同时执行。 |
不同类型的tasklet共享数据 |
加自旋锁 |
不同类型的tasklet可以在不同的处理器上同时执行。这里不需要禁止下半部,因为在同一处理器上绝不会有tasklet相互抢占的情况。 |
软中断共享数据 |
加自旋锁 |
不管是相同类型还是不同类型的软中断,都可以在不同的处理器上同时执行。但是同一处理器上的一个软中断绝不会抢占另一个软中断,因此没有必要禁止下半部 |
进程和下半部共享数据 |
(进程) 1.禁止下半部处理 2.加锁 |
|
中断上半部和下半部共享数据 |
(下半部) 1.禁止中断 2.加自旋锁 |
|
工作队列中共享数据 |
加锁 |
|
10.3 读-写自旋锁
10.4 信号量
信号量比自旋锁提供了更好的处理器利用率,但是,信号量比自旋锁开销更大。
往往在需要和用户空间同步时,代码会需要睡眠,此时使用信号量是唯一的选择。
自旋锁会禁止内核抢占,信号量不会禁止内核抢占。
10.4.1 计数信号量和二值信号量
信号量可以同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有它。一般情况下,基本上用到的都是互斥信号量(计数等于1的信号量)。计数大于1的信号量称为计数信号量,它允许在一个时刻多个执行线程访问临界区,计数信号量不能用来进行强制互斥。
信号量支持两个原子操作P(探查操作)和V(增加操作),后来叫做down()和up()。
10.4.2 创建和初始化信号量
10.5 读-写信号量
10.6 互斥体
10.6.1 信号量和互斥体
优先使用互斥体。
10.6.2 自旋锁和互斥体
中断上下文只能使用自旋锁,而在任务睡眠时只能使用互斥体。
10.7 完成变量
如果在内核中一个任务需要发出信号通知另一任务发生了某个特定事件,利用完成变量是使两个任务得以同步的简单方法。如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。
|
10.8 BKL:大内核锁
大内核锁是一个全局自旋锁。现在已经消失。
10.9 顺序锁
Seq锁在如下情况下是最理想的选择:
Jiffies是一个64位变量,记录了自系统启动以来的时钟节拍累加数。定时器中断会更新jiffies的值。使用get_jiffies_64()读取jiffies时,使用的就是顺序锁。
10.10 禁止抢占
10.11 顺序和屏障
编译器和处理器为了提高效率,可能对读和写重新排序(X86不会)。但是我们有时希望以指定的顺序发出读内存和写内存的指令。
内存屏障:rmb()、wmb()、mb()等,确保跨越屏障的载入和存储操作不发生重排。
编译器屏障:barrier(),可以防止编译器跨屏障对载入或存储操作进行优化。(轻快)