凌云的博客

行胜于言

Linux 内核概述(三)

分类:linux| 发布时间:2025-01-23 20:08:00

Unix内核提供了一个执行环境,应用程序可以在其中运行。 因此,内核必须实现一组服务和相应的接口。应用程序通过这些接口进行交互,通常不会直接与硬件资源交互。

同步与临界区

实现可重入内核需要使用同步机制。 如果一个内核控制路径在操作内核数据结构时被挂起,则不应允许其他内核控制路径操作同一数据结构,除非该数据结构已被重置为一致的状态。 否则,两个控制路径的交互可能会破坏存储的信息。

例如,假设一个全局变量 V 表示某个系统资源的可用项数。 第一个内核控制路径 A 读取该变量并确定只有一个可用项。 此时,另一个内核控制路径 B 被激活,读取相同的变量,发现它仍然是1。 于是,B减少 V 的值并开始使用该资源项。 随后 A 恢复执行;由于它已经读取了 V 的值,它认为可以减少 V 并获取该资源项,而该资源项已经被 B 使用。 最终,V 的值变为 -1,并且两个内核控制路径试图使用同一个资源项,这可能导致灾难性的后果。

当一个计算的结果依赖于两个或多个进程的调度顺序时,代码是错误的。 这种情况被称为竞争条件(race condition)。

通常,通过使用原子操作来确保对全局变量的安全访问。 在前面的例子中,如果两个控制路径通过单个、不可中断的操作读取并减少 V 的值,则不会发生数据损坏。 然而,内核包含许多无法通过单一操作访问的数据结构。 例如,通常不能通过单个操作从链表中移除一个元素,因为内核需要同时访问至少两个指针。 任何必须由每个开始执行它的进程完成的代码段,在另一个进程进入之前,都被称为临界区(critical region)。

这些问题不仅发生在内核控制路径之间,还发生在共享公共数据的进程之间。 为了解决这些问题,已经采用了几种同步技术。以下部分将重点讨论如何同步内核控制路径。

禁用内核抢占

为了提供一种极为简单的同步问题解决方案,一些传统的Unix内核是非抢占式的:当一个进程在内核模式下执行时,它不能被任意挂起并被另一个进程替代。 因此,在单处理器系统上,所有未被中断或异常处理程序更新的内核数据结构对内核来说都是安全的。

当然,内核模式中的进程可以自愿放弃CPU,但在这种情况下,它必须确保所有数据结构保持一致的状态。 此外,当进程恢复执行时,它必须重新检查任何之前访问过的数据结构的值,因为这些值可能已经发生变化。

适用于抢占式内核的同步机制包括在进入临界区之前禁用内核抢占,并在离开临界区后立即重新启用它。

对于多处理器系统,仅仅禁用抢占是不够的,因为在不同 CPU 上运行的两个内核控制路径可以同时访问相同的数据结构。

禁用中断

另一种适用于单处理器系统的同步机制是在进入临界区之前禁用所有硬件中断,并在离开临界区后立即重新启用中断。 虽然这个机制简单,但远非最优。如果临界区较大,中断可能会长时间被禁用,从而可能导致所有硬件活动冻结。

此外,在多处理器系统中,禁用本地 CPU 的中断并不足够,必须使用其他同步技术。

信号量 (Semaphores)

一种广泛使用的机制,适用于单处理器和多处理器系统,依赖于信号量的使用。 信号量实际上是一个与数据结构关联的计数器;所有内核线程在尝试访问数据结构之前都会检查该信号量。 每个信号量可以视为一个由以下组成的对象:

  • 一个整数变量
  • 一个等待进程的列表
  • 两个原子方法:down()up()

down() 方法减少信号量的值。 如果新的值小于 0,方法会将正在运行的进程加入信号量列表,然后将其阻塞(即调用调度程序)。 up() 方法增加信号量的值,如果新值大于或等于0,则重新激活信号量列表中的一个或多个进程。

每个要保护的数据结构都有一个自己的信号量,初始值为 1。 当一个内核控制路径希望访问数据结构时,它会在适当的信号量上执行 down() 方法。 如果信号量的新值不为负数,则允许访问数据结构。 否则,执行内核控制路径的进程会被加入信号量列表并被阻塞。 当另一个进程在该信号量上执行 up() 方法时,信号量列表中的一个进程将被允许继续执行。

自旋锁 (Spin locks)

在多处理器系统中,信号量并不总是同步问题的最佳解决方案。 有些内核数据结构应该防止被运行在不同 CPU 上的内核控制路径同时访问。 在这种情况下,如果更新数据结构所需的时间较短,使用信号量可能会非常低效。 为了检查信号量,内核必须将一个进程插入到信号量列表中,然后将其挂起。 由于这两个操作都相对昂贵,在完成这些操作所需的时间里,另一个内核控制路径可能已经释放了信号量。

在这些情况下,多处理器操作系统使用自旋锁。 自旋锁与信号量非常相似,但它没有进程列表;当一个进程发现锁被另一个进程关闭时,它会“自旋”,即反复执行紧凑的指令循环,直到锁变为开放。

当然,自旋锁在单处理器环境中是无用的。 当一个内核控制路径尝试访问被锁定的数据结构时,它会陷入一个无休止的循环。 因此,正在更新受保护数据结构的内核控制路径将无法继续执行并释放自旋锁。 最终结果将是系统挂起。

避免死锁

进程或内核控制路径在与其他控制路径同步时,可能很容易进入死锁状态。 死锁的最简单情况发生在进程 p1 获得数据结构 a 的访问权限,进程 p2 获得 b 的访问权限,但 p1 随后等待 b,p2 等待 a。 其他更复杂的进程组之间的循环等待也可能发生。 当然,死锁条件会导致受影响的进程或内核控制路径完全冻结。

就内核设计而言,当使用的内核锁数量较多时,死锁就成为一个问题。 在这种情况下,确保所有可能的内核控制路径交替执行方式中都不会发生死锁状态可能非常困难。 包括 Linux 在内的几个操作系统通过按照预定义的顺序请求锁来避免这个问题。