Linux 中断、抢占、锁之间的关系

抢占(preempt)

现代操作系统为了提高资源的利用率一般都支持多任务(这里不想纠结进程、线程、内核线 程之间的关系所以使用任务一词),但是因为系统资源有限,系统中同一时间点能够运行的 任务是有限的(单核的话只有一个,多核可能有多个)。为了解决这个冲突,操作系统不得 不在任务之间不断的切换,让一些任务运行一段时间之后睡眠,然后从原来处于睡眠状态的 任务中选出一些来继续运行。这种从一个运行的任务切换到另一个运行的任务的行为叫做任 务切换,而任务切换是通过调度来完成的。

调度又分为两种调度:抢占式的调度和非抢占式的调度。在非抢占式的调度中,任务之间通 过协调来决定哪一个任务将会在下一刻运行,而在抢占式的调度中这一决策由调度器来完成 。调度器这种暂时停止一个任务的运行从而让另一个任务运行的行为就称为抢占。Linux 使 用的就是抢占式调度。

抢占一般又分为用户抢占和内核抢占。用户抢占:是指的内核在返回用户空间时做出的抢占 决定,它可能发生在一下两种情况下:

  • 内核从一个系统调用中返回用户空间(也就是说一个进程调用了一个系统调用从而陷入内 核空间,当内核完成了这个系统调用之后返回用户空间)。

  • 内核从一个中断处理器中返回用户空间(也就是说一个进程在正常执行中内核收到中断信 号转而执行中断处理器,中断处理器完成任务之后返回用户空间继续原来的进程执行)

内核抢占:是指在内核代码的执行过程中发生的抢占。通常在执行内核代码时必须等待内核 代码执行完成之后才能进行下一次调度(也就是上面的用户抢占的两种情况),也就是说调 度器无法在任务还处于内核中的时候对它进行抢占。Linux 实现了的内核抢占使得内核代码 的执行也可以被抢占,条件是当前任务没有持有锁。这是通过在每一个进程的 thread_info 中加入 preempt_count 计数器来实现的,如果这个计数器为 0 表示可以 抢占,否则就不行。以下四中情况下可能会发生内核抢占:

  • 中断处理器结束并返回内核空间之前(也就是说内核代码正在执行的时候被中断)

  • 内核再次变成可以抢占的时候(内核是否可以抢占需要依赖 preempt_count 如果它从 非零变成零也就是再次变为可抢占)

  • 内核代码显式调用 schedule() 函数(如果内核代码显式调用该函数表明内核代码确定 目前可以抢占,否则就是代码的 BUG 了)

  • 任务在内核中阻塞(这也会导致 schedule() 被调用)

中断(interrupt)

内核需要管理硬件资源,也就是说需要和硬件之间有通信方式存在。考虑到处理器一般速度 比硬件快上好几个级别,让内核发送请求而等待硬件的响应显然是不可取的。我们需要一种 方式使得硬件能够通知内核,让内核响应硬件的请求。

方式之一是通过“轮询”定期的检查硬件的状态从而进行响应,显然这种方式的开销太大。另 一种更为合理的方式是提供一种机制,让硬件在需要和内核通信的时候给内核发送信号,这 种机制就是“中断”。而当内核收到信号之后去处理信号的代码叫做“中断处理器”(注意不要 混淆中断处理器还中断控制器,前者是内核函数属于软件范畴,而后者是一个芯片是硬件范 畴)。

很显然中断在任何时刻都可能会发生,因为这是一种异步的通信方式,内核无法控制硬件在 什么时候会去发送信号给它(比如说内核它永远不可能知道你下一次敲击键盘是什么时候) 。

中断和抢占的关系

关于中断和抢占之间的关系,在《Linux Kernel Development (Third Edition)》的P127页 中断控制的第二段中,作者提到了这样一句话:

Moreover, disabling interrupts also disbles kernel preemption.

也就是说其实中断和抢占有着比较密切的联系,我并不能完全理解这句话的意义。其中最后 一个词语 kernel preemption 指的是否仅仅是内核抢占还是包括用户抢占不得而知(从字 面上说应该是单独指的内核抢占)。

