大话Linux内核中锁机制之RCU、大内核锁
在上篇博文中笔者分析了关于完成量和互斥量的使用以及一些经典的问题,下面笔者将在本篇博文中重点分析有关RCU机制的相关内容以及介绍目前已被淘汰出内核的大内核锁(BKL)。文章的最后对《大话Linux内核中锁机制》系列博文进行了总结,并提出关于目前Linux内核中提供的锁机制的一些基本使用观点。
十、RCU机制
本节将讨论另一种重要锁机制:RCU锁机制。首先我们从概念上理解下什么叫RCU,其中读(Read):读者不需要获得任何锁就可访问RCU保护的临界区;拷贝(Copy):写者在访问临界区时,写者“自己”将先拷贝一个临界区副本,然后对副本进行修改;更新(Update):RCU机制将在在适当时机使用一个回调函数把指向原来临界区的指针重新指向新的被修改的临界区,锁机制中的垃圾收集器负责回调函数的调用。总结即是读-拷贝-更新。RCU的结构体定义如图10.1所示。
图10.1 RCU的结构体定义
可以看出它的结构体定义是很简单的。只有一个用于串接链表的next指针和一个函数指针,这个函数指针即是上述提及的回调函数,这个需使用RCU机制的用户向链表注册,即挂接到链表下,从而在适当时机下得到调用。
关于RCU机制中的写者,它是自己负责拷贝的临界区,而不是由操作系统负责,最后由已注册的回调函数实现回收。那么所谓的“适当的时机”具体是什么时候呢,这个时机是:所有引用该共享临界区的CPU都退出对临界区的操作。即没有CPU再去操作这段临界区后,这段临界区即可回收了,此时回调函数即被调用。
上述讨论了如此多RCU内容,可能读者会问:RCU到底可以做些什么呢。RCU是一种机制,它允许读写并发执行,对于读者来说,读者间没有任何同步开销,因为可随时读取临界区,和其他读者没有相互影响;但对于不同的写者来说,它们之间如果存在同步开销,则写者间的同步开销则取决于写者间的采用同步机制,和RCU并没有直接的关系。
正如RCU机制中所说写者间可并行执行。但RCU并不维护锁,因此对于不同的写者来说若要访问共享数据时,需要写者和写者之间“它们相互协商”维护所采用的锁机制。这样对RCU机制来说便实现了既允许多个读者同时访问被保护的临界区,又允许多个读者和多个写者同时访问被保护的临界区。这里需要特别注意的是就是上述提及的是否可以有多个写者并行访问临界区取决于写者之间所使用的同步锁机制。因此对于先前笔者讨论的读写锁,可发现RCU机制实际上是一种改进的读写锁,但不能替代。因为RCU机制主要是针对指针类型数据的,而读写锁却非如此。特别的,当写者的同步开销比较大,也即写操作比较多时,对读者的性能提高不能弥补写者导致的损失。
下面我们将看到一个例子,但在看这个例子之前,我们须有两个概念:quiescent state(静默状态过程),它表示为CPU发生上下文切换的过程;grace period(即本节内容一开始提及的“适当时机”),它表示为所有CPU都经历一次quiescent state所需要的等待的时间,也即系统中所有的读者完成对共享临界区的访问。其中当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,也就是保存当前进程的上下文,以便再次执行该进程时,能够得到进程切换时的状态,从而使该进程能够执行下去。至此,相信对上述两个概念有了相对程度的理解了吧。
理解这两个概念后我们来看下面的这个例子,如图10.2所示。
图10.2所展示的示例实现的是写者要从链表中删除元素B。要达到此目的,写者首先遍历该链表得到指向元素B的指针,然后修改元素B的前一个元素的next指针指向元素B的next指针指向的元素C,修改元素B的next指针指向的元素C的prep指针指向元素B的prep指针指向的元素A。在此期间可能有读者访问该链表,由于修改指针指向的操作是原子的,因此这个过程不需要同步,而元素B的指针并没有去修改,因为读者可能正在使用B元素来得到链表的下一个或前一个元素,即A或C。当写者完成上述操作后便向系统注册一个回调函数func以便在 grace period之后能够删除元素B,注册完毕后写着便可认为它已经完成删除操作(实际上并未完成)。垃圾收集器在检测到所有的CPU不在引用该链表后,即所有的CPU已经经历了一次quiescent state(即grace period),当grace period完成后,系统便会去调用先前写者注册的回调函数func,从而真正的删除了元素B。这便是RCU机制的一种使用范例。
经过上述的讨论后,相信读者对于RCU机制有了较好的理解。但是读者很容易就会发现RCU采用时存在一些约束问题。首当其冲的便是在使用RCU时,对共享资源的访问在大部分时间应该是只读的,写访问应该相对较少,因为写访问多了必然相对于其他锁机制而已更占系统资源,影响效率。其次是读者在持有rcu_read_lock(RCU读锁定函数)的时候,不能发生进程上下文切换,否则,因为写者需要等待读者完成方可进行,则此时写者进程也会一直被阻塞,影响系统的正常运行。再次写者执行完毕后需要调用回调函数,此时发生上下文切换,当前进程进入睡眠,则系统将一直不能调用回调函数,更槽糕的是,此时其它进程若再去执行共享的临界区,必然造成一定的错误。最后一点是受RCU机制保护的资源必须是通过指针访问。因为从RCU机制上看,几乎所有操作都是针对指针数据的。
下面笔者将讨论RCU提供的操作函数以及它的实现实质,包括图10.3和图10.4。它们分别在文件中include\linux\rcupdate.h实现。
图10.3展示的是RCU向读者提供的函数,包括基本的读写锁函数以及同步函数,其中同步函数最为重要,即synchronize_rcu()。读者函数的实质其实很简单:禁止抢占,也就是说在RCU期间不允许发生进程上下文切换,原因上述已提及,即是写者需要等待读者完成方可进行,则此时写者进程也会一直被阻塞,影响系统的正常运行等,故而不允许在RCU期间发生进程上下文切换。下面给出RCU向写者提供的函数,如图10.4所示。
图10.4 RCU机制的函数接口
关于写者函数,主要就是call_rcu和call_rcu_bh两个函数。其中call_rcu能实现的功能是它不会使写者阻塞,因而它可在中断上下文及软中断使用,该函数将函数func挂接到RCU的回调函数链表上,然后立即返回,读者函数中提及的synchronize_rcu()函数在实现时也调用了该函数。而call_rcu_bh函数实现的功能几乎与call_rcu完全相同,唯一的差别是它将软中断的完成当作经历一个quiescent state(静默状态,本节一开始有提及这个概念), 因此若写者使用了该函数,那么读者需对应的使用rcu_read_lock_bh() 和rcu_read_unlock_bh()。
为什么这么说呢,这里笔者结合call_rcu_bh的源码实现给出自己的看法:一个静默状态表示一次的进程上下文切换(上述提及),就是当前进程执行完毕并顺利切换到下一个进程。将软中断的完成当作经历一个静默状态是确保此时系统的软中断能够顺利的执行完毕,因为call_rcu_bh可在中断上下文使用,而中断上下文能打断软中断的运行,故而当call_rcu_bh在中断上下文中使用的时候,需确保软中断的能够顺利执行完毕。
对应于此时读者需使用rcu_read_lock_bh() 和rcu_read_unlock_bh()函数的原因是由于call_rcu_bh函数不会使写者阻塞,可在中断上下文及软中断使用。这表明此时系统中的中断和软中断并没有被关闭。那么写者在调用call_rcu_bh函数访问临界区时,RCU机制下的读者也能访问临界区。此时对于读者而言,它若是需要读取临界区的内容,它必须把软中断关闭,以免读者在当前的进程上下文过程中被软中断打断(上述内容提过软中断可以打断当前的进程上下文)。而rcu_read_lock_bh() 和rcu_read_unlock_bh()函数的实质是调用local_bh_disable()和local_bh_enable()函数,显然这是实现了禁止软中断和使能软中断的功能。
另外在Linux源码中关于call_rcu_bh函数的注释中还明确说明了如果当前的进程是在中断上下文中,则需要执行rcu_read_lock()和rcu_read_unlock(),结合这两个函数的实现实质表明它实际上禁止或使能内核的抢占调度,原因不言而喻,避免当前进程在执行读写过程中被其它进程抢占。同时内核注释还表明call_rcu_bh这个接口函数的使用条件是在大部分的读临界区操作发生在软中断上下文中,原因还是需从它实现的功能出发,相信很容易理解,主要是要从执行效率方面考虑。
关于RCU的回调函数实现本质是:它主要是由两个数据结构维护,包括rcu_data和rcu_bh_data数据结构,实现了挂接回调函数,从而使回调函数组成链表。回调函数的原则先注册到链表的先执行。
下面笔者将讨论RCU的链表操作内容,它在文件include\linux\rculist.h中定义。事实上,对于RCU而言,它的目标不仅是保护一般的指针,而且还保护双向链表。实际上,这就是关于RCU链表操作出现的缘由。类似于内核中提供的标准链表操作的函数内容,RCU机制提供的函数也是有基于普通链表和哈希链表的。对于RCU链表操作,除了在遍历链表,修改和删除链表元素时,必须调用RCU机制的函数外,其他过程仍能使用标准链表函数,而标准的链表操作函数于文件include\linux\list.h.定义。事实上,关于RCU的链表操作函数,它们的实现机制大部分还是直接调用关于标准链表操作函数,少部分还增加了调用针对RCU链表操作机制的一些代码。
下面来给出RCU链表操作提供的一些函数,这里只给出部分基本函数,其余函数可以去查具体的内核源码或相关资料。如图10.5,10.6所示,普通链表函数下依次给出相对应的哈希链表函数。
图10.5 RCU链表操作函数
图10.6 RCU链表操作函数
十一、BKL(大内核锁)
最后来介绍下已经被淘汰出内核的大内核锁,简称BKL,由于它已经被踢出内核,故这里并不打算作过多深入的讨论,只是能有个了解,内核中曾经出现这么一个锁。在较低版本的内核中这个锁是存在的,只是不再建议使用。它可锁定整个内核,确保没有处理器在核心态并行运行。
大内核锁从Linux 2.6.39 开始正式彻底踢出内核,未踢出前是在include\linux\smp_lock.h 文件中定义。主要提供了两个函数,lock_kernel可锁定整个内核,unlock_kernel对应解锁。大内核锁的一个特性是它的锁深度会进行计数。这意味着在内核已经被锁定时,仍然可以调用lock_kernel。当然对应的解锁函数unlock_kernel也必须调用同样的次数,以解锁内核,使其它处理器能够进入内核。整个过程简言之就是能够实现递归获得锁。
内核锁本质上也是自旋锁,但是它又不同于自旋锁,不同点在于自旋锁是不可以递归获得锁的(会导致死锁),而大内核锁则可以递归获得锁。大内核锁作用是保护整个内核,而对应自旋锁则用于保护非常特定的某一共享资源。由于一般情况下使用大内核锁的时候保持该锁的时间较长,导致了它严重影响系统的性能和可伸缩性,笔者认为这是它被踢出内核的重要原因之一。至此,关于大内核锁的内容到此介绍完毕,同时,关于Linux内核中的锁机制也介绍完毕,下面笔者将对上述所介绍的内容作一总结。
总结
最后笔者来总结一下先前讨论过的所有内容,讨论的内容包括原子操作;自旋锁,内存屏障;读写自旋锁,顺序锁;信号量,读写信号量,完成量;互斥量;RCU机制;BKL(大内核锁)。
通过上述讨论的一些内容,我们可以总结得到以下一些基本观点:① 原子操作对整数操作,自旋锁和信号量应用较为广泛。② 当临界区小应选择自旋锁,反之,则应选择信号量。③ 关于信号量的选择问题:信号量是针对进程级的,它在内核中以进程方式运行,故它一般的使用条件是当申请信号量的进程需占用资源较长时间时。④ 读写自旋锁和读写信号量条件相对于自旋锁和信号量来说放宽不少,这一点可从它们的定义得出。⑤ RCU机制的应用目前越来越广。⑥ 内存屏障函数使用起来较为复杂,而且多数情况下需要和具体的体系结构相关,故而一般不建议使用。
文章的最后给出笔者在分析Linux内核锁机制过程中参考的一些文献资料,感兴趣的读者可以看下,不过事实上这些资料讲的大部分内容还是较浅的,笔者建议若想搞懂内核中的锁机制还是要去看、去分析内核源码。
至此,关于《大话Linux内核中锁机制》系列博文的内容到此结束。由于笔者水平所限,本系列博文中难免有出错之处,欢迎读者指出,大家相互讨论,共同进步。
转载请注明出处:http://blog.sina.com.cn/huangjiadong19880706
参考文献
[1]、Robert Love,《Linux 内核设计与实现》第三版,机械工业出版社,2011年。
[2]、Wolfgang Mauerer,《深入Linux内核架构》,人民邮电出版社,2011年。
[3]、宋宝华,《Linux设备驱动开发详解》第二版,人民邮电出版社,2011年。
[4]、李云华,《独辟蹊径品内核:Linux内核源代码导读》,人民邮电出版社,2009年。
[5]、http://blog.chinaunix.net/space.php?uid=25845340&do=blog&id=3011577
[6]、http://www.ibm.com/developerworks/cn/linux/l-cn-spinlock/
[7]、http://www.ibm.com/developerworks/cn/linux/l-rcu/