可以把内核看作是请求进行响应的服务器,这些请求可能来自在CPU上执行的进程,也可能来自发出中断请求的外部设备。这些请求可能引起竞争条件,因此需要采用适当的同步机制对这种情况进行控制。
一.内核如何为不同的请求提供服务
- 内核抢占:无论是抢占内核还是非抢占内核中,运行在内核态的进程都可以自动放弃CPU,比如,其原因可能是,进程由于等待资源而不得不转入睡眠状态;所有的进程切换都由宏switch_to来完成。抢占内核的主要特点是:一个在内核运行的进程,可能在执行内核函数期间被另外一个进程取代。使用内核可抢占的目的是减少用户态进程的分派延迟,即从进程变为可执行状态到它实际开始运行之间的时间间隔。内核抢占对执行及时被调度的任务的进程确实是有好处的,因为它降低了这种进程被另一个运行在内核态的进程延迟的风险。只有当内核正在执行异常处理程序(尤其是系统调用),而且内核抢占没有被显式地禁用时,才可能抢占内核。
- 什么时候同步是必需的:临界区一段代码,在其他的内核控制路径能够进入临界区前,进入临界区的内核控制路径必须全部执行完这段代码。交叉内核控制路径使内核开发者的工作变得复杂:那必须特别小心地识别出异常处理程序、中断处理程序、可延迟函数和内核线程中的临界区。一旦临界区被确定,就必须对其采用适当的保护措施,以确保在任意时刻只有一个内核控制路径位于临界区。
- 什么时候同步是不必要的:上章内核控制路径的同步(1)所有的中断处理程序响应来自PIC的中断并禁用IRQ线,此外,在中断处理程序结束之前,不允许产生相同的中断事件;(2)中断处理程序、软中断和tasklet既不可以被抢占也不能被阻塞,所以它们不可能长时间处于挂起状态;(3)执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断;(4)软中断和tasklet不能再一个给定的CPU上交错执行;(5)同一个tasklet不可能同时在几个CPU上执行。以上的每一种设计选择都可以被看作是一种约束,它能使一些内核函数的编码变得更容易:(1)中断处理程序和tasklet不必编写成可重入的函数;(2)仅被软中断和tasklet访问的每CPU变量不需要同步;(3)仅被一种tasklet访问的数据结构不需要同步。
二.不同原语
- 每个CPU变量:最好的同步技术是把设计不需要同步的内核放在首位。一个CPU不应该访问其他CPU对应的数组元素,另外,它可以随意读或修改它自己的元素而不担心出现竞争条件,因为它是唯一有资格这么做的CPU。
- 原子操作:避免由于“读-修改-写”指令引起的竞争条件的最容易的办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储单元。这些很小的原子操作可以建立在其他更灵活机制的基础之上以创建临界区。
- 优化和内存屏障:优化屏障原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编指令,这些汇编指令在C中都有对应的语句。在Linux中,优化屏蔽就是barrier()宏,它展开为asm volatile("":::"memory")。内存屏蔽原语确保,在原语之后的操作开执行之前,原语之前的操作已经完成,因此内存屏蔽类似于防火墙,让任何汇编语言指令都不能通过。在80x86处理器中,下列种类的汇编语言指令是“串行的”,因为它们起内存屏蔽的作用:(1)对I/O端口进行操作的所有指令;(2)有lock前缀的所有指令;(3)写控制寄存器、系统寄存器或调试寄存器的所有指令;(4)在Pentium 4微处理器中引入的汇编语言指令lfence、sfence和mfence,它们分别有效地实现内存屏蔽、写内存屏蔽和读-写内存屏蔽;(5)少数专门的汇编语言指令,终止中断处理程序或异常处理程序的iret指令就是其中的一个。Linux中六个屏蔽原语:mb()、rmb()、wmb()、smp_mb()、smp_rmb()、smp_wmb()。
- 自旋锁:一种广泛应用的同步技术是加锁。当内核控制路径必须访问共享数据结构或进入临界区时,就需要为自己获取一把“锁”。自旋锁(spin lcok)是用来在多处理器环境中工作的一种特殊的锁。如果内核控制路径发现自旋锁“开着”,就获取并继续自己的执行,相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径“锁着”,就在周围“旋转”,反复执行一条紧凑的循环指令,直到锁被释放。自旋锁的循环指令表示“忙等”。请注意,在自旋锁忙等期间,内核抢占还是有效的,因此,等待自旋锁释放的进程有可能被更高优先级的进程替换。
- 读/写自旋锁:读/写自旋锁的引入是为了增加内核的并发能力。只要没有内核控制路径对数据结构进行修改,读/写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径想对这个结构进行写操作,那么它必须首先获取读/写锁的写锁,写锁授权独占访问这个资源。当然,允许对数据结构并发读可以提高系统性能。
- 顺序锁:当使用读/写自旋锁时,内核控制路径发出的执行read_lock或write_lock操作的请求具有相同的优先权:读者必须等待直到写操作完成,同样的,写者也必须等待直到读操作完成。顺序锁与读/写自旋锁非常相似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待(除非另外一个写者正在写),缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。每个顺序锁都是包括两个字段的seqlock_t结构:一个类型为spinlock_t的lock字段和一个整型的sequence字段,第二个字段是一个顺序计算器。
- 读-拷贝-更新(RCU):是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术。RCU允许多个读者和写者并发执行(相对于只允许一个写者执行的顺序锁有了改进),而且RCU是不使用锁的,就是说它不使用被所有CPU共享的锁或计数器,在这一点上与读/写自旋锁和顺序锁(由于高速缓存行窃用和失效而有很高的开销)相比,RCU具有更大的优势。RCU是如何不使用共享数据结构而令人惊讶地实现多个CPU同步呢?其关键的思想包括限制RCP的范围:(1)RCU只保护被动态分配并通过指针引用的数据结构;(2)在被RCU保护的临界区中,任何内核控制路径都不能睡眠。
- 信号量:从本质上说,它们实现了一个加锁原语,即让等待者睡眠,直到等待的资源变为空闲。实际上,Liunx提供两种信号量:(1)内核信号量,由内核控制路径使用;(2)System V IPC信号量,由用户态进程使用。内核信号量类似于自旋锁,只有可以睡眠的函数才能获取内核信号量,中断处理程序和可延迟函数都不能使用内核信号量。
- 读/写信号量:读/写信号量类似于前面“读/写自旋锁”描述的读/写自旋锁,有一点不同的是:在信号量再次变为打开之前,等待进程挂起而不是自旋。很多内核控制路径为读可以并发地获取读/写信号量,打但是任何写者内核控制路径必须有对被保护资源的互斥访问。
- 补充原语:引入这种原语是为了解决多处理器系统上发生的一种微妙的竞争关系,当进程A分配了一个临时信号量变量,把它初始化为关闭的MUTEX,并把其它地址给进程B,然后再A之上调用down(),进程A打算一旦被唤醒就撤销该信号量。随后,运行在不同CPU上的进程B在同一信号量上调用up()。然而,up()和down()的目前实现还允许这两个函数在同一个信号量上并发执行。因此,进程A可以被唤醒并撤销临时信号量,而进程B还在运行up()函数,结果up()可以试图访问一个不存在的数据结构。补充是专门设计来解决以上问题的同步原语。补充原语和信号量之间的真正差别在于如何使用等待队列中包含的自旋锁,在补充原语中自旋锁用来确保complete()和wait_for_completion()不会并发执行,在信号量中自旋锁用于避免并发执行的down()函数弄乱信号量的数据结构。
- 禁止本地中断:确保一组内核语句被当做一个临界区处理的主要机制之一就是中断禁止。即使当硬件设备产生了一个IRQ信号时,中断禁止也让内核控制路径继续执行,因此,这就提供了一种有效的方式,确保中断处理程序访问的数据结构也受到保护。然而,禁止本地中断并不保护运行在另一个CPU上的中断处理程序对数据结构的鬓发访问,因此,在多处理器系统上,禁止本地中断经常与自旋锁结合使用。
- 禁止和激活可延迟函数:(1)检查本地CPU的preempt_count字段中断计数器和软中断计算器,如果这两个计算器的值等于0而且有挂起的软中断要执行,就调用do_softirq()来激活这些软中断;(2)检查本地CPU的TIF_NEED_RESCHED标志是否被设置,如果是,说明进程切换请求是挂起的,因此调用preempt_schedule()函数。
三.对内核数据结构的同步访问
系统性能可能随所选择同步原语种类的不同而有很大变化。通过情况下,内核开发者采用下述由经验得到的法则:把系统中的并发度保持在尽可能高的程度。系统中的并发度又取决于两个主要因素:(1)同时运转的I/O设备数;(2)进行有效工作的CPU数。为了使I/O吞吐量最大化,应该使中断禁止保持在很短的时间。为了有效地利用CPU,应该尽可能避免使用基于自旋锁的同步原语。
- 在自旋锁、信号量及中断禁止之间选择:一般来说,同步原语的选取取决于访问数据结构的内核控制路径的种类,如下表所示,记住,只要内核控制路径获得自旋锁,就禁用本地中断或本地软中断,自动禁止内核抢占。
访问数据结构的内核控制路径 单处理器保护 多处理器进一步保护
异常 信号量 无
中断 本地中断禁止 自旋锁
可延迟函数 无 无或自旋锁
异常与中断 本地中断禁止 自旋锁
异常与可延迟函数 本地软中断禁止 自旋锁
中断与可延迟函数 本地中断禁止 自旋锁
异常、中断与可延迟函数 本地中断禁止 自旋锁
四.避免竞争条件的实例
- 引用计算器:引用计算器广泛地用在内核中以避免由于资源的并发分配和释放而产生的竞争条件。引用计数器只不过是一个atomic_t计数器,与特定的资源,如内存页、模块或文件相关。当内核控制路径开始使用资源时就原子地减少计数器的值,当内核控制路径使用完资源时就原子地增加计数器。当引用计数器变为0时,说明该资源未被使用,如果必要,就释放该资源。
- 大内核锁:从内核版本2.6.11开始,用一个叫做kernel_sem的信号量来实现大内核锁。当一个持有大内核锁的进程被抢占时,schedule()一定不能被释放信号量,因为临界区内执行代码的进程没有主动触发进程切换。所以如果释放大内核锁,那么另一个进程就可能获得它,并破坏由被抢占的进程所访问的数据结构。为了避免被抢占的进程失去大内核锁,preempt_schedule_irq()临时把进程的lock_depth字段设置为-1。观察这个字段的值,schedule()假定被替换的进程不拥有kernel_sem信号量,也就不释放它。结果,被抢占的进程就一直拥有kernel_sem信号量。一旦这个进程再次被调度程序选中,preempt_schedule_irq()函数就恢复lock_depth字段原来的值,并让进程在被大内核锁保护的临界区中继续执行。
- 内存描述符读/写信号量:mm_struct类型的每个内存描述符在mmap_sem字段中都包含了自己的信号量。由于几个轻量级进程之间可以共享一个内存描述符,因此信号量保护这个描述符以避免可能产生的竞争条件。这种信号量是作为读/写信号量来实现的,因为一些内核函数,如缺页异常处理程序只需要扫描内存描述符。
- slab高速缓存链表的信号量:slab高速缓存描述符链表是通过cache_chain_sem信号量保护的,这个信号量允许互斥地访问和修改该链表。当kmem_cache_create()在链表中增加一个新元素,而kmem_cache_shrink()和kmem_cache_reap()顺序地扫描整个链表时,可能产生竞争条件。然而在处理中断时,这些函数从不被调用,在访问链表时它们也从不阻塞。由于内核是支持抢占的,因此这种信号量在多处理器系统和单处理器系统中都会起作用。
- 索引节点的信号量:Linux把磁盘文件的信息存放在一种叫做索引节点的内存对象中。相应的数据结构也包括有自己的信号量,存放在i_sem字段中。