Linux内核设计与实现(五)| 内核同步
对于用户空间而言内核中有类似可能造成并发执行的原因。简单的死锁案例编写代码时如何避免死锁SPARC体系结构:低8位的锁(已破译)原子整型的使用原子性与顺序性的比较1.2 原子位操作概述总结2.自旋锁概述2.1 自旋锁方法概述自旋锁与中断处理程序锁什么?死锁读写锁是可以重入的读写锁的API4.信号量概述信号量的特点4.1 计数信号量和二值信号量概述二值信号量计数
文章目录
什么是内核同步
1.临界区和竞争条件
- 临界区
我们把对共享内存进行访问的程序片段称为临界区,在这个临界区的代码必须是原子执行的,在执行结束前是不可以被打断的
- 竞争条件
如果两个执行线程有可能处于同一临界区中执行,并且这种情况确实发生了,我们称它为竞争条件,命名的原因就是这里可能产生线程竞争
- 同步
因为竞争引起的错误非常容易忽视,调试起来困难重重,避免并发和防止竞争条件称为同步
- 忙等待的互斥
所谓忙等待,就是另外一个无法进入临界区执行代码的进程会在临界区外不断的轮询自己是否可以进入的行为称为忙等待
- 互斥
以某种手段确保进程使用一个共享文件或变量时,其他进程不能做相同的操作,即互斥,是避免竞争的重要方式;
2.加锁
- 场景描述
假设我们现在要处理一个更加复杂的竞争条件,例如现在有一个队列上面有请求,底层实现为链表,每个节点代表一个请求,新请求加入队尾,被处理的请求从对头删除,如果一个线程试图读取队列,而这时正好另一个线程正在处理该队列,那么读取线程就会发现队列此刻正处于不一致状态。很明显,如果允许并发访问队列,就会产生危害。当共享资源是一个复杂的数据结构时,竞争条件往往会使该数据结构遭到破坏。
此时好像没有什么办法阻止另一个线程进入队列,体系中的原子指令对于这种不定长的临界区很难提供保护,所以我们需要一个机制确保一次仅有一个线程对数据结构进行操作,即锁机制。你可以将锁理解为门锁,门后的房间为临界区,指定时间内,只有一个线程在房间内,进入房间后会锁住房门,结束操作后走出房间打开门锁下一个线程重复
那么对于上面队列的例子,在进行新请求入队和删除请求时都需要获得锁才能继续后续操作
2.1 造成并发执行的原因
对于用户空间而言
用户空间的程序可能会被调度程序抢占和重新调度,由于用户进程可以在任何时间抢占,而调度程序完全可以选择一个高优先级的进程到处理器上执行,会使得一个程序正处于临界区时,被非自愿地抢占了。如果新调度的进程随后也进入同一个临界区(比如说,这两个进程要操作共享的内存,或者向同一个文件描述符中写入),前后两个进程相互之间就会产生竞争。另外,因为信号处理是异步发生的,所以,即使是单线程的多个进程共享文伸,或者在一个程序内部处理信号,也有可能产生竞争条件。这种类型的并发操作——这里其实两者并不真是同时发生的,但它们相互交叉进行,所以也可称作伪并发执行。
内核中有类似可能造成并发执行的原因。
- 中断——中断几乎可以在任何时刻异步发生,也就可能随时打断当前正在执行的代码。
- 软中断和tasklet——内核能在任何时刻唤醒或调度软中断和 tasklet,打断当前正在执行的代码。
- 内核抢占——因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占。
- 睡眠及与用户空间的同步——在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而导致调度一个新的用户进程执行。
- 对称多处理——两个或多个处理器可以同时执行代码。
2.2 了解需要保护什么
- 概述
其实锁机制的本身实现并不困难,真正困难的是发现潜在并发执行的可能,并有意采取某些措施来防止并发执行;即辨别出真正需要保护的数据和相应的临界区。所以我们需要在编写代码的开始阶段就要考虑加什么样的锁
- 总结
想要知道需要给什么数据加锁,我们可以反向思考什么代码需要保护加锁呢?大多数内核数据结构都需要加锁!有一条很好的经验可以帮助我们判断:如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁;如果任何其他什么东西都能看到它,那么就要锁住它。记住:要给数据而不是给代码加锁。
所以你在编写代码时就可以思考下面的问题:
- 这个数据是不是全局的?除了当前线程外,其他线程能不能访问它?
- 这个数据会不会在进程上下文和中断上下文中共享?它是不是要在两个不同的中断处理程序中共享?
- 进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?
- 当前进程是不是会睡眠(阻塞)在某些资源上,如果是,它会让共享数据处于何种状态?
- 怎样防止数据失控?
- 如果这个函数又在另一个处理器上被调度将会发生什么呢?
- 如何确保代码远离并发威胁呢?
3.死锁
- 概述
复习一下死锁产生的四个条件:
互斥条件
:一个资源每次只能被一个进程使用;请求与保持条件
:一个进程因请求资源而阻塞时,对已获得的资源保持不放;不剥夺条件
:进程已获得的资源,在末使用完之前,不能强行剥夺;循环等待条件
:若干进程之间形成一种头尾相接的循环等待资源关系;
简单的死锁案例
/**
* @author lhj
* @create 2022/6/24 21:41
*/
public class LockTest {
public static void main(String[] args) {
HashMapTest t1 = new HashMapTest();
HashMapTest t2 = new HashMapTest();
new Thread(()->{
synchronized (t1){
try {
Thread.sleep(2000);
synchronized (t2){
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(()->{
synchronized (t2){
synchronized (t1) {
}
}
}).start();
}
}
编写代码时如何避免死锁
- 按顺序加锁。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其他人也能照此顺序使用。即不同线程来抢嵌套锁也要按照一样的顺序进行,同样释放也要是获取锁的反向进行释放
- 防止发生饥饿。试问,这个代码的执行是否一定会结束?如果“张”不发生?“王”要一直等待下去吗?
- 不要重复请求同一个锁。
- 设计应力求简单——越复杂的加锁方案越有可能造成死锁。
4.争用和扩展性
- 争用
是指锁正在被占用时,有其他线程来获取该锁,那么该锁处于高度争用状态,可以参考Java的锁升级,如果有多个线程来等待获取锁,锁的串行化性质会让并发性能大幅下降,但是相比于几个线程把内核搞得混乱也是可以接收的
- 扩展性
扩展性( scalability)是对系统可扩展程度的一个量度。对于操作系统我们在谈及可扩展性时就会和大量进程、大量处理器或是大量内存等联系起来。其实任何可以被计量的计算机组件都可以涉及可扩展性。理想情况下,处理器的数量加倍应该会使系统处理性能翻倍。而实际上,这是不可能达到的。
内核同步方法
1.原子操作
- 概述
我们首先介绍同步方法中的原子操作,这是所有同步方法的鼻祖,原子操作可以保证指令以原子的方式执行并且执行过程不被打断
- Linux提供的原子接口
在Linux中提供两组原子操作接口:一组针对于整数进行操作,另一组针对单独的位进行操作
1.1 原子整数操作
- 概述
针对整数的原子操作只能对
atomic_t
类型的数据进行处理。在这里之所以引入了一个特殊数据类型,而没有直接使用C语言的int
类型,主要是出于两个原因:
- 首先,让原子函数只接收atomic t类型的操作数,可以确保原子操作只与这种特殊类型数据一起使用。
- 同时,这也保证了该类型的数据不会被传递给任何非原子函数。
typedef struct{
volatile int counter;
}atomic_t;
- SPARC体系结构:低8位的锁(已破译)
在Linux中整数数据都是32位的,但是在SPARC体系结构原子整数的值范围只能用到24位,因为32位中低8位被嵌入了一个锁
- 原子整型的使用
//定义一个atomic_t类型的数据方法很平常,
//你还可以在定义时给它设定初值
atomic_t v; /* 定义v */
atomic_t u = ATOMIC_INIT(0);/*定义u并把它初始化为0*/
//操作也都非常简单:
atomic_set ( &v,4); /*v = 4 (原子地)*/
atomic_add (2 , &ev); /*v = v+2 - 6(原子地)*/
atomic_inc (&v); /* v - v + 1 =7{原子地)*/
//如果需要将atomic_t转换成int型,可以使用atomic_read()来完成:
printk ( "%d\n" ,atomic_read(&v)) ; /*会打印"7"★ /
原子性与顺序性的比较
关于原子读取的上述讨论引发了原子性与顺序性之间差异的讨论。正如所讨论的,一个字长的读取总是原子地发生,绝不可能对同一个字交错地进行写;读总是返回一个完整的字,这或者发生在写操作之前,或者之后,
绝不可能发生在写的过程中
。这就是我们所谓的原子性。
也许代码比这有更多的要求。或许要求读必须在待定的写之前发生——这种需求其实不属于原子性要求,而是顺序要求。原子性确保指令执行期间不被打断,要么全部执行完,要么根本不执行。另一方面,顺序性确保即使两条或多条指令出现在独立的执行线程中,甚至独立的处理器上,它们本该的执行顺序却依然要保持。
- 64位的原子整数操作
随着64位体系的普及,64位的原子整数也应运而生,
atomic64_t
与32位的用法一致
typedef struct {
volatile long counter;
}atomic64_t;
1.2 原子位操作
- 概述
除了原子整数操作外,内核也提供了–组针对位这一级数据进行操作的函数。没什么好奇怪的,它们是与体系结构相关的操作,定义在文件<asm/bitops.h>中。
令人感到奇怪的是位操作函数是对普通的内存地址进行操作的。它的参数是一个指针和一个位号,第0位是给定地址的最低有效位。在32位机上,第31位是给定地址的最高有效位而第32位是下一个字的最低有效位。虽然使用原子位操作在多数情况下是对一个字长的内存进行访问,因而位号应该位于0~31(在64位机器中是0~63),但是,对位号的范围并没有限制。
unsigned long word = 0;
set_bit (0 , &word) ;/*第0位被设置(原子地)*/
set_bit(1 , &word) ;/*第1位被设置(原子地)*/
printk ( "%u1\n" , word) ;/*打印3*/
clear_bit(1 ,&word) ;/*清空第1位*/
change_bit (0 , &word) ;/*翻转第О位的值,这里它被清空*/
/*原子地设置第0位并且返回设置前的值(0)*/
if(test_and_set_bit (0, &word){
/*永远不为真*/
}
/*下面的语句是合法的;你可以把原子位指令与一般的c语句混在一起*/
word n 7 ;
- 总结
与原子整数操作不同,代码一般无法选择是否使用位操作,它们是唯的、具有可移植性的设置特定位方法,需要选择的是使用原子位操作还是非原子位操作。如果你的代码本身已经避免了竞争条件,你可以使用非原子位操作,通常这样执行得更快,当然,这还要取决于具体的体系结构。
2.自旋锁
- 概述
在实际场景中不会像原子整数那样增加变量来控制临界区就可以,临界区甚至可以跨越多个函数,这就需要锁的支持了
在Linux中最常见的就是自旋锁,自旋锁可以最多被一个可执行线程持有,如果发生争用,那么尝试获取锁的线程会一直进行忙循环(在原地旋转,等待锁重新可用),一个被争用的自旋锁使得请求它的线程在等待锁重新可用时自旋(特别浪费处理器时间),这种行为是自旋锁的要点。所以自旋锁不应该被长时间持有。事实上,这点正是使用自旋锁的初衷:在短期间内进行轻量级加锁。还可以采取另外的方式来处理对锁的争用:让请求线程睡眠,直到锁重新可用时再唤醒它。可以明显看到自旋锁的使用策略,再次推荐看一下Java的锁升级
但同时上面将请求线程睡眠在唤醒,会带来一定的开销,这里有两次明显的上下文切换,被阻塞的线程要换出换入,因此持有自旋锁的时间最好少于完成两次上下文切换的耗时,即持有自旋锁的时间尽可能短
2.1 自旋锁方法
- 概述
自旋的代码往往通过汇编编写,注意Linux中的自旋锁不支持重入(你处于忙等待就没有机会释放锁了)
DEFINE_SPINLOCK (mr_1ock);
spin_lock ( &mr_lock);
/*临界区...*/
spin_unlock ( &mr_lock);
因为自旋锁在同一时刻至多被一个执行线程持有,所以一个时刻只能有一个线程位于临界区内,这就为多处理器机器提供了防止并发访问所需的保护机制。注意在单处理器机器上,编译的时候并不会加入自旋锁。它仅仅被当做一个设置内核抢占机制是否被启用的开关。如果禁止内核抢占,那么在编译时自旋锁会被完全剔除出内核。
- 自旋锁与中断处理程序
中断处理程序可以使用自旋锁(不能用信号量,会导致睡眠),然后要禁止本地中断(当前处理器的中断请求,不同处理器不会影响),否则中断处理程序会打断正持有锁的内核代码,有可能去争用这个已经被持有的锁,中断处理程序会等待锁释放,但锁的持有者等着中断处理程序结束才会执行释放,这就有产生了死锁
锁什么?
使用锁的时候一定要对症下药,要有针对性。要知道需要保护的是数据而不是代码。尽管本章的例子讲的都是保护临界区的重要性,但是真正保护的其实是临界区中的数据,而不是代码。
大原则:针对代码加锁会使得程序难以理解,并且容易引发竞争条件,正确的做法应该是对数据而不是代码加锁。
- 自旋锁的方法列表
2.3 自旋锁与下半部
- 概述
由于下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中的共享数据进行保护,所以需要加锁的同时还要禁止下半部执行。同样,由于中断处理程序可以抢占下半部,所以如果中断处理程序和下半部共享数据,那么就必须在获取恰当的锁的同时还要禁止中断。
- 回忆tasklet
tasklet中我们要求同类型是无法同一时间在不同处理器运行的,但是不同类的tasklet也有可能访问共享数据,那么就需要访问下半部前获得一个普通的自旋锁,这里不需要禁止下半部(没有同一处理器tasklet相互抢占的情况)
- 回忆软中断
如论是否为同种类型,我们都要求得到锁的保护;同样没必要禁止下半部(理由同上)
3.读-写自旋锁
- 概述
有时,锁的用途可以明确的分为读取和写入,只要其他程序没有写操作,多个并发的读操作是安全的,在前面探讨过得任务链表(存进程的)就是这种情况,通过读写锁去获取保护
/*读/写自旋锁的使用方法类似于普通自旋锁,它们通过下面的方法初始化:*/
DEFINE_RWLOCK(mr_rwlock);
//然后,在读者的代码分支中使用如下函数:
read_lock ( &mr_rwlock) ;
/*临界区(只读)…*/
read_unlock ( &mr_rwlock) ;
//最后,在写者的代码分支中使用如下函数:
write_lock ( &mr_rwlock) ;
/*临界区〈读写)…*/
write_unlock ( &mr_rwlock) ;
//通常情况下,读锁和写锁会位于完全分割开的代码分支中,如上例所示。
- 死锁
执行下面两个函数将会带来死锁,因为写锁会不断自旋,等待所有的读者释放锁,其中也包括它自己。所以当确实需要写操作时,要在一开始就请求写锁。如果写和读不能清晰地分开的话,那么使用一般的自旋锁就行了,不要使用读一写自旋锁。
//注意,不能把一个读锁“升级”为写锁。比如考虑下面这段代码:
read_lock ( &mr_rwlock);
write_lock ( &mr_rwlock) ;
- 读写锁是可以重入的
如果中断处理程序只有读操作没有写操作,这就可以混合使用禁止中断的锁方法,比如读操作就不需要进行本地中断的禁止,但是写操作就要禁止本地中断;如果写操作没有这样设置就会造成中断的读操作可能锁死在写锁上(因为触发中断的程序可能包含写操作啊,此时由于读锁没有释放完成,写操作自旋,而读操作只能包含在写操作的中断返回后才能继续,释放读锁,死锁发生,所以我们不希望有写操作发生)
- 读写锁的API
4.信号量
- 概述
Linux中的信号量是一种睡眠锁,如果有一个任务试图获取一个不可用(已经被占用)的信号量时,信号量会将其推进到一个等待队列,然后让其睡眠,这时处理器重获自由去执行其他代码,当持有信号量可用(被释放)后,处于等待队列的任务将会被唤醒从而获得信号量
拿开门的例子,第一个进去并拿钥匙反锁,第二个没有钥匙的人就会在一个访问表中写上自己的名字,然后去打鼾,房间的人出来看表里面有没有人有的话去叫醒,这种方式下
钥匙=信号量、每个人=线程、进入房间=临界区
,利用率比自旋锁高但是信号量的开销会比自旋更大
- 信号量的特点
- 适合锁会被长时间的占用,如果很短,那睡眠、维护等待队列以及唤醒都要比占用时间长了
- 由于执行的线程如果有获取信号量的步骤,恰巧信号量被占用,那么此执行的线程会睡眠,所以只能在进程上下文上才应该去获取信号量锁,而不是中断上下文,因为后者无法进行调度;即支持内核抢占
- 你可以在获取到信号量去睡眠,别的线程也不会因此而产生死锁
- 在你占用信号量的同时不能占用自旋锁。因为在你等待信号量时可能会睡眠而在持有自旋锁时是不允许睡眠的。
4.1 计数信号量和二值信号量
- 概述
最后要讨论的是信号量的一个有用特性,它可以同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有它。信号量同时允许的持有者数量可以在声明信号量时指定。这个值称为使用者数量(usage count)或简单地叫数量(count)。
- 二值信号量
通常情况下,信号量和自旋锁一样,同一时刻仅允许有一个锁持有者,这时计数为1,这种信号量被称为二值信号量(要么一个持有,那么没有持有)
- 计数信号量
初始化信号量将数量设置成大于1的非0值,被称为计数信号量信号量,它允许在一个时刻至多有count个锁持有者计数信号量不能用来进行强制互斥,因为它允许多个执行线程同时访问临界区。
- 信号量的原子操作
支持两个原子操作
P()和V()
,后来OS将两种称为down()和up()
。
- 前者通过对信号量计数减1来请求获得一个信号量,如果结果为0或大于0则成功,如果为负数,则将任务放入等待队列,处理器执行其他任务
- 后者用来释放信号量,增加信号量的计数值,如果该信号量的等待队列不为空,那么队列的任务被唤醒同时获得该信号量
4.2 创建和初始化信号量
//semaphore类型用来表示信号量。可以通过以下方式静态地声明信号量
struct semaphore name ; //其中name是信号量
sema_init ( &name,count) ; //count是信号量的使用数量:
//创建更为普通的互斥信号量可以使用以下快捷方式,
static DECLARE_MUTEX(name) ;
//更常见的情况是,信号量作为一个大数据结构的一部分动态创建。
//此时,只有指向该动态创建的信号量的间接指针,
sema_init(sem, count) ;
//与前面类似,初始化一个动态创建的互斥信号量时使用如下函数:
init_MUTEX(sem) ;
4.3 使用信号量
down_interruptible()与down()
- 函数
down_interruptible()
试图获取指定的信号量,如果信号量不可用,它将把调用进程置成TASK_INTERRUPTIBLE
状态——进入睡眠。这种进程状态意味着任务可以被信号唤醒,一般来说这是件好事。如果进程在等待获取信号量的时候接收到了信号,那么该进程就会被唤醒,而函数down_interruptible()
会返回-EINTR
。
- 另外一个函数
down()
会让进程在TASK_UNINTERRUPTIBLE
状态下睡眠。你应该不希望这种情况发生,因为这样一来,进程在等待信号量的时候就不再响应信号了。因此,使用down_interruptible()
比使用down()
更为普遍(也更正确)。
- 获取和释放
/*定义并声明一个信号量,名字为mr_sem,用于信号量计数*/
static DECLARE_MUTEX(mr_sem);
/*试图获取信号量... */
if (down_interruptible (&mr_sem))
/*信号被接收,信号量还未获取*/
}
/*临界区... */
/*释放给定的信号量*/
up ( &mr_sem);
- 相关方法
5.读-写信号量
- 概述
与自旋锁一样,信号量也分为读写访问的可能;所有读写信号量都是互斥信号量,他们的引用计数等于1,虽然他们只对写者互斥,不对读者,所以同理是要没有写者,并发持有读者数不限;相反只有唯一的写者没有读者时,可以获取写锁,所以读写锁的睡眠不会被信号打断
//通过以下语句可以创建静态声明的读-写信号量:
static DECLARE_RWSEM (name) ;
//动态创建的读一写信号量可以通过以下函数初始化:
init_rwsem (struct rw_semaphore *sem).
注意
读写信号量同样有尝试获取读写锁的方法,如果成功获取信号锁与普通的信号量相比如果有争用返回的是0,注意与普通信号量完全相反
- 与读写自旋锁相比
- 读一写信号量相比读一写自旋锁多一种特有的操作:
downgrade_write()
。这个函数可以动态地将获取的写锁转换为读锁,锁降级。- 读–写信号量和读一写自旋锁一样,除非代码中的读和写可以明白无误地分割开来,否则最好不使用它。再强调一次,读一写机制使用是有条件的,只有在你的代码可以自然地界定出读―写时才有价值。
6.互斥体
- 概述
虽然信号量带来的睡眠的锁在某些复杂场景下会比自旋锁更加适合,更加轻量,但是简单的锁定而使用信号量并不方便,并且信号量也缺乏强制的规则来行使任何形式的自动调试,即便受限的调试也不可能。为了找到一个更简单睡眠锁,内核开发者们引入了互斥体(mutex)。
mutex在内核中对应数据结构mutex,其行为和使用计数为1的信号量类似,但操作接口更简单,实现也更高效,而且使用限制更强。
- 使用互斥体
//静态地定义mutex,你需要做:
DEFINE_MUTEX (name);
//动态初始化mutex,你需要做:
mutex_init ( &mutex);
//对互斥锁锁定和解锁并不难:
mutex_lock ( &mutex) ;
/*临界区*/
mutex_unlock ( &mutex);
- 互斥量的简单反而带来了使用上的限制
- 任何时刻中只有一个任务可以持有mutex,也就是说,mutex的使用计数永远是1。
- 给mutex上锁者必须负责给其再解锁——你不能在一个上下文中锁定一个mutex,而在另一个上下文中给它解锁。这个限制使得mutex不适合内核同用户空间复杂的同步场景。最常使用的方式是:在同一上下文中上锁和解锁。
- 递归地上锁和解锁是不允许的。也就是说,你不能递归地持有同一个锁,同样你也不能再去解锁一个已经被解开的mutex.
- 当持有一个mutex时,进程不可以退出。
- mutex不能在中断或者下半部中使用,即使使用mutex_trylock()也不行。
- mutex 只能通过官方API管理:它只能使用上节中描述的方法初始化,不可被考贝、手动初始化或者重复初始化。
6.1 信号量和互斥体
互斥体和信号量很相似,内核中两者共存会令人混淆。所幸,它们的标准使用方式都有简单的规范:除非mutex 的某个约束妨碍你使用,否则相比信号量要优先使用mutex。当你写新代码时,只有碰到特殊场合(一般是很底层代码)才会需要使用信号量。因此建议首选mutex。如果发现不能满足其约束条件,且没有其他别的选择时,再考虑选择信号量。
6.2 自旋锁和互斥体
- 概述
了解何时使用自旋锁,何时使用互斥体(或信号量)对编写优良代码很重要,但是多数情况下,并不需要太多的考虑,因为在中断上下文中只能使用自旋锁,而在任务睡眠时只能使用互斥体。
自此我们总结一下不同场景下的锁使用
7.完成变量
- 概述
如果在内核中一个任务需要发出信号通知另一任务发生了某个特定事件,利用完成变量是使两个任务得以同步的简单方法。
如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。这听起来很像一个信号量,的确如此——思想是一样的。事实上,完成变量仅仅提供了代替信号量的一个简单的解决方法。例如,当子进程执行或者退出时,vfork()系统调用使用完成变量唤醒父进程。
- 相关方法
8.BLK:大内核锁
- 概述
BKL(大内核锁)是一个全局自旋锁,使用它主要是为了方便实现从Linux最初的SMP过渡到细粒度加锁机制。现在内核的部分会用到这个老旧的锁
- 特性
- 持有BKL的任务仍然可以睡眠。因为当任务无法被调度时,所加锁会自动被丢弃;当任务被调度时,锁又会被重新获得。当然,这并不是说,当任务持有BKL时,睡眠是安全的,仅仅是可以这样做,因为睡眠不会造成任务死锁。
- BKL 是一种递归锁。一个进程可以多次请求一个锁,并不会像自旋锁那样产生死锁现象。
- BKL只可以用在进程上下文中。和自旋锁不同,你不能在中断上下文中申请BLK。
- 新的用户不允许使用BLK。随着内核版本的不断前进,越来越少的驱动和子系统再依赖于BLK 了。
- BLK的方法
9.顺序锁
- 概述
顺序锁,通常简称seq锁,是在2.6版本内核中才引入的一种新型锁。这种锁提供了一种很简单的机制,用于读写共享数据。实现这种锁主要依靠一个序列计数器。当有疑义的数据被写入时,会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读操作进行的过程中没有被写操作打断过。此外,如果读取的值是偶数,那么就表明写操作没有发生(要明白因为锁的初值是0,所以写锁会使值成奇数,释放的时候变成偶数)。可以粗略的理解为
CAS
- 使用
//定义一个seq锁:
seqlock_t mr_seq_lock = DEFINB_SEQLOCK(mr_seq_lock) ;
//然后,写锁的方法如下:
write_seqlock ( &mr_seq_lock);
/*写锁被获取...*/
write_sequnlock ( &mr__seq_lock);
//这和普通自旋锁类似。不同的情况发生在读时,并且与自旋锁有很大不同
unsigned long seq;
do{
seq =read_seqbegin ( &mr_seq_lock) ;
/*读这里的数据...*/
}while(read_seqretry ( &mr_seq_lock ,seq));
- 你选择顺序锁的理由
在多个读者和少数写者共享一把锁的时候,seq锁有助于提供一种非常轻量级和具有可扩展性的外观。但是seq锁对写者更有利。只要没有其他写者,写锁总是能够被成功获得。读者不会影响写锁,这点和读–写自旋锁及信号量一样。另外,挂起的写者会不断地使得读操作循环,直到不再有任何写者持有锁为止。
- 你的数据存在很多读者。
- 你的数据写者很少。
- 虽然写者很少,但是你希望写优先于读,而且不允许读者让写者饥饿。
- 你的数据很简单,如简单结构,甚至是简单的整型——在某些场合,你是不能使用原子量的。
10.禁止抢占
- 概述
由于内核是抢占性的,内核的进程在任何时刻都可能停下来以便来运行另一个被调度的进程,所以同一临界区可能会被两个进程访问,为了避免我们引入同步机制,例如一个自旋锁被持有,那么内核便不能进行抢占
内核抢占和SMP(多处理器)都面临相同的并发问题,因为同步机制的引入所以两者都是安全的
- 不需要锁、但要禁止抢占的情况
但实际有些情况我们并不需要自旋锁,但仍要关闭内核抢占,这种情况就是每个处理器上的数据,如果数据对于处理器本身是唯一的,那么不需要锁保护;现在内核没有锁、并可以抢占,唯一的数据(共享数据)被两个进程可能访问到
像下面这个例子即使是单处理器也会被多个进程并发访问,一般请求自旋锁,但是这个每个处理器唯一的我们可以通过
preempt_disable()
禁止内核抢占,可重复调用,每次调用如果取消需要preempt_enable()
抵消,最后一次被抵消后内核抢占会被重新启动
任务A对每个处理器中未被锁保护的变量foo进行操作
任务A被抢占
任务B被调度
任务B操作变量foo任务B完成
任务A被调度
任务A继续操作变量foo
- 相应方法
其中的计数值就是禁止抢占函数调用就+1,如果本身为0内核为可抢占状态
11.顺序与屏障
- 概述
当处理多处理器之间或硬件设备之间的同步问题时,有时需要在你的程序代码中以指定的顺序发出读内存(读入)和写内存(存储)指令。在和硬件交互时,时常需要确保一个给定的读操作发生在其他读或写操作之前。另外,在多处理器上,可能需要按写数据的顺序读数据(通常确保后来以同样的顺序进行读取)。但是编译器和处理器为了提高效率,可能对读和写重新排序,这样无疑使问题复杂化了。幸好,所有可能重新排序和写的处理器提供了机器指令来确保顺序要求。同样也可以指示编译器不要对给定点周围的指令序列进行重新排序。这些确保顺序的指令称作屏障(barriers)。
这里可以跟Java中volatile关键字提供的内存屏障做相关的记忆
- 例子
在编译器的静态顺序编译后,a在b的前面,但是处理器会重新动态排序,处理器在取指令和分派时,把
表面
看似无关的指令按照自以为最好的顺序排序去优化处理性能,当然大多时候这种就是最佳的
a=1;
b=2;
但如果下面这两个被重排序那就会引起歧义,所以编译器和处理器绝对不会对下面的进行重排列
a=1;
b=a;
提供屏障的方法
- rmb()
该方法提供一个读的内存屏障,它确保跨越
rmb()
的载入动作不会发送重排列,即rmb()
之前的载入不会被重新排在该调用之后,同理rmb()
之后的载入操作不会被重新排在该调用之前
- wmb()
wmb()
方法提供了一个“写”内存屏障,这个函数的功能和rmb()
类似,区别仅仅是它是针对存储而非载入——它确保跨越屏障的存储不发生重排序。
- mb()
mb()方法既提供了读屏障也提供了写屏障。载入和存储动作都不会跨越屏障重新排序。这是因为一条单独的指令(通常和rmb()使用同一个指令)既可以提供载入屏障,也可以提供存储屏障。
- read_barrier_depends()
read_barrier_depends()是rmb()的变种,它提供了一个读屏障,但是仅仅是针对后续读操作所依靠的那些载入。因为屏障后的读操作依赖于屏障前的读操作,因此,该屏障确保屏障前的读操作在屏障后的读操作之前完成。
基本上说,该函数设置一个读屏障,如rmb(),但是只针对特定的读——也就是那些相互依赖的读操作。在有些体系结构上,read_barrier_depends() 比 rmb()执行得快,因为它仅仅是个空操作,实际并不需要。
屏障的例子
如果不使用屏障,那么有可能就出现重排列导致与预期结果不服,下面出现的重排列是处理器为了优化传送管道,rmb()或wmb()函数相当于指令,它们告诉处理器在继续执行前提交所有尚未处理的载入或存储指令。
- 使用mb可以保证a和b是按照预定顺序写入
- 而rmb是确保c和d按照预定顺序读取
再一次声明,如果没有内存屏障,有可能在
pp
被设置成p
前,b
就被设置为pp
了。由于载入*pp
依靠载入p
,所以read_barrier_depends()
提供了一个有效的屏障。虽然使用rmb()
同样有效,但是因为读是数据相关的,所以我们使用read_barrier_depends()
可能更快。注意,不管在哪种情况下,左边的线程都需要mb()操作来确保预定的载入或存储顺序。
- 多处理器内核下的屏障
宏
smp_rmb()、smp_wmb()、smp_mb()和smp_read_barrier_depends()
提供了一个有用的优化。在SMP内核中它们被定义成常用的内存屏障,而在单处理机内核中,它们被定义成编译器的屏障。对于SMP 系统,在有顺序限定要求时,可以使用SMP的变种。
- 关于屏障方法的总结
更多推荐
所有评论(0)