Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

Vinllen Chen


To be a better coder

中断之后下半部和推后执行的工作

  在前面所说的中断处理程序,由于本身存在一些局限,所以它智能完成整个中断处理流程的上半部。这些局限包括:

  1. 中断处理程序以异步方式执行,并且它有可能会打断其他重要代码(甚至包括其他中断处理程序)的执行。因此,为了避免被打断的代码停止时间过长,中断处理程序应该执行得越快越好。
  2. 如果当前有一个中断处理程序正在执行,在最好的情况下,与该中断同级的其他中断会被屏蔽,在最坏的情况下,当前处理器上所有其他中断都会被屏蔽。因为禁止中断后硬件与OS无法通信,因此,中断处理程序执行得越快越好。
  3. 由于中断处理程序往往需要对硬件进行操作,所以它们通常有很高的时限要求。
  4. 中断处理程序不在进程上下文中运行,所以它们不能阻塞。

1.下半部

  下半部的任务就是执行与中断处理密切相关但中断处理程序本身不执行的工作。在理想的情况下,最好是中断处理程序将所有工作都交给下半部分执行。我们期望中断处理程序能够尽快地返回。其实,并不存在严格明确的规定来说明到底什么任务应该在哪部分完成--如果做决定完全取决于驱动程序开发者自己的判断。其主要建议如下:

  1. 如果一个任务对时间非常敏感,将其放在中断处理程序中执行。
  2. 如果一个任务和硬件相关,将其放在中断处理程序中执行。
  3. 如果一个任务要保证不被其他中断(特别是相同的中断)打断,将其放在中断处理程序中执行。
  4. 其他所有任务,考虑放在下半部执行。

  下半部并不需要指明一个明确时间,只要把这任务推迟一点,让它们在系统不太繁忙并且中断恢复后执行就可以了。通常下半部在中断处理程序一返回就会马上运行。下半部执行的关键在于当它们运行时后,允许相应所有的中断。

1.1 下半部的起源

  下半部起源于BH,每个BH都在全局范围内进行同步。即使分属于不同的处理器,也不允许任何两个BH同时执行。这种机制使用方便却不够灵活,简单却有性能瓶颈。
  不久,内核开发者们引入了任务队列。其中每个队列都包含一个由等待调用的函数组成链表。根据其所处队列的位置,这些函数会在某个时刻执行。驱动程序可以把它们自己的下半部注册到合适的队列上去。这种机制表现不错,但仍不够灵活,没法代替整个BH接口。对于一些性能要求较高的子系统,像网络部分,它也不能胜任。

1.2 软中断和tasklet

  本部分所说的软中断不是软件引起的中断。软中断是一组静态定义的下半部接口,有32个,可以在所有处理器上同时执行--即使两个类型相同也可以。tasklet是一种基于软中断实现的灵活性强,动态创建的下半部实现机制。两个不同类型的tasklet可以在不同的处理器上同时执行,但类型相同的tasklet不能同时执行。tasklet其实是一种在性能和易用性之间寻求平衡的产物。
  对于大部分下半部处理来说,用tasklet就足够了,像网络这样对性能要求非常的情况才需要使用软中断。可是,使用软中断需要特别小心,因为两个相同的软中断有可能同时被执行。此外,软中断还必须在编译期间就进行静态注册。与此相反,tasklet可以通过代码进行动态注册。

1.3 内核定时器

  另外一个可以用于将工作推后执行的机制是内核定时器。尽管前面讨论的机制可以把操作推后到除了现在以外的任何时间进行,但是当必须保证在一确定的时间过去以后再运行时,应该使用内核定时器。

2. 软中断

  软中断是编译期间静态分配的。由softirq_action结构表示,kernel/softirq.c中定义了一个包含有32个该结构体的数组:static struct softirq_action softirq_vec[NR_SOFTIRQS]。每个被注册的软中断都占据该数组的一项,因此最多可能有32个软中断。
  一个软中断不会抢占另外一个软中断。实际上,唯一可以抢占软中断的是中断处理程序。不过,其他的软中断(甚至是相同类型的软中断)可以在其他处理器上执行。

2.1 执行软中断

  一个注册的软中断必须在被标记后才会执行。这被称作触发软中断。通常,中断处理程序会在返回前标记它的软中断,使其在稍后被执行。于是,在合适的时候,该软中断就会运行。在下列地方,待处理的软中断会被检查和执行:

  1. 从一个硬件中断代码处返回时
  2. 在ksoftirqd内核线程中
  3. 在那些显示检查和执行待处理的软中断的代码中,如网络子系统中

  不管用什么方法唤起,软中断都要在do_softirq()中执行。该函数很简单。如果有待处理的软中断,就会遍历每一个,调用它们的处理程序。

