一些概念
内核可以处理来自CPU上执行的进程请求,也可以处理来自外部设备发出的中断请求。内核各个部分并不是严格按次序执行的,而是采用交错执行(interleave)的方式。
内核提供的服务对应于CPU处于内核态时所执行的代码,如果CPU在用户态则认为处于空闲状态。内核提供的原则支持内核控制路径的嵌套以及2.6中引入的内核抢占(kernel preemption),即进程处于内核态时,执行内核函数期间允许发生内核切换。可以减少用户态进程的分派延迟(dispatch latency),即进程变为可执行状态到实际开始执行之间的时间间隔。
- 在抢占内核与非抢占内核中,内核态进程可以自愿放弃CPU,称为计划性切换;但是在处理可能引起进程切换的异步事件时表现不同,称为强制进程切换。
- 所有进程切换都由switch_to宏完成。当调用调虎程序时会发生进程切换。在非抢占内核中,除了进程切换到用户态,否则是不可切换的。
内核只有在执行异常处理函数(特别是系统调用),而且CPU打开本地中断以及内核抢占没有被显式调用时可以抢占内核。交叉内核控制路径在进入临界区时,必须完全执行这段代码,以保证任何时刻只有一个内核控制路径处于临界区。在单CPU系统中,关闭中断可以实现临界区。另外在系统调用程序中,如果只有一个人CPU,可以禁用内核抢占来实现临界区。多CPU系统情况复杂很多。
同步原语
每CPU变量(Per-CPU variables)
位来自不同CPU的并发访问提供保护。将内核变量声明为P-Cv,主要是数据结构数组,每个CPU对应一个元素。CPU不能访问其他CPU对应的元素,并且可以读写自己的元素。因此,只有可以确定系统中CPU上的数据在逻辑上独立的时候才能使用。P-Cv数组的元素会在主存中进行排列,使得每个数据结构落在硬件高速缓存中不同的行,因此并发访问不会引起缓存行的窃用与失效,避免了具有昂贵系统开销的操作。
对来自异步函数(中断处理程序和可延迟函数)不提供保护。此外,内核抢占可能使P-Cv产生竞争条件,内核控制路径应该在禁用抢占的情况下访问P-Cv。
内核提供一系列P-Cv的函数和宏
原子操作
- 进行零次或一次对齐内存访问的汇编指令是原子的
- 读操作之后,写操作之前没有其他处理器占用内存总线,则“读-修改-写”汇编指令(inc dec等)是原子的,在单处理器系统中,不会发生内存总线窃用
- 前缀是lock(oxf0)的“读-修改-写”指令是原子的,执行指令时,其他处理器不能访问这个内存单元
- 前缀是rep(0xf2,0xf3)指令不是原子的,强制控制单元多次重读执行指令。控制单元在执行新的循环之前要检查挂起的中断
内核提供atomic_t类型,函数和宏。
typedef struct {
voiltile int counter
}atomic_t;
使用这一结构而不直接使用int类型出于以下几点考虑。首先,原子操作方法只接受atomic_t类型参数,确保数据类型的统一并防止数据传向其他非原子方法。其次,可以确保编译器不会优化对值的访问,确保原子操作得到正确的地址。并且使用这一结构屏蔽了不同架构的实现差异。
优化屏障和内存屏障
编译器可能会重新安排汇编语言指令以使寄存器以最优方式使用,CPU可以并行地执行若干指令,重新安排内存访问,以加速程序执行。所有的同步原语起优化和内存屏障的作用。
优化屏障(optimization barrier)原语保证编译器不会将原语之前的指令和之后的指令混淆。Linux提供barrier()
宏,展开为asm volatile("":::"memory")
,volatile关键字禁止编译器将asm指令和程序其他指令重新组合;memory关键字强制编译器假定RAM中所有存储单元都被汇编语言指令修改,因此编译器不能使用在asm指令之前CPU寄存器所存储的存储单元中的值来优化代码。内存屏障(memory barrier)确保原语之后的操作开始之前,原语之前的指令都已完成。
x86架构中,下列执行是串行的,起到内存屏障作用:
- 所有对I/O端口进行操作的指令
- lock前缀所有指令
- 修改控制寄存器、系统寄存器、调试寄存器的所有指令
- lfence,sfence,,mfence在Pentium4中引入,分别实现读、写、读-写内存屏障。
- 少数其他指令,如iret用于终止中断或异常处理程序
内核提供6个内存屏障原语。
自旋锁(spin lock)
自旋锁是用于多处理器环境中的锁。如果内核控制路径发现自旋锁开着,就获取锁并继续自己的执行;否则就在周围旋转,即执行一条紧凑的循环指令,直到锁被释放。spin look的循环指令代表**忙等待 **,即使等待状态的内核路径无事可做,它仍在CPU上保持运行,因此自旋锁不应该长时间占有,通常短于进程上下文切换的时间。由于很多内核资源只锁1毫秒的时间片段,而释放CPU和随后获得CPU会消耗更多时间,所以自旋锁非常便于使用,由于不会导致睡眠,还可以用在异常处理程序(获取锁前需要禁用本地中断,否则可能被嵌套中断导致死锁)。Linux自旋锁不能递归使用,因为将导致死锁(self deadlock)。
在单处理器系统上,自旋锁原语仅禁止或启用内核抢占。在忙等期间,内核抢占还是生效的,因此等待自旋锁的进程肯能被更高优先级的进程替代。自旋锁结构为spinlock_t,slock字段1表示未加锁,负数和0表示已加锁;break_lock表示进程在忙等待。
读/写自旋锁
读/写自旋锁是为了增加内核的并发能力。只要没有内核控制路径对数据结构进行修改,读/写自旋锁允许同时读一数据结构,允许数据结构并发读。如果有内核控制路径要修改结构,需要获得写锁,独占访问这个资源。读/写锁为rwlock_t结构,lock字段是32位,分为两部分:"24位计数器",表示并发读操作内核控制路径的数目,每次减一并将补码存放在0~23位;"未锁"字段,没有内核控制路径在读或写时置位,否则清0。
顺序锁(seqlock)
2.6中引入了seqlock,它与读写锁非常相似,只是为写者赋予较高的优先级,即使有读者正在读也允许写者继续运行。优点是写者永远不会等待(除非另一个写者正在写);缺点是读者可能需要多次反复读取直到获取有效的副本。seqlock_t结构包含spinlock_t的lock字段和整型sequence字段,第二个字段是顺序计数器,每个读者应该在读数据前后两次读顺序计数器,如果两次不同,表明新的写者已经开始写,暗示读者刚读取到的数据是无效的。写者获取顺序锁后将计数器加1,释放顺序锁前将计数器加1,这样保证当有写者时计数器位奇数,没有写者时计数器位偶数。
读-拷贝-更新(RCU)
RCU保护被多个CPU读的数据结构而设计的同步技术。允许多个读者和写者并发执行,并且不使用锁,关键思想如下:
- RCU只保护被动态分配的并通过指针引用的数据结构
- 被RCU保护的临界区中,任何内核控制路径都不能睡眠
读取数据时,执行rcu_read_lock(),等同于preempt_disable(),调用rcu_read_unlock()等同于preempt_enable()标志临界区结束。当写者需要修改数据时,间接引用指针并生成数据结构的副本,修改这个副本。修改完毕后使指针指向修改后的副本。仍然需要内存屏障:保证数据结构修改后已更新的指针才对其他CPU可见。写者开始修改时,正访问数据结构的读者可能还是读取旧副本,只有所有读者都执行rcu_read_lock()之后,才释放旧副本。
信号量
当内核控制路径试图获取内核信号量所保护的忙资源时,信号量将相应进程放进等待队列并使其睡眠,当资源被释放时,进程再度变为可运行的。因此只有可以睡眠的函数才能获取内核信号量,中断处理程序及可延迟函数都不能使用。stuct semaphore,字段count大于0表明资源空闲,等于0表明资源忙,但没有进程等待,小于0表明有进程在等待;wait字段存放等待队列链表的地址,即所有睡眠进程;sleepers字段是否有一些进程在信号量上睡眠,如果没有就设为0,否则置位。与自旋锁不同,信号量没有禁用内核抢占,即持有信号量的内核控制路径允许被抢占,信号量不会对调度延迟产生不利影响。
读/写信号量
类似读/写自旋锁,只是在信号量被打开之前。等待进程挂起而不是自旋。多个内核控制路径可以并发获取读/写信号量用于读操作。但是任何写者内核控制路径必须互斥访问。内核严格以FIFO方式处理读/写信号量。信号量释放时,如果等待队列第一个为写者,其他进程要继续睡眠;如果为读者,紧跟其后的所有读进程也被唤醒。rw_semaphore结构:count字段两个16位计数器,高16位以补码存放非等待写者(0,1)和等待的内核控制路径数,低16位存放非等待的读者和写者总数;wait_list字段,指向等待进程列表每个元素包含指向睡眠进程描述符的指针和一个标志读需要还是写需要的标志;wait_lock字段,一个自旋锁,保护 rw_semaphore和等待队列。
补充原语
2.6使用了类似信号量的原语:补充(completion)。主要通过等待队列的自旋锁确保complete()[类似up()],wait_for_completion()[类似down()]不会并发执行。信号量中自旋锁用于避免并发执行down()弄乱信号量的数据结构。
禁止本地中断
local_irq_disable()和local_irq_enable()关闭和打开本地CPU上的中断。内核进入临界区时,通过将eflags寄存器的IF位清0以关闭中断,但是在临界区结束不知道应如何处理,因为中断可以嵌套执行。通过loacl_irq_save()以及loac)irq_restore()保存和恢复eflags的值。
禁止和激活可延迟函数
可延迟函数在不可预知的时间执行(往往在硬件中断处理程序结束时),需要保护其访问的数据结构。通过改变thread_info中preempt_count字段的值可以在本地CPU激活或禁止可延迟函数。local_bh_enable()本地CPU软中断计数器减1,local_bh_disable()本地CPu软中断计数器加1。
对内和数据的同步访问
内核控制路径 | UP | SMP进一步保护
-----------------------------------------------
异常 | 信号量 |无
-----------------------------------------------
中断 | 本地中断禁止 |自旋锁
-----------------------------------------------
可延迟函数 | 无 |无或自旋锁
-----------------------------------------------
异常与中断 | 本地中断禁止 |自旋锁
-----------------------------------------------
异常与可延迟函数 | 本地软中断禁止|自旋锁
-----------------------------------------------
中断与可延迟函数 | 本地中断禁止 |自旋锁
-----------------------------------------------
异常、中断与可延迟函数| 本地中断禁止 |自旋锁
-----------------------------------------------
避免竞争条件
引用计数器(reference counter)
atomic_t类型计数器,与特定资源相关
全局内核锁(big kernel lock/ global kernel lock)
只能在进程上下文使用,确保只有一个进程可以运行在内核态。每个进程描述符都有lock_depth字段,允许同一个进程几次获得全局内核锁,如果进程未获得锁,字段为-1,否则0代表获取1次锁,并每次递增。
/*lock_kernel()*/
depth = current->lock_depth + 1;/*当前进程描述符中字段,不需要原子执行*/
if (depth == 0)
down(&kernel_sem);
current->lock_depth = depth;
/*unlock_kernel()*/
if (--current->lock_depth < 0)
up(&kernel_sem);