内核如何为不同的请求提供服务
把内核看作必须满足两种请求的侍者:一种来自顾客,另一种来自数量有限的几个不同的老板。对不同的请求,侍者采用如下策略:
(1). 老板提出请求时,如侍者空闲,则开始为其服务。
(2). 如老板提出请求时,侍者正在为顾客服务,则侍者停止为顾客服务,开始为老板服务。
(3). 如一个老板提出请求时,侍者正在为另一个老板服务,则侍者停止为第一个老板提供服务,开始为第二个老板服务,服务完毕,再继续为第一个老板服务。
(4). 一个老板可能命令侍者停止正在为顾客提供的服务。侍者在完成对老板最近请求的服务后,可能会暂时不理会原来的顾客,而去为新选中的顾客服务。
侍者提供的服务对应于CPU
处于内核态时所执行的代码。如CPU
在用户态执行,则侍者被认为处于空闲。老板的请求相当于中断,顾客的请求相当于用户态进程发出的系统调用或异常。请求内核服务的用户态进程需发出一个适当的指令,在80x86
上是int $0x80
或sysenter
。这些指令引起一个异常,迫使CPU
从用户态切换到内核态。
内核抢占
(1). 无论在抢占内核还是非抢占内核,运行在内核态的进程都可自动放弃CPU
。
原因可能是,进程由于等待资源而睡眠。将这种进程切换称为计划性进程切换。抢占式内核在响应进程切换的异步事件的方式上与非抢占的内核是有差别的,把这种进程切换称作强制性进程切换。
(2). 所有的进程切换都由宏switch_to
完成。在抢占内核和非抢占内核中,当进程执行完某些具有内核功能的线程,且调度程序被调用后,发生进程切换。抢占内核的主要特点是:一个在内核态运行的进程,可能在执行内核函数期间被另外一个进程取代。
举例1
:
在进程A
执行异常处理程序时,一个有较高优先级的进程B
变为可执行。如发生了中断请求,且相应的处理程序唤醒了进程B
。如内核是抢占的,就发生强制性进程切换,让B
取代A
。异常处理程序的执行被暂停,直到调度程序再次选择进程A
时才恢复它的执行。如内核是非抢占的,在进程A
完成异常处理程序的执行前是不会发生进程切换的。除非进程A
自动放弃CPU
。此时异常处理执行完毕返回时,将发生切换.
举例2
:
考虑一个执行异常处理程序的进程已经用完了它的时间配额的情况,如内核是抢占的,进程可能会立即被取代,但如内核是非抢占的,进程继续运行直到它执行完异常处理程序或自动放弃CPU
。使能内核可抢占的目的是减少用户态进程的分派延迟,即从进程变为可执行状态到它实际开始运行间的间隔。使Linux 2.6
内核具有可抢占的特性,无需对支持非抢占的旧内核做太大改变。curren_thread_info
所引用的thread_info
的preempt_count
大于0
时,禁止内核抢占。该字段的编码对应三个不同的计数器,它在如下任何一种情况发生时,取值都大于0
:
(1). 内核正在执行中断服务例程
(2). 可延迟函数被禁止(内核正执行软中断或tasklet
时经常如此)
(3). 通过把抢占计数器设置为正数而显式禁用内核抢占。
上面的原则告诉我们,只有当内核正执行异常处理程序(尤其是系统调度),且内核抢占没被显式禁用时,才可能抢占内核。本地的CPU
必须打开本地中断,否则,无法完成内核抢占。preempt_enable
宏递减抢占计数器,检查TIF_NEED_RESCHED
。此时,进程切换请求是挂起的,故宏调preempt_schedule
,它本质上执行下面代码:
if(!current_thread_info->preempt_count && !irqs_disabled())
{
current_thread_info->preempt_count = PREEMPT_ACTIVE;
schedule();
current_thread_info->preempt_count = 0;
}
函数检查是否允许本地中断,及当前进程的preempt
是否为0
,如两个都是真,就调用schedule
选择另外一个进程运行。故,内核抢占可能在结束内核控制路径(通常是一个中断处理程序)时发生,也可能在异常处理程序调preempt_enable
重新允许内核抢占时发生。内核抢占也可能发生在启用可延迟函数时候。内核抢占会引起不容忽视的开销。
什么时候同步是必须的
当计算的结果依赖于两个或两个以上的交叉内核控制路径的嵌套方式时,可能出现竞争条件。必须小心的识别出异常处理程序,中断处理程序,可延迟函数(软中断),内核线程中的临界区。一旦临界区被确定,就必须对其采用适当的保护措施,以确保任意时刻只有一个内核控制路径处于临界区。
多处理系统中,情况更加复杂。由于许多CPU
可能同时执行内核路径,因此内核开发者不能假设只要禁用内核抢占功能,且中断,异常,软中断处理程序都没访问过该数据结构,就能保证这个数据结构能安全地被访问。
什么时候同步是不必要的
(1). 所有的中断处理程序响应来自PIC
的中断并禁用IRQ
线。此外,在中断处理程序结束前,不允许产生相同的中断事件。
(2). 中断处理程序,软中断,tasklet
既不可被抢占,也不能被阻塞,所以,它们不可能长时间处于挂起状态。最坏情况下,它们的执行将有轻微的延迟,因为在其执行的过程中可能发生其他的中断。
(3). 执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断。
(4). 软中断和tasklet
不能在一个给定的CPU
上交错执行。
(5). 同一个tasklet
不可能同时在几个CPU
上执行。
以上的每一种设计选择,都可以被看成是一种约束。它能使一些内核函数的编码变得更容易。
下面是一些可能简化了的例子:
(1). 中断处理程序和tasklet
不必编写成可重入的函数。
(2). 仅被软中断和tasklet
访问的每CPU
变量不需要同步。
(3). 仅被一种tasklet
访问的数据结构不需要同步。
同步原语
考察在避免共享数据之间的竞争条件时,内核控制路径是如何交错执行的。
技术 | 说明 | 适用范围 |
---|---|---|
每CPU变量 | 在CPU之间复制数据结构 | 所有CPU |
原子操作 | 对一个计数器原子地"读-修改-写"的指令 | 所有CPU |
内存屏障 | 避免指令重新排序 | 本地CPU或所有CPU |
自旋锁 | 加锁时忙等 | 所有CPU |
信号量 | 加锁时阻塞等待(睡眠) | 所有CPU |
顺序锁 | 基于访问计数器的锁 | 所有CPU |
本地中断的禁止 | 禁止单个CPU上的中断处理 | 本地CPU |
本地软中断的禁止 | 禁止单个CPU上的可延迟函数处理 | 本地CPU |
读-拷贝-更新 | 通过指针而不是锁来访问共享数据结构 | 所有CPU |
每CPU变量
最好的同步技术是把设计不需要同步的内核放在首位。事实上每一种显式的同步原语都有不容忽视的性能开销。最简单也是最重要的同步技术包括把内核变量声明为每CPU
变量,每CPU
变量主要是数据结构的数组,系统中每个CPU
对应数组的一个元素。一个CPU
不应访问与其他CPU
对应的数组元素,另外,它可随意读或修改它自己的元素而不担心出现竞争条件,因为它是唯一有资格这么做的CPU
。但,这也意味着每CPU
变量基本上只能在特殊情况下使用,即当它确定在系统的CPU
上的数据在逻辑上是独立的时候。每CPU
的数组元素在主存中被排列,以使每个数据结构存放在硬件高速缓存的不同行,因此,对每CPU
数组的并发访问不会导致高速缓存行的窃用和失效。
虽然,每CPU
变量为来自不同CPU
的并发访问提供保护,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下,需要另外的同步原语。此外,在单处理器和多处理器系统中,内核抢占都可能使每CPU
变量产生竞争条件。总的原则是内核控制路径应在禁止抢占的情况下访问每CPU
变量。
原子操作
若干汇编语言指令具有"读-修改-写"
类型,即访问存储器单元两次,第一次读原值,第二次写新值。单独的读内存,写内存,由于存储器仲裁器(对访问RAM
芯片的操作进行串行化的硬件电路)插手,单独的读,写都是串行的。避免由于"读-修改-写"
指令引起的竞争条件的最容易办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU
访问同一存储单元。这些很小的原子操作,可建立在其他更灵活机制的基础上以创建临界区。
当数据项的地址可被其尺寸整除时,称数据项在内存中被对齐。一般来说,非对齐的内存访问不是原子的。对80x86
:
(1). 进行零次或一次对齐内存访问的汇编指令是原子的。
(2). 如在读操作之后,写操作之前没有其他处理器占用内存总线,则从内存中读取数据,更新数据,并把更新后的数据写回内存中的这些"读-修改-写"汇编语言指令(如inc,dec)是原子的。当然,在单处理系统中,永远不会发生内存总线窃用的情况。
(3). 操作码前缀是lock
字节的"读-修改-写"汇编语言指令即使在多处理系统中也是原子的。当控制单元检测到这个前缀时,就"锁定"内存总线,直到这条指令执行完成为止。因此,当加锁的指令执行时,其他处理器不能访问这个内存单元。
(4). 操作码前缀是一个rep
字节的汇编语言指令不是原子的,这条指令强行让控制单元多次重复执行相同的指令。控制单元在执行新的循环之前要检查挂起的中断。
在编写C
代码程序时,不能保证编译器会为a=a+1
或甚至像a++
这样的操作使用一个原子指令。因此,Linux
内核提供了一个专门的atomic_t
类型(一个原子访问计数器)和一些专门的函数或宏,这些函数和宏作用于atomic_t
类型的变量,并当作单独的,原子的汇编语言指令来使用。在多处理系统中,每条这样的指令都有一个lock
字节的前缀。
优化和内存屏障
当使用优化的编译器时,千万不要认为指令会严格按它们在源代码中出现的顺序执行。如,编译器可能重新安排汇编语言指令以使寄存器以最优的方式使用。此外,现代CPU
通常并行地执行若干条指令,且可能重新安排内存访问。这种重新排序可极大加速程序的执行。然而, 当处理同步时, 必须避免指令重新排序。如放在同步原语之后的一条指令在同步原语本身之前执行,事情很快会变得失控。事实上,所有的同步原语其优化和内存屏障的作用。
优化屏障原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编语言指令,这些汇编语言指令在C
中都有对应的语句。在Linux
中,优化屏障就是barrier
宏,它展开为asm volatile("":::"memory")
。指令asm
告诉编译程序要插入汇编语言片段(这种情况下为空)。volatile
关键字强制编译器假定RAM
中的所有内存单元已经被汇编语言指令修改;因此,保证编译器不会混淆存放在同步操作之前的汇编语言指令和存放在同步操作之后的汇编语言指令。
内存屏障原语确保,在原语之后的操作开始执行之前,原语之前的操作已经完成。在80x86
处理器中,下列种类的汇编语言指令是"串行的",因为它们其内存屏障的作用:
(1). 对I/O
端口进行操作的所有指令
(2). 有lock
前缀的所有指令
(3). 写控制寄存器,系统寄存器或调试寄存器的所有指令(如cli,sti,
修改eflags
指令)
(4). 在Pentium 4
微处理器中引入的汇编语言指令lfence, sfence, mfence
,它们分别有效地实现读内存屏障,写内存屏障,读-写内存屏障。
(5). 少数专门的汇编语言指令,终止中断处理程序或异常处理程序的iret
指令是其中的一个。
Linux
使用六个内存屏障原语。这些原语也被当作优化屏障,因为我们必须保证编译过程不在屏障前后移动汇编语言指令。当内存屏障应防止仅出现于多处理器系统上的竞争条件时,就使用smp_xxx
;其他的内存屏障防止出现在单处理器和多处理器上的竞争条件。
宏 | 说明 |
---|---|
mb() | 适用于MP,UP的内存屏障 |
rmb() | 适用于MP,UP的读内存屏障 |
wmb() | 适用于MP,UP的写内存屏障 |
smp_mb() | 适用于MP的内存屏障 |
smp_rmb() | 适用于MP的读内存屏障 |
smp_wmb() | 适用于MP的写内存屏障 |
内存屏障原语的实现依赖于系统的体系结构。在80x86
微处理器上,如CPU
支持lfence
,rmb
就展开为asm volatile("lfence")
。否则就展开为asm volatile("lock;addl $0,0(%%esp)":::"memory")
。asm
指令告诉编译器插入一些汇编指令并起优化屏障的作用。
Intel
上的wmb
宏实际上更简单,因为它展开为barrier
。这是因为Intel
处理器从不对写内存访问重新排序,因此,没必要再在代码中插入一条串行化汇编指令。不过,这个宏禁止编译器重新组合指令。
关于lfence,sfence,mfence,lock
的补充解释:sfence
之前的写操作一定在sfence
完成且全局可见。这包含了顺序性,硬件缓存一致性两种保证。lfence
之前的读操作一定在lfence
完成。这包含了顺序性保证。
mfence
之前的读操作一定在lfence
完成,之前的写操作一定在sfence
完成且全局可见。lock
,自带mfence
效果。且锁内存总线。使得期间内存只能被单个cpu
访问。
自旋锁
自旋锁的循环指令表示"忙等"。即使等待的内核控制路径无事可做,它也在CPU
上保持运行。一般来说,由自旋锁所保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,自旋锁原语仅仅是禁止或启用内核抢占。
注意,在自旋锁忙等期间,内核抢占还是有效的。因此,等待自旋锁是否的进程有可能被更高优先级的进程替代。在Linux
中,每个自旋锁都用spinlock_t
表示, 其中包含两个字段:
(1). slock
该字段表示自旋锁的状态:1表示"未加锁",任何负数和0都表示"加锁"状态
(2). break_lock
表示进程正在忙等自旋锁(只在内核支持SMP
和内核抢占下使用该标志)
宏 | 说明 |
---|---|
spin_lock_init() | 把自旋锁置为1(未锁) |
spin_lock() | 循环,直到自旋锁变为1(未锁),然后,把自旋锁置为0(锁上) |
spin_unlock() | 把自旋锁置为1(未锁) |
spin_unlock_wait() | 等待,直到自旋锁变为1(未锁) |
spin_is_locked() | 如自旋锁被置为1(未锁),返回0;否则,返回1 |
spin_trylock() | 把自旋锁置为0(锁上),如原来锁的值是1,则返回1;否则,返回0 |
具有内核抢占的spin_lock宏
下面的描述都是针对支持SMP
系统的抢占式内核。该宏获取自旋锁的地址slp
作为它的参数,并执行下面的操作:
(1). 调preempt_disable
禁用内核抢占。
(2). 调_raw_spin_trylock
,它对自旋锁的slock
字段执行原子性测试和设置。
movb $0, %al
xchgb %al, slp->slock
汇编语言指令xchg
原子性地交换8
位寄存器%al
(存0
)和slp->slock
指示的内存单元的内容。随后,如存放在自旋锁中的旧值(在xchg
指令执行后存放在%al
中)是正数,函数就返回1
,否则返回0
(3). 如自旋锁中旧值是正数,结束:内核控制路径已经获得自旋锁。
(4). 否则,执行循环一直到在其他CPU
上运行的内核控制路径释放自旋锁。调preempt_enable
递减在第一步递增了的抢占计数器。如在执行spin_lock
之前内核抢占被启用,则其他进程此时可能取代等待自旋锁的进程。
(5). 如break_lock
字段为0
,将其设置为1
。通过检测该字段,拥有锁并在其他CPU
上运行的进程就可知道是否有其他进程在等待这个锁。如进程把持某个自旋锁的时间太长,它可提前释放锁以使等待相同自旋锁的进程能继续向前运行。
(6). 执行等待循环
while(spin_is_locked(slp) && slp->break_lock)
cpu_relax();
cpu_relax
简化为一条pause
汇编语言指令。在Pentiun 4
中引入此指令以优化自旋锁循环的执行。pause
与早先的80x86
微处理器模型是向后兼容的,因为它对应rep;nop
(7). 跳转回1
,再次试图获取自旋锁
非抢占式内核中的spin_lock宏
1: lock; decb slp->slock
jns 3f
2: pause
cmpb $0, slp->slock
jle 2b
jmp 1b
3:
汇编语言指令decb
递减自旋锁的值,指令是原子的,因为它带有lock
前缀。
spin_unlock宏
movb $1, slp->slock
在随后调preempt_enable
(如不支持内核抢占,preempt_enable
什么都不干)。因为现在的80x86
微处理器总是原子地执行内存中的只写访问,所以不使用lock
字节。
读/写自旋锁
每个读/写自旋锁都是一个rwlock_t
,其lock
字段是一个32
位的字段,分为两个不同的部分:
(1). 24
位计数器,表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。这个计数器的二进制补码存放在这个字段的0~23
位。
(2). "未锁"标志字段,当没有内核控制路径在读或写时设置该位,否则,清0
。这个未锁标志存放在lock
字段第24
位。
注意,如自旋锁为空(设置了"未锁"标志且无读者),则lock
字段的值为0x01000000
;如写者已经获得自旋锁(未锁标志清0
且无读者),则lock
字段的值为0x00000000
;如果一个,两个或多个进程因为读获取了自旋锁,则lock
字段的值为0x00ffffff,0x00fffffe
等。
这里的"补码"含义看着是,给定一个目标数值。补码为=0x1000000-
目标数值(未锁标志清0
,读者个数的二进制补码在0~23
位上)。与spinlock_t
结构一样,rwlock_t
也包括break_lock
。rwlock_init
把读/写自旋锁的lock
字段初始化为0x01000000
,把break_lock
初始化为0
为读获取和释放一个锁
如编译内核时选择了内核抢占选项,read_lock
执行与spin_lock
相似的操作。只有一点不同:该宏执行_raw_read_trylock
以在第2
步有效地获取读/写自旋锁。
int _raw_read_trylock(rwlock_t *lock)
{
atomic_t *count = (atomic_t*)lock->lock;
atomic_dec(count);
if(atomic_read(count) >= 0)
return 1;
atomic_inc(count);
return 0;
}
计数器等于0x01000000
表示没有任何进程占用锁,等于0x00ffffff
表示有一个读者,等于0x00000000
表示有一个写者。如编译内核时没有选择内核抢占选项,read_lock
宏产生下面的汇编语言代码:
movl $rwlp->lock, %eax
lock; subl $1, (%eax)
jns 1f
call __read_lock_failed
1:
这里,__read_lock_failed
是下列汇编语言函数:
(1). 恢复rwlp->lock
。
(2). 循环等待rwlp->lock
变为>=1
(3). 再次尝试递减rwlp->lock
,若递减后结果是负数,再次转入__read_lock_failed
。否则,成功获取读锁且更新了读者数。
__read_lock_failed:
lock; incl (%eax)
1: pause
cmpl $1, (%eax) # AT&T汇编,(%eax)<1时,SF被设置
js 1b
lock; decl (%eax)
js __read_lock_failed
ret
释放读自旋锁是相当简单的,read_unlock
只需用汇编语言指令简单地增加lock字段的计数器。隐含了使用限制:必须先取得读锁后,才能执行释放读锁。
lock; incl rwlp->lock
然后调preempt_enable
重新启用内核抢占
为写获取和释放一个锁
write_lock
实现的方式与spin_lock
和read_lock
类似。例如,如支持内核抢占,则该函数禁用内核抢占并通过调__raw_write_trylock
立即获得锁。如函数返回0
,说明锁已经被占用。因此,宏像前面章节描述的那样重新启用内核抢占并开始忙等待循环。
int _raw_write_trylock(rwlock_t *lock)
{
atomic_t *count = (atomic_t *)lock->lock;
if(atomic_sub_and_test(0x01000000, count)) // 原子性的将count指向内存中值减去0x01000000,若结果为0,返回true。否则,返回false。
return 1;
atomic_add(0x01000000, count);
return 0;
}
释放写自旋锁同样非常简单,因为write_unlock
只需使用汇编语言指令lock; addl $0x01000000, rwlp
把lock
字段中的"未锁"标识置位,再调用preempt_enable
即可。
顺序锁
使用读/写自旋锁时,内核控制路径发出的执行read_lock
或write_lock
操作的请求具有相同的优先权:读者必须等待,直到写操作完成。同样的,写者也必须等待,知道读操作完成。
Linux 2.6
引入了顺序锁,它与读/写自旋锁相似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待(除非另一个写者正在写),缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。
每个顺序锁都包括两个字段的seqlock_t
结构:
一个类型为spinlock_t
的lock
字段,一个整型的sequence
字段,字段2
是一个顺序计数器。
每个读者都必须在读数据前后两次读顺序计数器,并检查两次读到的值是否相同。如不同,说明新的写者已经开始写并增加了顺序计数器,因此,暗示读者刚读到的数据是无效的。通过把SEQLOCK_UNLOCKED
赋给变量seqlock_t
或执行seqlock_init
宏,把seqlock_t
变量初始化为"未上锁"。
写者通过调write_seqlock
和write_sequnlock
获取和释放顺序锁。 保证写者在写的过程中,计数器的值是奇数,且当没有写者在改变数据的时候,计数器的值是偶数。读者执行下面的临界区代码:
unsigned int seq;
do {
seq = read_seqbegin(&seqlock);
/* ... */
} while(read_seqretry(&seqlock, seq));
read_seqbegin
返回顺序锁的当前顺序号;如局部变量seq
的值是奇数(写者在read_seqbegin
被调用后,正更新数据结构),或seq
的值与顺序锁的顺序计数器的当前值不匹配(当读者正执行临界区代码时,写者开始工作),read_seqretry
就返回1
。
注意,当读者进入临界区时,不必禁用内核抢占;另一方面,由于写者获取自旋锁,所以它进入临界区时自动禁用内核抢占。并不是每一种资源都可使用顺序锁来保护。
一般,必须在满足下述条件时才能使用顺序锁:
(1). 被保护的数据结构不包括被写者修改和被读者间接引用的指针(否则,写者可能在读者的眼鼻下就修改指针)
(20. 读者的临界区代码没副作用(否则,多个读者的操作会与单独的读操作有不同的结果)此外,读者的临界区代码应简短,且写者应不常获取顺序锁。否则,反复的读访问会引起严重的开销。Linux 2.6
中,使用顺序锁的典型例子包括保护一些与系统时间处理相关的数据结构。
读-拷贝-更新–实现细节待定
为了保护在多数情况下被多个CPU
读的数据结构而设计的另一种同步技术。RCU
允许多个读者和写者并发执行(相对于只允许一个写者执行的顺序锁有了改进)。且,RCU
是不使用锁的,即它不使用被所有CPU
共享的锁或计数器,这一点上与读/写自旋锁和顺序锁(由于高速缓存行窃用和失效而有很高的开销)相比,RCU
有更大的优势。
RCU
是如何不使用共享数据结构而令人惊讶地实现多个CPU
同步呢?
关键的思想包括限制RCU
的范围,如下:
(1). RCU
只保护被动态分配并通过指针引用的数据结构
(2). 在被RCU
保护的临界区中,任何内核控制路径都不能睡眠。当内核控制路径要读取被RCU
保护的数据结构时,执行rcu_read_lock
,它等同于preempt_disable
。接下来,读者间接引用该数据结构指针所对应的内存单元并开始读这个数据结构。如前面所强调的,读者在完成对数据结构的读操作之前,是不能睡眠的。用等同于preempt_enable
的宏rcu_read_unlock
标记临界区的结束。可想象,由于读者几乎不做任何事情来防止竞争条件的出现,所以写者不得不做的更多一些。事实上,当写者要更新数据结构时,它间接引用指针并生成整个数据结构的副本。接下来,写者修改这个副本。一旦修改完毕,写者改变指向数据结构的指针,以使它指向被修改后的副本。由于修改指针值的操作是一个原子操作,所以旧副本和新副本对每个读者或写者都是可见的,在数据结构中不会出现数据崩溃。
尽管如此,还需内存屏障来保证:
只有在数据结构被修改后,已更新的指针对其他CPU
才是可见的。如把自旋锁与RCU
结合以禁止写者的并发执行,就隐含地引入了这样的内存屏障。
然后,使用RCU
的真正困难在于:写者修改指针时不能立即释放数据结构的旧副本。实际上,写者开始修改时,正在访问数据结构的读者可能还在读旧副本。只有在CPU
上的所有(潜在的)读者都执行完宏rcu_read_unlock
后,才可释放旧副本。内核要求每个潜在的读者在下面的操作之前执行rcu_read_unlock
:
(1). CPU
执行进程切换
(2). CPU
开始在用户态执行
(3). CPU
执行空循环
对上述每种情况,我们说CPU
已经经过了静止状态。写者调用函数call_rcu
来释放数据结构的旧副本。当所有的CPU
都通过静止状态之后,call_rcu
接受rcu_head
描述符(通常嵌在要被释放的数据结构中)的地址和将要调用的回调函数的地址作为参数。一旦回调函数被执行,它通常释放数据结构的旧副本。函数call_rcu
把回调函数和其他参数的地址存放在rcu_head
描述符,然后把描述符插入回调函数的每CPU
链表中。
内核每经过一个时钟滴答就周期性地检查本地CPU
是否经过了一个静止状态。如所有CPU
都经过了静止状态,本地tasklet
(它的描述符存放在每CPU
变量rcu_tasklet
中)就执行链表中的所有回调函数。RCU
是Linux 2.6
中新加的功能,用在网络和虚拟文件系统中。
信号量
实际上,Linux
提供两种信号量:
(1).内核信号量,由内核控制路径使用;
(2).System V IPC
信号量,由用户态进程使用。内核信号量类似于自旋锁,锁关闭时,不允许内核控制路径继续进行。当内核控制路径试图获取内核信号量所保护的忙资源时,相应的进程被挂起。只有在资源被释放时,进程才再次变为可运行的。因此,只有可睡眠的函数才能获取内核信号量;中断处理程序和可延迟函数都不能使用内核信号量。内核信号量是struct semaphore
类型的对象,包含下面这些字段:a. count
存放atomic_t
类型的一个值。如该值大于0
,则资源就是空闲的。如count
等于0
,则信号量是忙的,但没进程等待这个被保护的资源。如count
为负数,则资源是不可用的,并至少有一个进程等待资源。
b. wait
存放等待队列链表的地址,当前等待资源的所有睡眠进程都放在这个链表中。
c. sleepers
存放一个标志,标识是否有一些进程在信号量上睡眠。
可用init_MUTEX
和init_MUTEX_LOCKED
来初始化访问所需的信号量:这两个宏分别把count
字段设置成1
(互斥访问的资源空闲)和0
(对信号量进行初始化的进程当前互斥访问的资源忙)。宏DECLARE_MUTEX
和DECLARE_MUTEX_LOCKED
完成同样的功能,但它们也静态分配semaphore
结构的变量。注意,也可把信号量中的count
初始化为任意的正数值n
,这种情况下,最多有n
个进程可并发地访问这个资源。
获取和释放信号量
进程希望释放内核信号量锁时,就调用up
函数。函数本质等价于
movl $sem, %ecx
lock; incl (%ecx)
jg 1f
lea %ecx, %eax
pushl %edx
pushl %ecx
call __up
popl %ecx
popl %edx
1:
这里的__up
是下列c函数:
__attribute__((regparm(3))) void __up(struct semaphore* sem)
{
wake_up(&sem->wait);
}
当进程希望获取内核信号量锁时,就调用down
。down
实现比较复杂,但本质上等价于下列代码:
down:
movl $sem, %ecx
lock; decl (%ecx);
jns 1f
lea %ecx, %eax
pushl %edx
pushl %ecx
call __down
popl %ecx
popl %edx
1:
这里的__down
是下列C
函数:
__attribute__((regparm(3))) void __down(struct semaphore* sem)
{
DECLARE_WAITQUEUE(wait, current);
unsigned long flags;
current->state = TASK_UNINTERRUPTIBLE;
spin_lock_irqsave(&sem->wait.lock, flags);
add_wait_queue_exclusive_locked(&sem->wait, &wait);
sem->sleepers++;
for(;;)
{
if(!atomic_add_negative(sem->sleepers - 1, &sem->count))
{
sem->sleepers = 0;
break;
}
sem->sleepers = 1;
spin_unlock_irqrestore(&sem->wait.lock, flags);
schedule();
spin_lock_irqsave(&sem->wait.lock, flags);
current->state = TASK_UNINTERRUPTIBLE;
}
remove_wait_queue_locked(&sem->wait, &wait);
wake_up_locked(&sem->wait);
spin_unlock_irqrestore(&sem->wait.lock, flags);
current->state = TASK_RUNNING;
}
要牢记,如没进程在信号量等待队列上睡眠,则信号量的sleepers
字段通常置为0
,否则置为1
。考虑几种典型情况
(1). MUTEX
信号量打开(count
等于1
,sleepers
等于0
)
down
仅仅把count
置为0,
并跳到主程序的下一条指令;
(2). MUTEX
信号量关闭,没有睡眠进程(count
等于0
,sleepers
等于0
)
down
减count
并将count
字段置为-1
,且sleepers
置为0
来调用__down
。在循环体的每次循环中,该函数检查count
字段是否为负。(因为当调用atomic_add_negative
时,sleepers
等于0
。因此,atomic_add_negative
不改变count
字段)
a.如count
字段为负,__down
就调用schedule
挂起当前进程。count
字段仍置为-1
,sleepers
字段置为1
。随后,进程在这个循环内恢复自己的运行并又进行测试。
b. .如count
字段不为负,则把sleepers
置为0
,并从循环退出。__down
试图唤醒信号量等待队列中的另一个进程,并终止保持的信号量。在退出时,count
字段和sleepers
字段都置为0
,这表示信号量关闭且没有进程等待信号量。
(3). MUTEX
信号量关闭,有其他睡眠进程(count
等于-1
,sleepers
等于1
)
down
减count
并将count
字段置为-2
,且sleepers
字段置为1
来调用__down
函数。该函数暂时把sleepers
置为2
,然后通过把sleepers-1
加到count
来取消由down
执行的减操作。同时,该函数检查count
是否依然为负(在__down
进入临界区之前,持有信号量的进程可能正好释放了信号量)。
a. 如count
字段为负,__down
把sleepers
重新置为1
,并调schedule
挂起当前进程。count
字段还是置为-1
,而sleepers
字段置为1
。
b. 如count
字段不为负,__down
把sleepers
置为0
,试图唤醒信号量等待队列上的另一个进程,并退出持有的信号量。退出时,count
字段置为0
且sleepers
字段置为0
。考虑下,在等待队列上的另一个进程已被唤醒,这个进程进行循环体的另一次循环;atomic_add_negative
从count
中减去1
,count
重新变为-
1;唤醒的进程在重新回去睡眠前,把sleepers
重新置为1
。
考虑下,__down
中的wake_up
至多唤醒一个进程,因为等待队列中的睡眠进程是互斥的。只有异常处理程序,特别是系统调用服务例程,才可调down
函数。中断处理程序或可延迟的函数不必调down
,因为当信号量忙时,这个函数挂起进程。由于此,Linux
提供了down_trylock
,前面提及的异步函数可安全地使用down_trylock
。该函数和down
函数除了对资源繁忙情况的处理有所不同外,其他是相同的。在资源繁忙时,该函数会立即返回,而不是让进程去睡眠。系统中还定义了一个略有不同函数,down_interruptible
。该函数广泛地用在设备驱动程序中,如进程接受了一个信号但在信号量上被阻塞,就允许进程放弃"down"
。如睡眠进程在获取所需的资源之前被一个信号唤醒,则该函数会增加信号量的count
字段的值并返回-EINTR
。另一方面,如down_interruptible
正常结束并得到了所需的资源,就返回0
。因此,在返回值是-EINTR
时,设备驱动程序可放弃I/O
操作。最后,因为进程通常发现信号量处于打开状态,因此,就可优化信号量函数。尤其是,如信号量等待队列为空,up
就不执行跳转指令;同样,如信号量是打开的,down
就不执行跳转指令。信号量实现的复杂性是由于极力在执行流的主分支上避免费时的指令造成的。
考虑:
初始数量为1
的信号量sem
(1). A
执行P
(sem
),进入临界区。sem->count
变为0
。
(2). B
执行P(sem)
。加入等待链表。此前sem->sleepers
为0
,故而sem->count
变为-1
,sem->sleepers
变为1
。放弃调度。
(3). C
执行P(sem)
。加入等待链表。此前sem->sleepers
为1
,故而sem->count
变为-1
,sem->sleepers
变为1
。放弃调度。
(4). D
执行P(sem)
。加入等待链表。此前sem->sleepers
为1
,故而sem->coun
t变为-1
,sem->sleepers
变为1
。放弃调度。
(5). A
执行V(sem)
。sem->count
变为0
,执行__up
唤醒B
。B
被唤醒后,再次循环。
此时sem->sleepers
为1
。sem->count
为0
,执行检测后,满足唤醒条件。故而sem->sleepers
变为0
,将B
从等待链表移除。继续唤醒等待链表的C
。B
进入临界区运行。C
被唤醒后,再次循环。此时sem->sleepers
为0
。sem->count
为0
,执行检测后,不满足唤醒条件。故而,而sem->count
变为-1
,sem->sleepers
变为1
。放弃调度。综合上述流程下,信号量可正常履行其职责。
信号量互斥保护部分,保证了。存在执行体阻塞等待此信号量时,sem->count
变为-1
,sem->sleepers
变为1
。当信号量使用者释放信号量引起sem->count
变为0
,将唤醒一个等待者。其余等待者继续等待。
读/写信号量–实现细节待定
读/写信号量类似于前面"读/写自旋锁",有一点不同:在信号量再次变为打开之前,等待进程挂起而不是自旋。很多内核控制路径为读可以并发地获取读/写信号量。但,任何写者内核控制路径必须有对被保护资源的互斥访问。故,只有在没有内核控制为读访问或写访问持有信号量时,才可为写获取信号量。读/写信号量可提高内核中的并发度,并改善整个系统的性能。每个读/写信号量都是由rw_semaphore
结构描述的,它包含下列字段:
(1). count
存放两个16
位的计数器。其中最高16
位计数器以二进制补码形式存放非等待写者进程的总数(0
或1
)和等待的写内核控制路径数。最低16
位计数器存放非等待的读者和写者进程的总数。
(2). wait_list
指向等待进程的链表。链表中的每个元素都是一个rwsem_waiter
结构,该结构包含一个指针和一个标志,指针指向睡眠进程的描述符,标志表示进程是为读需要信号量还是为写需要信号量。
(3). wait_lock
一个自旋锁。用于保护等待队列链表和rw_semaphore
结构本身。
init_rwsem
初始化rw_semaphore
,down_read
和down_write
分别为读或写获取读/写信号量。同样,up_read
和up_write
为读或写释放以前获取的读/写信号量。down_read_trylock,down_write_trylock
分别类似于down_read,down_write
,但在信号量忙时,它们不阻塞进程。downgrade_write
自动把写锁转换成读锁。
补充原语
struct completion{
unsigned int done;
wait_queue_head_t wait;
};
void fastcall __sched wait_for_completion(struct completion *x)
{
might_sleep();
spin_lock_irq(&x->wait.lock);
if (!x->done) {
DECLARE_WAITQUEUE(wait, current);
wait.flags |= WQ_FLAG_EXCLUSIVE;
__add_wait_queue_tail(&x->wait, &wait);
do {
__set_current_state(TASK_UNINTERRUPTIBLE);
spin_unlock_irq(&x->wait.lock);
schedule();
spin_lock_irq(&x->wait.lock);
} while (!x->done);
__remove_wait_queue(&x->wait, &wait);
}
x->done--;
spin_unlock_irq(&x->wait.lock);
}
void fastcall complete(struct completion *x)
{
unsigned long flags;
spin_lock_irqsave(&x->wait.lock, flags);
x->done++;
__wake_up_common(&x->wait, TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE,1, 0, NULL);
spin_unlock_irqrestore(&x->wait.lock, flags);
}
禁止本地中断
禁止本地中断并不保护运行在另一个CPU
上的中断处理程序对数据结构的并发访问,因此,多处理器系统上,禁止本地中断经常与自旋锁结合使用。宏local_irq_disable
使用cli
关闭本地CPU
上的中断。宏local_irq_enable
使用sti
打开被关闭的中断。保持和恢复eflags
的内容是分别通过local_irq_save
和local_irq_restore
来实现。local_irq_save
把eflags
的内容拷贝到一个局部变量中,随后用cli
把IF
标志清0
。在临界区的末尾,宏local_irq_restore
恢复eflags
内容。
禁止和激活可延迟函数
必须保护可延迟函数访问的数据结构使其避免竞争条件。禁止可延迟函数在一个CPU
上执行的一种简单方式就是禁止在那个CPU
上的中断。因为,没有中断处理程序被激活,软中断操作就不能异步地开始。然而,内核有时需只禁止可延迟函数而不禁止中断。通过操纵当前thread_info
的preempt_count
字段中存放的软中断计数器,可以在本地CPU
上激活或禁止可延迟函数。如软中断计数器是正数,do_softirq
就不会执行软中断,且,因为tasklet
在软中断之前被执行,把这个计数器设置为大于0
的值,由此禁止了在给定CPU
上的所有可延迟函数和软中断的执行。
宏local_bh_disable
给本地CPU
的软中断计数器加1
,local_bh_enable
从本地CPU
的软中断计数器中减掉1
。递减软中断计数器之后,local_bh_enable
执行两个重要的操作以助于保证适时地执行长时间等待的线程:
(1). 检查本地CPU
的preempt_count
字段中硬中断计数器和软中断计数器。
如这两个计数器的值都等于0
且有挂起的软中断要执行,就调do_softirq
来激活这些软中断。
(2). 检查本地CPU
的TIF_NEED_RESCHED
标志是否被设置。
如是,说明进程切换请求是挂起的,因此调preempt_schedule
。preempt_schedule
是一个 Linux
内核中的函数,用于在抢占式调度(preemptive scheduling
)中执行调度操作。
对内核数据结构的同步访问
通常,内核开发者采用下述由经验得到的法则:把系统中并发度保持在尽量高的程度。系统中的并发度取决于两个主要因素:
(1). 同时运转的I/O
设备数
(2). 进行有效工作的CPU
数。
为了使I/O
吞吐量最大化,应使中断禁止保持在很短的时间。当中断被禁止时,由I/O
设备产生的IRQ
被PIC
暂时忽略,因此,就没有新的活动在这种设备上开始。为有效地利用CPU
,应尽可能避免使用基于自旋锁的同步原语。更糟糕的是:由于自旋锁对硬件高速缓存的影响而使其对系统的整体性能产生不利影响。
让我们举例在下列两种情况下,既可维持较高的并发度,也可达到同步:
(1). 共享的数据结构是一个单独的整数值。
可把它声明为atomic_t
类型并使用原子操作对其更新。 原子操作比自旋锁和中断禁止都快,只有在几个内核控制路径同时访问这个数据结构时速度才会慢下来。
(2). 把一个元素插入到共享链表的操作决不是原子的。
不过内核有时并不用锁或禁止中断就可执行这种插入操作。我们把这种操作的工作机制作为例子来进行说明。考虑一种情况,系统调用服务例程把新元素插入到一个简单链表中,而中断处理程序或可延迟函数异步地查看该链表。
在C
语言中,插入是通过下列指针赋值实现的:
new->next = list_element->next;
list_element->next = new;
在汇编语言中,插入简化为两个连续的原子指令。第一条指令建立new
元素的next
指针,但不修改链表。因此,如中断处理程序在第一条指令和第二条指令执行的中间查看这个链表,看到的就是没有新元素的链表。如该处理程序在第二条指令执行后查看链表,看到的就是有新元素的链表。关键是,任何一种情况下,链表都是一致且处于未损坏状态。然而,只有在中断处理程序不支持修改链表下才能确保这种完整性。如修改了链表,则在new
元素内刚刚设置的next
指针就可能变为无效的。然而,开发者必须确保两个赋值操作的顺序不被编译器或CPU
控制单元搅乱;否则,如中断处理程序在两个赋值之间中断了系统调用服务例程,处理程序就会看到一个损坏的链表。因此,就需写一个内存屏障原语:
new->next = list_element->next;
wmb();
list_element->next = new;
在自旋锁,信号量,中断禁止之间选择
一般来说,同步原语的选取取决于访问数据结构的内核控制路径的种类。记住,只要内核控制路径获得自旋锁(还有读/写锁,顺序锁,RCU
),就禁止本地中断或本地软中断,自动禁用内核抢占。
访问数据结构的内核控制路径 | 单处理器保护 | 多处理器保护 |
---|---|---|
异常 | 信号量 | 无 |
中断 | 本地中断禁止 | 自旋锁 |
可延迟函数 | 无 | 无或自旋锁 |
异常与中断 | 本地中断禁止 | 自旋锁 |
异常与可延迟函数 | 本地软中断禁止 | 自旋锁 |
中断与可延迟函数 | 本地中断禁止 | 自旋锁 |
异常,中断,可延迟函数 | 本地中断禁止 | 自旋锁 |
保护异常所访问的数据结构
当一个数据结构仅由异常处理程序访问时,竞争条件可通过信号量避免。因为信号量原语允许进程睡眠到资源变为可用。注意,信号量的工作方式在单处理器系统和多处理器系统上完全相同。内核抢占不会引起太大的问题。如一个拥有信号量的进程是可以被抢占的,运行在同一个CPU
上的新进程就可能试图获得这个信号量。这种情况下,让新进程处于睡眠状态,且原来拥有信号量的进程最终会释放信号量。只有在访问每CPU
变量的情况下,必须显式地禁用内核抢占。因为内核抢占可能让一个每cpu
变量先后被两个执行体写。前一执行体恢复执行后,看到的将不是自己初始写的内容。
保护中断所访问的数据结构
假定一个数据结构仅被中断处理程序的"上半部分"访问。中断处理程序本身不能同时多次运行。因此,访问数据结构就无需任何同步原语。但是,如多个中断处理程序访问同一个数据结构,情况就不同了。一个处理程序可中断另一个处理程序,不同的中断处理程序可在多处理器系统上同时运行。在单处理器系统上,必须通过在中断处理程序的所有临界区上禁止中断来避免竞争条件。多处理器系统的要求甚至更加苛刻。不能简单地通过禁止本地中断来避免竞争条件。事实上,即使在一个CPU
上禁止了中断,中断处理程序还可在其他CPU
上执行。避免竞争条件最简单的方法是禁止本地中断,并获取保护数据结构的自旋锁、读/写自旋锁。
Linux
内核使用了几个宏,把本地中断激活/禁止与自旋锁结合起来。单处理器系统上,这些宏仅激活或禁止本地中断和内核抢占。
保护可延迟函数所访问的数据结构
首先,在单处理器系统上不存在竞争条件。这是因为可延迟函数的执行总是在一个CPU
上串行进行。即一个可延迟函数不会被另一个可延迟函数中断。多处理器系统上,竞争条件的确存在,因为几个可延迟函数可并发执行。
访问数据结构的可延迟函数 | 保护 |
---|---|
软中断 | 自旋锁 |
一个tasklet | 无 |
多个tasklet | 自旋锁 |
保护由异常和中断访问的数据结构
现在考虑下由异常处理程序(例如系统调用服务例程)和中断处理程序访问的数据结构。单处理器系统上,竞争条件的防止是简单的。因为中断处理程序不是可重入且不能被异常中断。只要内核以本地中断禁止访问数据结构,内核访问数据结构过程就不会被中断。
多处理器系统上,必须关注异常和中断在其他CPU上的并发执行。本地中断禁止还必须加自旋锁,强制并发的内核控制路径进行等待,直到访问数据结构的处理程序完成自己的工作。有时,用信号量代替自旋锁可能更好。
保护由异常和可延迟函数访问的数据结构
可延迟函数本质上是由中断的出现激活的,而可延迟函数执行时不可能产生异常。因此,把本地中断禁止和自旋锁结合就够了。异常处理程序可通过local_bh_disable
禁止可延迟函数,而不禁止本地中断。仅禁止可延迟函数比禁止中断更可取,因为中断还可继续在CPU上得到服务。在每个CPU
上可延迟函数的执行都被串行化,因此,不存在竞争条件。多处理器系统上,要用自旋锁确保任何时候,只有一个内核控制路径访问数据结构。
保护由中断和可延迟函数访问的数据结构
可延迟函数运行时可能产生中断,但是,可延迟函数不能阻止中断处理程序。因此,需通过可延迟函数执行期间禁用本地中断来避免竞争。不过,中断处理程序可随意访问被可延迟函数访问的数据结构而不用关中断,前提是没有其他的中断处理程序访问这个数据结构。在多处理器系统上,还是需自旋锁禁止对多个CPU
上数据结构的并发访问。
保护由异常,中断,可延迟函数访问的数据结构
禁止本地中断,获取自旋锁。当中断处理程序终止执行时,可延迟函数才能被实质激活。
避免竞争条件实例
引用计数器
当内核控制路径开始使用资源时就原子地减少计数器的值,
当内核控制路径使用完资源时就原子地增加计数器。
当引用计数器变为0
时,如有必要,就释放资源。
大内核锁
每个进程描述符都含有lock_depth
,这个字段允许同一个进程几次获取大内核锁。因此,对大内核锁两次连续的请求不挂起处理器。如进程未获得过锁,则这个字段值为-1
;否则,这个字段值加1
,表示已经请求了多少次锁。如没有这个字段,在当前进程已经拥有大内核锁的情况下,任何试图获得这个锁的异步函数都可能产生死锁。lock_kernel
和unlock_kernel
用来获得和释放大内核锁。前一个等价于:
depth = current->lock_depth + 1;
if(depth == 0)
{
down(&kernel_sem);
}
current->lock_depth = depth;
后者等价于
if(--current->lock_depth < 0)
{
up(&kernel_sem);
}
允许一个持有大内核锁的进程调shedule
,从而放弃CPU
。不过,schedule
检查被替换进程的lock_depth
,如它的值是0
或正数,就自动释放kernel_sem
信号量。因此,不会有显式调用schedule
的进程在进程切换前后都保持大内核锁。但,当schedule
再次选择这个进程来执行时候,将为该进程重新获得大内核锁。
如一个持有大内核锁的进程被另一个进程抢占,情况就不同了。现在大内核锁的实现是基于信号量的,且不会由于获得它而自动禁用内核抢占。实际上,在被大内核锁保护的临界区内允许内核抢占是改变大内核锁实现的主要原语。其次,这对于系统的响应时间会产生有益的影响。
当一个持有大内核锁的进程被抢占时,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
保护的,这个信号量允许互斥地访问和修改该链表。
索引节点的信号量
程序使用了两个或多个信号量时,为避免死锁,信号量的请求需按预先确定的地址顺序进行。