中断嵌套与可重入性
从 Context 中可知,中断具有高优先级和异步抢占的特性。上下文的切换并不是受到 OS 调度器控制的。而且所有的中断处理都是在同一个执行流中完成的,这些性质会在发生“中断嵌套”时造成很糟糕的 bug 。
中断嵌套指的是在处理中断的时候又发生一个中断。这种现象和某个线程执行的时候,突然切到另一个线程具有一定的相似性,但是又不完全相同,对于这种并发编程的情况,我们可以用锁来避免对于资源的同时访问,保证线程安全。但是对于中断嵌套,普通的锁就没有用了,如下所示:
我们假设我们已经在中断处理中拿到一个锁了,因为再次发生中断,重新执行拿锁操作,那么就拿不到,因为锁在外层中断里。而如果内层中断一直拿不到锁,外层中断也一直没法执行,就构成了死锁。究其根本,是因为所有的异常处理都位于同一个执行流(也就是在逻辑上是同步的,很神秘吧),内层拿不到锁,无法切换到;而并行编程里有多个执行流,当一个线程拿锁不成功时,可以切换到拿锁成功的线程继续处理。
所谓的可重入性(Reentrancy),就是为了在中断嵌套情景下避免出现 bug 的特性(不止是死锁),其定义为:
it can be safely called again before its previous invocation has been completed
也就是描述在同一个执行流中反复调用的特性,从这个角度看,中断更像是一种 jump
指令,而非一种 switch()
函数。
更具体的说,可重入性具有以下标准:
- 不写入共享数据
- 避免死锁
- 避免代码的自修改
- 不调用不可重入的函数,比如
printf(), malloc()
。
满足这些并不能保证一定是可重入的,总之可重入性对于代码是一个很强很难实现的约束。(另外可重入代码一定是线程安全的,但是线程安全的不一定是可重入的)。
原子中断
很多处理器在进入中断处理程序前,都会自动地将全局中断使能关上,这是因为如果不自动关上中断,那么中断触发信号会一直都在,那么处理器会反复执行硬件中断处理流程(比如反复调到异常入口函数处)。
所以硬件处理的第一步就是将全局使能关上,这样才可以正常地进入异常处理流程。之所以要全局禁用而不是仅禁用当前中断,这是因为同时可能存在多个中断,禁用一个中断有可能不顶用。
这种操作其实很好的解决了中断嵌套的问题,因为关掉全局中断以后,不会再产生中断了,也就不会嵌套了,所以就没有可能有 bug 了。这种方式并不要求中断代码,因为其不可被打断,所以被称为“原子中断”。
原子中断的缺点在于,当中断处理函数过长的时候,那么关中断的时间就会过长,就会影响中断的实时性。
中断上下部
正因为原子中断的缺点,Linux 系统将中断的处理分成了上下部:
- 上半部(top half):完成一些必要但轻量级的操作,如向中断控制器确认中断
- 下半部(bottom half):完成剩余的、复杂且时延相对较低的操作
只有上半部是关闭中断的,而下半部则是开启中断的。下半部在 Linux 中有 3 种类型:
软中断 softirq
软中断是最为朴素的下半部实现方式,它在内核中维护了一个关于软中断的优先队列。在硬中断结束后,他们会按照优先级依次执行软中断,此时是允许中断的。
然后就引发了一个问题,就是因为中断使能开启,所以软中断会出现中断嵌套问题,所以软中断必须确保具有可重入性。
tasklet
软中断是在代码编译时就确定了,是无法运行时动态创建。为了解决这个问题,Linux 提出了 tasklet 机制。
在实现上,tasklet 也构成了一个队列,它属于软中断的一种(其实是两种,对应两个不同的优先级)。
tasklet 的优势还在于,内核会保证 tasklet 的原子性,所以并不会出现中断嵌套的情况。更幸运的是,内核甚至保证了 tasklet 不会并发执行,所以来线程安全都不用考虑了。
总的来说,tasklet 更侧重编程开发者的友好性,而 softirq 更侧重运行的效率。
工作队列 work queue
softirq 和 tasklet 都使用的是中断上下文(废话),所以有一个很大的缺点就在于中断上下文不可睡眠,不可调度,这就导致了很高的延迟。而相反的,像进程上下文就是可被调度,将进程上下文和中断处理相结合,就得到了 work queue 。
工作队列是将下半部交给内核线程来执行的设计思想,下半部被描成一个 work 并加入到一个 work queue 中,每个内核工作线程(kworker)负责处理这样的一个 work queue ,他们会串行的执行加入队列中的所有 work 。内核工作线程就是可调度的,也不需要保证可重入性,但是需要保证线程安全。
但是因为每个工作队列是严格 FIFO 执行的,必须先处理好前面的 work ,才能处理后面的 work ,所以依然会存在死锁问题。并且驱动开发人员不愿意自己的 work 排队,所以往往会新创建一个 work queue ,这样就导致系统资源被大大消耗。
再后来 Linux 引入了并发可管理工作队列(Concurrency Managed Workq Queue, CMWQ),将工作队列的管理从驱动开发者中交还给内核。