2.2 使用软中断

  软中断保留给系统中对时间要求最严格以及最重要的下半部使用。目前,只有两个子系统直接使用软中断。
  在编译期间,通过在中定义的一个枚举类型来静态声明软中断。内核用这些从0开始的索引来表示一种相对优先级。索引号小的软中断在索引号大的软中断之前执行。
  接着,在运行时通过调用open_softirq()注册软中断处理程序,该函数有两个参数:软中断的索引号和处理函数。软中断处理程序执行的时候,允许响应中断,但它自己不能休眠。在一个处理程序运行的时候,当前处理器上的软中断被禁止。但其他的处理器仍可以执行别的软中断。引入软中断的主要原因是其可扩展性。如果不需要扩展到多个处理器,那么,就使用tasklet。tasklet本质上也是软中断,只不过同一个处理程序的多个实例不能在多个处理器上同时运行。
  raise_softirq()函数可以将一个软中断设置为挂起状态,让它在下次调用do_softirq()函数时投入运行。
  在中断处理程序中触发软中断是最常见的形式。在这种情况下,中断处理程序执行硬件设备的相关操作,然后触发相应的软中断,最后退出。内核在执行完中断处理程序以后,马上就会调用do_softirq()函数。于是软中断开始执行中断处理程序留给它去完成的剩余任务。在这个例子中,“上半部”和“下半部”名字的含义一目了然。

3. tasklet

  tasklet由两类软中断代表:HI_SOFTIRQ和TASKLET_SOFTIRQ。这两者之间唯一的实际区别在于,前者类型的软中断先于后者执行。tasklet由tasklet_struct结构表示。每个结构体单独代表一个tasklet。

3.1 调度tasklet

  已调度的tasklet(等同于被触发的软中断,此处调度等同于触发)存放在两个单处理器数据结构:tasklet_vec(普通tasklet)和tasklet_hi_vec(高优先级的tasklet)。这两个数据结构都是由tasklet_struct结构体构成的链表。链表的每个tasklet_struct代表一个不同的tasklet。
  tasklet由tasklet_schedule()和tasklet_hi_schedule()函数进行调度,它们接受一个指向tasklet_struct结构的指针作为参数。两个函数非常类似(区别在于一个使用TASKLET_SOFTIRQ而另外一个用HI_SOFTIRQ)。
  tasklet_schedule()执行步骤:

  1. 检查tasklet的状态是否为TASKLET_STATE_SCHED。如果是,说明tasklet已经被调度过了,函数立即返回。
  2. 调用_tasklet_schedule()。
  3. 保存中断状态,然后禁止本地中断。在我们执行tasklet代码时,这么做能够保证当tasklet_schedule()处理这些tasklet时,处理器上的数据不会弄乱。
  4. 把需要调度的tasklet加到每个处理器一个的tasklet_vec链表或tasklet_hi_vec链表的表头上去。
  5. 唤起TASKLET_SOFTIRQ或HI_SOFTIRQ软中断,这样在下一次调用do_softirq就会执行该tasklet。
  6. 恢复中断到原状态并返回。

  其中,最近一个中断返回的时候看起来就是执行do_softirq()的最佳时机。以下是tasklet_action()和tasklet_hi_action()的执行,就是tasklet处理的核心。

  1. 禁止中断,并为当前处理器检索tasklet_vec或tasklet_hig_vec链表。
  2. 将当前处理器上的该链表设置为NULL,达到清空效果。
  3. 允许响应中断。
  4. 循环遍历获得链表上的每一个待处理的tasklet。
  5. 如果是多处理器系统,通过检查TASKLET_STATE_RUN来判断这个tasklet是否正在其他处理器上运行。如果它正在运行,那么现在就不要执行,跳到下一个待处理的tasklet去。
  6. 如果当前这个tasklet没有执行,将其状态设置为TASKLET_STATE_RUN,这样别的处理器就不会再执行它了。
  7. 检查count值是否为0,确保tasklet没有被禁止。如果tasklet被禁止了,则跳到下一个挂起的tasklet去。
  8. 我们已经清楚地知道这个tasklet没有在其他地方执行,并且被我们设置成执行状态,这样它在其他部分就不会被执行,并且引用计数为0,现在可以执行tasklet的处理程序了。
  9. tasklet运行完毕,清除tasklet的state域的TASKLET_STATE_RUN状态标志。
  10. 重复执行下一个tasklet,直至没有剩余等待处理的tasklet。

