1.内核抢占
当进程正在执行内核函数时,即它在内核态运行时,允许发生内核切换,这个内核就是抢占的。
抢占内核主要特点:一个在内核态运行的进程,可能在执行内核函数期间被另外一个进程取代。
如果内核是抢占的,则会发生强制性进程切换,进程立刻被取代。
如果内核是非抢占的,在在进程A完成执行之前是不会发生进程切换的,除非进程A自动放弃CPU。
内核抢占的目的:减少用户态进程的分派延迟,即从进程变为可执行状态到它实际开始运行之间的时间间隔。
1.1什么时候同步是必须的
临界区是一段代码,在其他的内核控制路径能够进入临界区前,进入临界区的内核控制路径必须全部执行完这段代码。
一旦临界区被确定,就必须对其采用适当的保护措施,以确保任意时刻只有一个内核控制路径处于临界区。
对于单CPU系统,可以采取访问共享数据结构时关闭中断的方式来实现临界区,因为只有在开中断的情况下才可能发生内核控制路径的嵌套。故当系统中只有一个CPU时,就可以非常简单的通过访问共享数据结构时禁用内核抢占来实现临界区。
对于多CPU系统,许多CPU可以同时执行内核路径,则不能假设只要禁用内核抢占功能就能保证这个数据结构能够安全的被访问。
1.2什么时候同步是不必要的
中断处理程序和tasklet不必编写成可重入函数。
仅被软中断和tasklet访问的每CPU变量不需要同步。
仅被一种tasklet访问的数据结构不需要同步。
2.同步原语
内核使用的各自同步技术:
每cpu变量:在cpu之间复制数据结构。
原子操作:对一个计数器原子的“读-修改-写”的指令。
内存屏障:避免指令重新排序。
自旋锁:加锁时忙等待。
信号量:加锁时阻塞等待(休眠)。
顺序锁:基于访问计数器的锁。
本地中断的禁止:禁止单个CPU上的中断处理
本地软中断的禁止:禁止单个CPU上的可延迟函数处理。
读-拷贝-更新(RCU):通过指针而不是锁来访问共享数据结构。
2.1 每CPU变量
每CPU变量主要是数据结构的数组,系统的每个CPU对应数组的一个元素。
一个CPU不应该访问与其他CPU对应的数组元素,另外可以随意的读或者修改其自己的元素而不用担心出现竞争。
2.2 原子操作
避免由于 读-修改-写 指令引起的竞争条件的最容易的办法是确保这样的操作在芯片级别是原子的,任何一个这样的操作必须以单个指令执行,中间不能中断,且避免其他CPU访问同一存储单元。
2.3 优化屏障
当使用优化的编译器时,指令不会严格按照他们在源代码中出现的顺序,编译器可能重新安排汇编语言指令以使寄存器以最优的方式使用。
优化屏障 原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语之后的汇编语言指令。
在linux中优化屏障就是barrier()宏:asm volatile(" ":::"memory").
volatile关键字禁止编译器把asm指令与程序中的其他指令重新组合。
memory关键字强制编译器假定RAM中的所有内存单元已经被汇编语言指令修改。
内存屏障 确保在原语执行之前,原语之前的操作已经完成。内存屏障类似于防火墙,让任何汇编语言指令都不能通过。
2.4 自旋锁
由锁机制保护的资源非常类似于限制于房间内的资源。当某人进入房间时,就把门锁上,如果内核控制路径希望访问资源,就是他获取钥匙打开门,当且仅当资源空闲时,才成功。然后只要它还想使用这个资源,门就依然锁着。当内核控制路径释放了锁,门就打开,另一个内核控制路径就可以进入房间。
自旋锁spin lock是用来在多处理环境中工作的一种特殊的锁。如果内核控制路径发现自旋锁开着,就获得锁并继续往下执行。相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径锁着,就在周围旋转,反复执行一条紧凑的循环指令,直到锁被释放。
自旋锁的循环指令表示忙等,即使等待的内核控制路径无事可做,也在CPU上保存运行。
2.5 读写锁
读写锁的是为了增加内核的并发能力,要么只有一个写锁,要么可有多个读锁,允许并发读来提高系统性能。
2.6 顺序锁
顺序锁为写赋予更高的优先级,即使在读的时候也允许写者继续允许。好处在于写者永远不会等待,缺点在于有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。
一般来说,必须满足下述条件时才使用顺序锁:
- 被保护的数据结构不包括写者修改和被读者间接引用的指针(否则写者可能在读者的眼鼻下就修改指针)。
- 读者的临界区代码没有副作用(否则多个读者的操作会与单独的读操作有不同的结果)。
读者的临界区代码应该简短,而且写者应该不常获取顺序锁。
2.7 读-拷贝-更新 RCU
RCU是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术,其允许多个读者和写者并发执行。相当于对只允许一个写者执行的顺序锁的改进。用在网络层和虚拟文件中。
RCU时如何不使用共享数据而实现多个CPU同步:
- RCU只保护被动态分配并通过指针引用的数据结构。
- 在被RCU保护的临界区中,任何内核控制路径都不能睡眠。
使用RCU技术的困难在于:写者修改指针时不能立即释放数据结构的旧副本。实际上,写者开始修改时,正在访问数据结构的读者还在读旧副本。
2.8 信号量
信号量从本质上实现一个加锁原语,即让等待者睡眠,直到等待的资源变为空闲。
Linux提供两种信号量:
内核信号量,由内核控制路径使用。
system V IPC信号量,由用户态进程使用。
内核信号量类似于自旋锁,因为当锁关闭时,不允许内核控制路径继续进行。然而,当内核控制路径试图获取内核信号量锁保护的忙资源时,相应的进程被挂起,只有在资源被释放时,进程才再次变为可运行的。因此,只有可睡眠的函数才能获取内核信号量,中断处理程序和可延迟函数都不能使用内核信号量。
2.8.1 获取和释放信号量
当进程希望释放内核信号量时,调用up()函数。up()函数增加*sem信号量count字段的值,然后检查它的值是否大于0.
count的增加及其后jump指令所测试的标志的设置必须原子的执行,否则另一个内核控制路径有可能同时访问这个字段的值。
当进程希望获取内核信号量锁时,调用down()函数,该函数减少信号量的 count 字段的值,然后检查是否为负。该值的减少和检查过程都是必须是原子的。如果count大于或等于0,当前进程获得资源并继续正常执行。否则count为负,当前进程必须挂起等待。
2.9 读/写信号量
读/写信号量类似于 读/写自旋锁 ,不同之处在于:在信号量再次变为打开之前,等待进程挂起而不是自旋。
内核以严格的FIFO顺序处理等待读/写信号量的所有进程。如果读者或者写者进程发现信号量关闭,这些进程就被插入到信号量等待队列链表的末尾。当信号量被释放时,就检查处于等待队列链表第一个位置的进程。
第一个进程常被唤醒:
如果是一个写者进程,等待队列上的其他的进程就继续睡眠。
如果是一个读者进程,那么紧跟着第一个进程的其他所有读者进程也被唤醒并获得锁。不过在写者进程之后排队的读者进程继续睡眠。
2.10 补充原语
其为了解决多处理器系统上发生的一种微妙的竞争条件,解决以下问题的同步原语:
当进程A分配了一个临时信号量变量,把它初始化为关闭的MUTEX,并把其地址传递给进程B,然后在A之上调用down(),进程A打算一旦被唤醒就撤销该信号量。随后,运行在不同CPU上的进程B在同一信号量上调用up()。然而,up()和down()的目前实现还允许这两个函数在同一信号量上并发执行。因此,进程A可以唤醒并撤销临时信号量,而进程B还在运行up()函数。结果up()函数可能试图访问一个不存在的数据结构。
补充原语和信号量之间的真正差别在于如何使用等待队列中包含的自旋锁。
2.11 禁止本地中断
确保一组内核语句被当做一个临界区处理的主要机制之一就是中断禁止。即使当硬件设备产生了一个IRQ信号时,中断禁止也让内核控制路径继续执行。
但是禁止中断并不保护运行在另一个CPU上的中断处理程序对数据结构的并发访问。因此在多处理器系统上,禁止本地中断经常与自旋锁结合使用。
2.12 禁止和激活可延迟函数
在 软中断 一节中,可延迟函数可能在不可预知的时间执行(实际上是在硬件中断处理程序结束时)。因此必须保护可延迟函数访问的数据结构使其避免竞争条件。
禁止可延迟函数在一个CPU上执行的一种简单方式就是禁止在那个CPU上的中断。因为没有中断处理程序被激活,因此软中断操作就不能异步的开始。
3.对内核数据结构的同步访问
把系统中的并发度保持在尽可能高的程度。系统中的并发度又取决于两个主要因素:
- 同时运转的I/O设备数
- 进行有效工作的CPU数
为了使I/O吞吐量最大化,应该使中断禁止保持在很短的时间。
为了有效利用CPU,应该尽可能避免使用基于自旋锁的同步原语。自旋锁打开时,是在浪费宝贵的机器周期。
3.1 自旋锁,信号量及中断禁止之间的选择
只要内核控制路径获得自旋锁、读/写锁、顺序锁或RCU读锁,就禁止本地中断或本地软中断,自动禁止内核抢占。
3.1.1保护异常所访问的数据结构
竞争条件可以通过信号量避免,因为信号量原语运行进程睡眠到资源变为可用。信号量工作方式在单处理器系统和多处理器系统上完全相同。
3.1.2保护中断所访问的数据结构
在单处理器上:
必须通过禁止中断处理程序的所有临界区上禁止中断来避免竞争条件。只能用这种方式进行同步,因为其他的同步原语都不能完成这件事。
信号量能够阻塞进程,因此不能再中断处理程序上。
自旋锁可能使系统冻结,如果访问数据结构的处理程序被中断,它就不能释放锁,因此新的中断处理程序在自旋锁的循环上保持等待。
在多处理器上:
不能简单的通过禁止本地中断来避免竞争条件,因为即使在一个CPU上禁止了中断,中断处理程序还可以在其他CPU上执行。
3.1.3保护可延迟函数所访问的数据结构
在单处理器上不存在竞争条件,因为可延迟函数的执行总是在一个CPU上串行执行,即一个可延迟函数不会被另一个可延迟函数中断,故不需要同步原语。
在多处理器系统上,竞争条件的确存在,因为几个可延迟函数可以并发执行。
由软中断访问的数据结构必须受到保护,通常使用自旋锁进行保护,因为同一个软中断可以在两个或多个CPU上并发执行。相反,仅由一种tasket访问的数据结构不需要保护,因为同种tasket不能并发执行。
3.1.4保护由异常和中断访问的数据结构
在单处理系统上,竞争条件的防止很简单,因为中断处理程序是不可重入的而且不能被异常中断。只要内核以本地中断禁止访问数据结构,内核在访问数据结构的过程中就不会被中断。但是如果数据结构正好被一种中断处理程序访问,则中断处理程序不用禁止本地中断就可以自由的访问数据结构。
在多处理系统上,本地中断禁止还必须外加自旋锁,强制并发的内核控制路径进行等待,直到访问数据结构的处理程序完成自己的工作。
3.1.5 保护由异常和可延迟函数访问的数据结构
仅禁止可延迟函数比禁止中断更可取,因为中断还可以继续在CPU上得到服务。在每个CPU上可延迟函数的执行都被串行化,不存在竞争条件。
在多处理器系统上,使用自旋锁确保任何时候只有一个内核控制路径访问数据结构。
4.避免竞争的实例
4.1引用计数器
当内核控制路径开始使用资源的时候就原子的减少计数器的值,当内核控制器使用完资源时就原子的增加计数器。当引用计数器变为0时,资源未被使用,如果必要则释放该资源。
4.2大内核锁
大内核锁/全局内核锁/BKL 是一种相对粗粒度的自旋锁,确保每次只有一个进程能运行在内核态
4.3索引节点的信号量
linux把磁盘文件的信息存放在索引节点node的内存对象中。所有的这些竞争条件都可以通过用索引节点信号量保护目录文件来避免。