1. 内核如何为不同的请求提供服务
- 我们可以将内核看作为是一个不断对请求进行响应的服务器, 而这些请求可能来自于CPU 上执行的进程, 也可能来自于发出中断请求的外部设备
- 内核的各个部分并不是严格按照顺序依次执行的, 而是采用交错执行的方式。
1.1 内核抢占
- 如果一个运行在内核中的进程, 在执行内核函数的时候, 允许发生内核切换, 那么, 我们认为他是可抢占的。
- 计划性进程切换, 进程由于等待资源而不得不转入睡眠状态
- 内核抢占属于强制性进程切换
- 抢占内核的主要特点: 一个在内核态运行的进程, 可能在执行内核函数的期间被另一个进程取代
- 使内核可抢占的目的是减少用户态进程的分派延迟, 亦即进程变为可执行状态到他实际开始运行之间的时间间隔
- 只有当内核正在执行异常处理程序(尤其系统调用), 而且内核抢占没有被显式禁用的时候, 才可能抢占内核, 同时要求, 本地CPU 必须打开本地中断。
- 内核抢占会引起不容忽视的开销
1.2 什么时候同步是必需的
- 我们希望在其他内核控制路径能够进入临界区之前, 进入临界区的内核控制路径必须全部执行完临界区内的代码, 这时候需要同步, 保证在任意时刻只有一个内核控制路径处于临界区
- 在单cpu系统中, 我们可以采用访问共享数据结构时, 关闭中断的方式来实现临界区(因为只有在开中断的情况下, 才可能发生内核控制路径的嵌套)
- 而对于多cpu系统, 就相对比较复杂了, 需要涉及各种内核同步技术了
1.3 什么时候同步是不必要的
2. 同步原语
2.1 每CPU变量
- 本质就是在CPU 之间复制数据结构, 将内核变量声明为每CPU 变量(可以理解为是一个数组, 系统中每个CPU 对应数组中的一个元素)
- 一个CPU 不应该访问其他CPU 对应的数组元素, 从而他不用担心出现竞争条件, 因为他是唯一有资格这么操作的CPU。 他的限制条件: 系统的CPU 上的数据在逻辑上是独立的
- 每CPU 数组元素在主存中排列, 以使得每个数据结构存放在硬件高速缓存的不同行。
- 虽然, 每CPU 变量为来自不同CPU的并发访问提供访问保护, 但是, 对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护, 需要额外的同步原语
- 内核抢占可能使得每CPU 变量产生竞争条件。 因而, 总的原则是内核控制路径应该在禁用抢占的情况下访问每CPU变量。
2.2 原子操作
- 若干汇编指令具有“读-修改-写”类型, 他们涉及两次访问存储单元(读原值, 写新值), 但是, 这种方式很容易引起竞争条件。
- 为避免这样的竞争条件, 我们需要确保这样的操作在芯片级是原子的(保证在这两次读写之间, 处理器不能访问这个单元)
- 汇编指令lock前缀, linux 类型 atomic_t 类型 etc
2.3 优化和内存屏障
- 编译器可能会重新安排汇编语言指令以使得寄存器以最优的方式使用, 而在处理同步问题的时候, 我们就需要避免指令重新排序。
- 优化屏障原语保证编译程序不会混淆放在原语操作之前的汇编指令和原语之后的汇编语言指令。(约束编译器)
- 内存屏障原语, 保证原语之后的操作开始执行的时候, 原语之前的操作已经完成。他有点类似于我们的防火墙, 让任何汇编语言指令都不能通过(约束CPU)
2.4 自旋锁
- 由锁机制保护的资源非常类似于限制于房间内的资源
- 自旋锁的循环指令表示“忙等”, 非常方便, 一般用于等待时间比较短的资源
- 一般而言, 由自旋锁所保护的每个临界区都是禁止内核抢占的
- 这里所定义的宏一般都是原子性的
2.5 读/写自旋锁
- 引入读写自旋锁的目的,是为了增加内核的并发能力 , 只要没有内核路径对数据结构进行修改操作, 读写自旋锁就允许多个内核控制路径同时读同一个数据结构。
2.6 顺序锁
- 对于读写自旋锁而言, 内核控制路径发出的执行read_lock或者write_lock 操作的请求有着相同的优先权。
- 而顺序锁中, 写者有着更高的优先级, 我们允许在读者正在读的时候, 写者可以继续写, 这样的好处是, 写者永远不会等待, 缺点是, 有时候读者需要反复读取相同的内容直到获取有效的版本
- 我们通过设定一个顺序计数器, 保证写的时候为奇数, 没有写者的时候为偶数
- 使用条件: 被保护的数据结构不包括被写者修改和被读者间接引用的指针, 同时, 读者的临界区代码不能有副作用
2.7 读-拷贝-更新(RCU)
- 读-拷贝-更新(RCU)是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术
- RCU 允许多个读者和写者并发执行, 同时RCU 并不使用锁
- RCU 的核心思想:
- 只保护被动态分配并通过指针引用的数据结构
- 在被RCU 保护的临界区中, 任何内核控制路径都不能睡眠(ie, 读者在完成对数据结构的读操作之前, 是不能睡眠的)
- 本质: 生成一个对象的副本, 写者修改这个副本, 修改完毕之后, 修改对象指针, 使其指向新的对象(副本)
- 只有在数据结构被修改之后, 已更新的指针对其他CPU 才是可见的
- 难点: 写者修改指针的时候, 不能立即释放数据结构的旧副本。(只有当所有潜在的读者都执行完操作之后, 才可以释放旧副本), 这个内核中通过要求: 每个潜在读者在读之前都调用一个 rcu_read_unlock 宏来进行处理
2.8 信号量
- 本质上: 信号量实现了一个加锁原语, 让等待者睡眠, 直到等待的资源变为空闲
- Linux 提供了两种不同的信号量
- 内核信号量, 由内核控制路径使用
- System V IPC 信号量, 由用户态进程使用
- 内核信号量有点类似于自旋锁, 当内核控制路径试图获取内核信号量所保护的忙资源的时候, 相应的进程被挂起, 因此, 只有可以睡眠的函数才能获取内核信号量, 中断处理程序和可延迟函数都不能使用内核信号量
- 内核信号量是 struct semaphore 类型的对象, 他包含三个字段:
- count, atomic_t 类型的值, 如果该值大于 0, 表示资源空闲
- wait, 存放等待队列链表的地址, 当前等待资源的所有睡眠进程都放在这个链表中
- sleepers, 标志是否有一些进程在信号量上睡眠
2.9 读/写信号量
- 这个有点类似于之前的 读写自旋锁, 不同的是, 在信号量再次变为打开之前, 等待进程挂起, 而不是自旋
- 提高并发度, 改善性能
2.10 补充原语
2.11 禁止本地中断
- 一般单CPU 可以直接使用
- 多CPU 系统中, 一般需要配合自旋锁使用(禁止本地中断, 并不会保护运行在另一个CPU 上的中断处理程序对数据结构的并发访问)
2.12 禁止和激活可延迟函数
3. 对内核数据结构的同步访问
- 要求: 把系统中的并发度保持在尽可能高的程度
- 并发度取决于:
- 同时运转的IO 设备数
- 进行有效工作的CPU 数
- 为了使得 IO 吞吐量最大化, 应该使得中断禁止保持在很短的时间。
- 为了有效利用CPU, 应该尽量避免使用基于自旋锁的同步原语(ex, 原子操作, 内存屏障etc)
3.1 在自旋锁, 信号量及中断禁止之间的选择
- 只要内核控制路径获得自旋锁, 就禁用本地中断或者本地软中断, 自动禁用内核抢占
4. 避免竞争条件的实例
4.1 引用计数器
4.2 大内核锁
4.3 内存描述符读/写信号量
- mm_struct 类型的每个内存描述符在mmap_sem字段中都包含自己的信号量。 通过这个信号量可以保护由于几个轻量级进程之间可以共享一个内存描述符所产生的竞争条件