3.2 使用tasklet

  tasklet可以动态或静态创建。
  因为tasklet是靠软中断实现,所以tasklet不能睡眠。两个相同的tasklet决不会同时执行,这点和软中断不同--尽管两个不同的tasklet可以在两个处理器上同时执行。如果你的tasklet和其他的tasklet或者是软中断共享了数据,必须加锁保护。
  在tasklet被调度以后,只要有机会它就会尽可能早的运行。在它还没得到运行机会之前,如果有一个相同的tasklet又被调度了,那么它仍然只会运行一次。而如果这时它已经开始运行了,比如在另外一个处理器上,那么这个新的tasklet会被重新调度并再次运行。作为一种优化措施,一个tasklet总是在调度它的处理器上执行--这是希望能更好地利用处理器的高速缓存。
  可以通过tasklet_disable()来禁止指定tasklet。可以调用tasklet_kill()函数从挂起的队列中去掉一个tasklet。
  每个处理器都有一组辅助处理软中断的内核线程。党内和中出现大量软中断,这些内核进程就会辅助处理它们。
  如果软中断本省出现频率很高,再加上它们又有将自己重新设置为可执行状态的能力,那么就会导致用户空间进程无法获得足够的处理器时间,因而处于饥饿状态。而且,单纯的对重新触发的软中断采取不立即处理的策略,也无法让人接受。
  处理以上问题的做法就是:当大量软中断出现的时候,内核会唤醒一组内核线程来处理这些负载。这些线程在最低的优先级上运行(nice=19),这就能避免它们跟其他重要的任务抢夺资源。但它们最终肯定会被执行,所以,这个这种方案能够保证在软中断负担很重的时候,用户程序不会因为得不到处理时间而处于饥饿状态。相应的,也能保证“过量”的软中断终究会得到处理。

4. 工作队列

  工作队列可以把工作推后,交由一个内核线程去执行--这个下半部分总是会在进程上下文中执行。这样,通过工作队列执行的代码能占尽进程上下文的所有优势。最重要的是工作队列允许重新调度甚至睡眠。
  工作队列子系统是一个用于创建内核线程的接口,通过它创建的进程负责执行由内核其他部分排到队列里的任务。它创建的这些内核线程称为工作者线程。缺省的工作者线程叫做events/n,这里n是处理器的编号;每个处理器对应一个线程。许多内核驱动程序都把它们的下半部交给缺省的工作者线程去做。除非一个驱动程序或子系统必须建立一个属于它自己的内核线程,否则最好使用缺省线程。

5. 三者比较

  以上三者:软中断,tasklet和工作队列中,tasklet基于软中断实现,所以两者很相近。工作队列机制与它们完全不同,它靠内核线程实现。
  从设计角度,软中断提供的执行序列化的保障最小。这就要求软中断处理函数必须格外小心采取一些步骤确保共享数据的安全。如果被考察的代码本省多线索化工作就做的非常好,比如网络子系统,它完全使用单处理器变量,那么软中断就是非常好的选择。对于时间要求严格和执行频率高的应用来说,它执行得也最快。
  如果代码多线索化考虑得并不充分,那么选择tasklet意义更大。它接口简单,而且,由于两个同种类型tasklet不能同时执行,所以实现也会简单一些。驱动程序开发者应该尽可能选择tasklet。
  如果需要把任务推后到进程上下文中完成,那么这三者中就只能选择工作队列。工作队列开销最大,因为牵扯到内核线程甚至是上下文切换。   易于使用来说:工作队列>tasklet>软中断。

6. 加锁保护

  在使用下半部机制时,即使是在一个单处理器的系统上,避免共享数据被同时访问也是至关重要。记住,一个下半部实际上可能在任何时候运行。
  一般单纯禁止下半部的处理是不够的。为了保证共享数据的安全,更常见的做法是,先得到一个锁再禁止下半部的处理。驱动程序通常都使用这种方法。
  可以通过local_bh_disable()和local_bh_enable()来禁止和激活本地处理器的软中断和tasklet的处理。
   这些函数并不能禁止工作队列的执行。因为工作队列是在进程上下文中执行的,不会涉及异步执行的问题,所以没有必要禁止。由于软中断和tasklet是异步发生的,所以,内核代码必须禁止它们。另一方面,对于工作队列来说,它保护共享数据所做的工作和其他任何进程上下文中所作的都差不多。

参考:《Linux内核设计与实现》


About the author

vinllen chen

Beijing, China

格物致知


Discussions

comments powered by Disqus