在 stack overflow 中有很多人提这个问题,各种解释众说纷纭不过有一点可以肯定的是, 中断处理器结束运行之后无论是返回用户空间(用户抢占)还是返回内核空间(内核抢占) 都有可能会发生调度从而出现抢占,这点在前面关于抢占的讨论中有提到过。从这个角度理 解,阻止了中断至少不会出现从中断处理器中返回而导致的抢占。

(关于这段话如果大家有更好的解释欢迎补充,我目前还没有系统的读内核代码,所以没有 看到他们之间的直接联系)

另外中断处理是在一个特殊的上下文(context)中断上下文中完成的,在这个上下文中中 断处理器无法阻塞也不能睡眠,因为它本身没有一个进程在背后支撑,所以它是不可调度的 。也就是说中断处理器是无法抢占的。但是中断处理器还是有可能被其他的中断处理器中断 的,因为中断可能发生在任何的时候,如果出现了更高优先级的中断,那么很可能当前中断 处理器的处理再次被中断。

抢占和锁的关系

抢占中的内核抢占可以发生的条件是任务不能持有锁,也就是说 preempt_count 必须是为 零,所以说加锁可以阻止内核抢占。

Linux 内核提供了阻止内核抢占的 API – preempt_disable()preempty_enable() 。这两个函数其实是通过控制 preempt_count 来达到效果的。下面是 preempt_disble() 的定义:

1
2
3
4
5
#define preempt_disable() \
do { \
preempt_count_inc(); \
barrier(); \
} while (0)

从代码中我们可以明显的看到它直接调用的就是增加 preemptc_count 的值的另一个例程 (routine,这里用 routine 是因为它本身还是一个宏定义,而这个宏定义的最终扩展 会调用一个函数,我不想去区分宏定义和函数在这里的差别,所以使用了 routine 而不 是 function),从而达到了阻止内核抢占的效果。

所以说锁和内核抢占有直接的联系,因为持有锁的任务不能进行内核抢占。LKD 中我目前没 有看到关于锁和用户抢断之间的联系这里不妄下定论。

中断和锁的关系

中断和锁理论上说应该没有直接的联系,但是在实践中有较大的瓜葛。在同步处理中,锁是 起着非常关键的作用,而当加锁和中断联系在一起的时候问题就变得非常有趣了。

因为中断随时可能发生,所以很有可能会出现以下这种情况:当一个任务正在执行的时候被 中断的到来打断了。一般情况下这并没有什么大碍,等待中断处理结束之候原任务可以继续 运行(或者运行其他任务,因为从中断处理返回之后会进行调度)。但是如果涉及到同步问 题处理,事情就变得复杂了。

我们知道为了防止竞争条件的出现,我们需要给共享数据加锁形成临界区。在中断处理器中 同样如此,但是因为中断处理器本身不能被阻塞也不能睡眠所以它能使用的锁就只有自旋锁 (spin lock)。假设任务 A 获得了某个临界区的自旋锁 L 但是还没有退出临界区(也就 是还没有释放这把自旋锁),这个时候它被一个突如其来的中断打断,内核转而执行中断处 理器 B,而这个中断处理器 B 同样需要访问这个临界区,因此它需要先获取自旋锁 L,于 是中断处理器 B 开始自旋因为锁被 A 占有而 A 被 B 打断无法运行也就无法释放自旋锁 L ,更糟糕的是中断处理是无法抢占的所以 B 一直自旋,这就形成了死锁。

因此在实践中使用自旋锁的同时一般会阻止中断的发生,Linux 内核甚至提供了同时完成这 两个步骤的 API – spin_lock_irqsave()spin_unlock_irqrestore() 等等。阻止中 断可以使得代码不被当前处理器上的中断打断,而加锁可以使得防止其他处理器上的任务同 时处理数据。结合这两者就可以很好的处理同步问题了。


以上内容是我阅读《Linux Kernel Development(Third Edition)》时的一些总结,如果有 错误的地方欢迎大家指正。