Linux中断下半部及推后执行的工作

背景

Linux中断上半部,参见Linux中断和中断处理程序
Linux中断下半部,参见Linux中断下半部及推后执行的工作

这部分讲中断下半部和推后执行的工作。

中断处理程序(中断上半部)的局限:

  • 以异步方式执行,并且有可能打断其他重要代码(包括其他中断处理程序)的执行。因此,中断处理程序应执行越快越好。
  • 如果当前有个中断处理程序正在执行,在最好的情况下(如果设置了SA_INTERRUPT),与该中断同级的其他中断会被屏蔽;最坏情况下,当前处理器上所有其他中断都会被屏蔽。
  • 由于中断处理程序往往需要对硬件进行操作,所以通常有很高时限要求。
  • 中断处理程序不在进程上下文中运行,所以它们不能阻塞。这限制了它们所做的事情。(中断处理程序可以嵌套中断,但不能阻塞)

这是中断处理程序 只能作为整个硬件中断处理流程的一部分原因
上半部作为快速、异步、简单的处理程序,负责对硬件做出响应,并完成对时间要求严格的操作;
对于那些时间要求相对宽松的任务,应该推迟到中断被激活后的后半部运行。

下半部

中断上半部,是指中断处理程序:中断的下半部(bottom half),是执行与中断处理密切相关,但中断程序本身不执行的工作。也就是说,没有严格规定说,到底什么任务应该在上半部,还是下半部中完成,如何做取决于驱动程序开发者自己的判断。

中断处理程序会异步执行,而且在最好的情况下,也会锁定当前的中断线。因此将中断处理程序持续执行的时间缩短到最小程度,显得十分重要。虽然上半部、下半部工作划分,没有严格规则,但也存在一些提示可供借鉴:

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

为什么要用下半部?

希望减少中断处理程序中要完成的工作量,因为它运行时,当前中断线在所有处理器上都会被屏蔽。如果是SA_INTERRUPT类型的中断处理程序,执行时会禁用所有本地中断。因此,缩短中断被屏蔽时间,将一些工作放到以后去做,对系统的响应能力和性能都至关重要。

具体放到以后什么时候去做?
没有明确时间,只需要把这些任务推迟一点即可。可以在系统不太繁忙,并且中断恢复后执行即可。通常,下半部在中断处理程序一返回就马上运行。关键在于,这些工作运行的时候,运行响应所有的中断。

下半部的多种实现机制

下半部有多种实现机制。

1)BH(bottom half)
内核2.5以后,BH接口被弃用。
Linux最早实现下半部的唯一机制。提供一个静态创建、由32个bottom half组成的链表。上半部通过一个32bit整数中1bit来标识哪个bottom half可以执行。每个BH都在全局范围内进行同步。即使分属不同的处理器,也不允许任何2个bottom half同时执行。

特点:使用方便但不够灵活,简单却有性能瓶颈。

2)任务队列(task queue) / 工作队列(work queue)
用来替代BH机制,内核定义了一组队列,其中每个队列都包含一个由等待调用的函数组成链表。根据其所处队列的位置,这些函数会在某个时刻被执行。驱动程序可以把自己的下半部注册到合适的队列上去。

特点:比BH性能更好,但不够灵活。对于一些性能要求较高的子系统,如网络部分,不能胜任。

3)软中断(softirqs)和tasklet
软中断不同于实现相同调用的软件中断。软中断和tasklet可以完全替代BH接口。软中断是一组静态定义的下半部接口,有32个,可以在所有处理器同时执行 ---- 即使2个类型相同也可以。
tasklet 是一种基于软中断实现的灵活性强、动态创建的下半部实现机制。2个不同类型的tasklet可以在不同处理器上同时执行,但类型相同的tasklet不能同时执行。

特点:对于大部分下半部来说,用tasklet即可,而像网络这样对性能要求非常高的情况,才需要用软中断;
2个相同的软中断有可能同时执行;
软中断必须在编译期就静态注册,而tasklet客通过代码动态注册。

4)内核定时器
内核定时器可以将工作推后到某个确定的时间段之后执行。

特点:可以将工作推后到某个确定的时间段执行。

小结:

  • “下半部” 用于指代中断处理流程中,推后执行的那一部分。用于实现将工作推后执行的内核机制称为“下半部机制”。软中断是实现下半部的一种机制,但不等于下半部。
  • Linux2.6中,内核提供了三种不同形式的下半部实现机制:软中断、tasklet和工作队列。BH、任务队列被弃用。

[======]

软中断

软中断使用较少,通常用tasklet这种形式。不过,tasklet也是通过软中断实现的。
软中断代码位于:kernel/softirq.c

软中断的实现

软中断是编译期静态分配,tasklet是动态注册、删除。软中断由softirq_action结构表示,定义于<linux/interrupt.h>:

/* 本结构代表一个软中断项 */
struct softirq_action {
    void (*action) (struct softirq_action *); /* 待执行的函数 */
    void *data; /* 传给函数的参数 */
};

kernel/softirq.c 定义了一个包含32个结构体的数组

static struct softirq_action softirq_vec[32];

每个被注册的软中断都占用数组的一项,因此最多支持32个软中断。这是个定值,2.6内核只用到6个。

  1. 软中断处理程序
    action的函数原型:
void softirq_handler(struct softirq_action*);

内核运行一个软中断处理程序的时候,就会执行这个action函数,其参数为指向softirq_action结构指针。
如果my_softirq指向softirq_vec数组某项,那么内核会用如下方式调用软中断处理程序中的函数:

int i = 0;
struct softirq_action* my_softirq = &softirq_vec[i]; /* my_softirq指向softirq_vec某项 */
...
my_softirq->action(my_softirq); /* 调用软中断处理程序中的函数 */

注意:
1)传递给软中断处理程序的是整个softirq_action指针,而非其data域。这是为了保证将来结构体加入新域时,无需对所有的软中断处理程序都进行变动,依然可以方便地解析其参数。
2)一个软中断不会抢占另一个软中断,不过可以在不同处理器同时执行。而唯一可以抢占软中断的是中断处理程序。

  1. 执行软中断
    一个注册的软中断必须在被标记后才会执行,这被称为触发软中断(raising the softirq)。通常,上半部会在返回前标记其软中断,使其稍后执行。
    在下列对方,待处理的软中断会被检查和执行:
  • 从一个硬件中断代码处返回时。
  • 在ksoftirqd内核线程中。
  • 在那些显式检查和执行待处理的软中断的代码中,如网络子系统中。

不论用什么办法唤起,软中断都要在do_softirq()中执行。如果有待处理的软中断,do_softirq()会循环遍历每一个,调用它们的处理程序。do_softirq()经过简化后的核心部分:

u32 pending = softirq_pending(cpu); /* 保存软中断位图(32位), 得到哪些软中断有待处理 */

if (pending) { /* 有软中断待处理 */
    struct softirq_action* h = softirq_vec; /* h指向包含32个软中断待处理程序、数据信息的softirq_vec数组 */
    softirq_pending(cpu) = 0; /* 清除软中断位图, 因为已经保存到pending */

    /* 循环检查软中断位图并调用相应中断处理程序 */
    do {
        if (pending & 1)
            h->action(h); /* 执行相应的软中断处理程序 */
        h++;
        pending >>= 1;
    }while(pending);
}

do_softirq检查并执行所有待处理的软中断,具体包括:
1)用局部变量pending保存softirq_pending() 宏的返回值。它是待处理的软中断的32bit位图,如果第n位被设置为1,那么第n位对应类型的软中断等待处理。
2)现在待处理的软中断位图已经被保存,可以将实际的软中断位图清零。
3)将指针h指向softirq_vec的第一项。
4)如果pending第一位被置为1,h->action(h) 被调用。
5)指针+1,所以现在它指向softirq_vec数组的第二项。
6)位掩码pending右移一位。这样会丢失第一位,然后让其他各位依次向右移动一个位置。原来第二位现在就在第一位的位置上了(依此类推)。
7)现在指针h指向数组的第二项,pending位掩码的第二位现在也收到了第一位上。重复执行上面的步骤。
8)一直重复,直到pending变为0,表明已经没有待处理的软中断了。这种检查足以保证h总指向softirq_vec的有效项,因为pending最多只可能设置32位,循环最多执行32次。

使用软中断

软中断保留给系统中对时间要求最严格,以及最重要的下半部使用。目前只有2个子系统:网络和SCSI,直接使用软中断。
内核定时器和tasklet都是建立在软中断上的。打算用软中断时,优先考虑用tasklet

  1. 分配索引
    编译期间,可以通过<linux/interrupt.h>中定义的枚举类型来静态声明软中断,枚举值代表索引,同时表示优先级(值越小优先级越高)。

  1. 注册你的处理程序
    运行时调用open_softirq()注册软中断处理程序,该函数有3个参数:软中断的索引号、处理函数、data域存放的数值。
    如,网络子系统通过以下方式注册软中断:
open_softirq(NET_TX_SOFTIRQ, net_tx_action, NULL);
open_softirq(NET_RX_SOFTIRQ, net_rx_action, NULL);

软中断处理程序时,允许响应中断,但不能休眠(能中断,但不能阻塞)。一个处理程序运行时,当前处理器上的软中断被禁止,但其他处理器仍然可以执行别的软中断(能并发,但不能嵌套)。如果,同一个软中断在被执行时再次被触发,意味着处理程序重入。也就是说,任何共享数据都需要严格的锁保护。

大部分软中断处理程序都通过采取单处理器数据(访问处理器局部数据,不需要加锁),或其他一些技巧来避免显式加锁,从而提供更好的性能。

  1. 触发你的软中断
    通过在枚举类型的列表中添加新项,以及调用open_softirq()注册后,新的软中断处理程序就能运行。raise_softirq() 可以将一个软中断设置为挂起状态,让它在下次调用do_softirq()时投入运行。
    例如,网络子系统可能会调用:
/* 中断未禁止时调用 */raise_softirq(NET_TX_SOFTIRQ); /* 触发软中断 */

这会触发NET_TX_SOFTIRQ软中断,进而处理程序net_tx_action(前面通过open_softirq()注册)会在内核的下一次执行软中断时投入运行。
raise_softirq()会先禁止中断,触发后再恢复原来的状态。如果中断本来就已经被禁止,那么可以调用raise_softirq_irqoff(),这会带来一些优化效果。如:

/* 中断已被禁止时调用 */

raise_softirq_irqoff(NET_TX_SOFTIRQ); /* 触发软中断 */

[======]

tasklet

tasklet是利用软中断实现的一种下半部机制。跟进程没有任何关系。tasklet和软中断在本质上很相似,行为表现也接近,但接口更简单,锁保护要求较低。

通常,建议使用tasklet,而不是软中断。

tasklet的实现

tasklet通过软中断实现,因此本身也是软中断。tasklet两类软中断代表:HI_SOFTIRQ,TASKLET_SOFTIRQ。HI_SOFTIRQ类型的软中断先于TASKLET_SOFTIRQ的软中断执行。

1)tasklet结构体

每个结构体是tasklet_struct类型,单独代表一个tasklet,定义于<linux/interrupt.h>:

struct tasklet_struct {
    struct tasklet_struct *next;  /* 链表中的下一个tasklet */
    unsigned long state;          /* tasklet的状态 */
    atomic_t count;               /* 引用计数器 */
    void (*func)(unsigned long);  /* tasklet处理函数 */
    unsigned long data;           /* 给tasklet处理函数的参数 */
};

tasklet_struct.func成员是tasklet的处理程序,data是其唯一参数。state成员只能在{0, TASKLET_STAT_SCHED, TASKLET_STATE_RUN}之间取值。
TASKLET_STAT_SCHED:表明tasklet已被调度,正准备投入;
TASKLET_STATE_RUN:表面该tasklet正在运行。只有在多处理器的系统上,才会作为一种优化来使用,单处理器系统任何时候都清楚单个tasklet是不是在运行。

在中断处理程序中触发软中断是常见形式。

2)调度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()处理这些tasklet时,处理器上的数据不会弄乱。
(3)把需要调度的tasklet加到每个处理器一个的tasklet_vec链表或tasklet_hi_vec链表的表头上去。
(4)唤起TASKLET_SOFTIRQ或HI_SOFTIRQ软中断,这样下一次调用do_softirq()时,就会执行该tasklet。
(5)恢复中断到原状态并返回。

前面提到过挂起,do_softirq()会尽可能早地在下一个合适的时机执行。由于大部分tasklet和软中断都是在中断处理程序中被设置为待处理状态,所以最近一个中断返回的时候,看起来就是执行do_softirq()的最佳时机。因为TASKLET_SOFTIRQ和HI_SOFTIRQ已经被触发了,所以do_softirq()会执行相应的软中断处理程序。

这两个处理程序,tasklet_action()和tasklet_hi_action() 就是tasklet处理的核心。它们做了什么:
(1)禁止中断,并为当前处理器检索tasklet_vec或tasklet_hi_vec链表。没有必要首先保存其状态,因为这里的代码总是作为软中断被调用,而且中断总是被激活的。
(2)将当前处理器上的该链表设置为NULL,达到清空的效果。
(3)允许响应中断。没有必要再恢复它们回原状态,因为这段程序本身就是作为软中断处理程序被调用的,所以中断应该被允许。
(4)循环遍历获得链表上的每个待处理的tasklet。
(5)如果多处理器系统,通过检查TASKLET_STATE_RUN状态标志来判断这个tasklet是否正在其他处理器上运行。如果正在运行,那么现在就不要执行,跳到下一个待处理的tasklet去,因为同一时间里,相同类型的tasklet只能有一个执行。
(6)如果当前这个tasklet没有执行,将其状态标志设为TASKLET_STATE_RUN,这样别的处理器就不会在执行它了。
(7)检查count值(引用计数)是否为0,确保tasklet没有被禁止。如果tasklet被禁止,则跳到下一个挂起的tasklet。
TODO:为什么count值为0,跟tasklet有没有被禁止有关??

(8)该tasklet没有在其他地方执行,并且被我们设置成执行状态,这样它在其他部分就不会被执行,并且引用计数为0,现在可以执行tasklet的处理程序了。

(9)tasklet运行完毕,清除tasklet.state域的TASKLET_STATE_RUN标志。

(10)重复执行下一个tasklet,直至没有剩余的等待处理的tasklet。

tasklet的实现简单、巧妙。所有tasklet都通过重复运用HI_SOFTIRQ和TASKLET_SOFTIRQ这两个软中断来实现。当一个tasklet被调度时,内核就会唤起这两个软中断中的一个。随后,该软中断会被特点的函数处理,执行所有已调度的tasklet。该函数保证同一时间里只有一个给定类比的tasklet会被执行,但其他不同类型的tasklet可以同时执行。

使用tasklet

步骤:
1)声明自己的tasklet
可以静态或动态创建。选哪种方式,取决于你到底是有一个对tasklet的直接引用,还是间接引用。如果你准备静态地创建一个tasklet(即有一个直接引用),使用下面<linux/interrupt.h>中定义的两个宏中的一个:

/* 静态创建: 二者都根据给定名称一个tasklet_struct结构 */
DECLARE_TASKLET(name, func, data);          /* 引用计数器初值0, 该tasklet处于激活状态 */
DECLARE_TASKLET_DISABLED(name, func, data); /* 引用计数器初值1, 该tasklet处于禁止状态 */

当该tasklet被调度以后,给定的函数func会被执行,参数由data给出。

例,下面两行代码等价:

DECLARE_TASKLET(my_tasklet, my_tasklet_handler, dev);
<=>
stcut tasklet_struct my_tasklet = {NULL, 0, ATOMIC_INIT(0), my_tasklet_handler, dev};

创建了一个名为my_tasklet,处理程序为my_tasklet_handler且已被激活的tasklet。当处理程序被调用时,dev就会被传递给它。

还可以通过将一个间接引用(一个指针)赋给一个动态创建的tasklet_struct结构的方式来初始化一个tasklet:

/* 动态创建 */
tasklet_init(t, tasklet_handler, dev); 

2)编写自己的tasklet处理程序

tasklet处理程序必须符合规定的函数类型:

void tasklet_handler(unsigned long data);

tasklet是靠软中断实现,因此其处理程序不能睡眠,i.e. 不能在tasklet中使用信号量或其他阻塞式函数。如果你的tasklet和其他的tasklet或软中断共享了数据,必须进行适当的锁保护,或者屏蔽中断。

两个相同类别的tasklet绝不会同时执行,这点和软中断不同:尽管两个不同的tasklet可以在两个处理器上同时执行。

3)调度自己的tasklet

通过调用tasklet_schedule()函数并传递给它相应的tasklet_struct的指针(动态或静态创建的tasklet),该tasklet就会被调度以便执行:

tasklet_schedule(&my_tasklet); /* 把my_tasklet标记为挂起 */

tasklet 被调度以后,只要有机会它就会尽可能早运行。在没有得到运行机会前,如果有一个相同的tasklet又被调度了,那么它仍然只会运行一次。而如果此时它已经开始运行了,比如在另一个处理器上,那么这个新tasklet会被重新调度并再次运行。作为一种优化措施,一个tasklet总在调度它的处理器上执行 -- 希望更好利用处理器的高速缓存。

调用tasklet_disable(),可禁止某个指定的tasklet。如果该tasklet正在执行,会等待它执行完毕再返回。
调用tasklet_disable_nosync(),可禁止指定tasklet,不会等待tasklet执行完毕再返回。因此不太安全。
调用tasklet_enable() ,激活一个tasklet,如果希望激活DECLARE_TASKLET_DISABLED()创建的tasklet,也得调用该函数。

例如:

tasklet_disable(&my_tasklet); /* tasklet现在被禁止 */

/* 现在知道tasklet不能运行 .. */

tasklet_enable(&my_tasklet);  /* tasklet现在被激活 */

调用tasklet_kill() ,从挂起的队列中去掉一个tasklet。常用于从挂起队列移去已调度的tasklet。函数首先等待该tasklet执行完毕,然后再将其移去。没什么可以阻止其他地方重新调度该tasklet。该函数可能引起休眠,因此禁止在中断上下文中使用。

ksoftirqd

每个处理器都有一组辅助软中断和tasklet的内核线程。当内核中出现大量软中断时,这些内核进程就会辅助处理它们。

对于软中断,内核中几个特殊时机进行处理:而中断处理程序返回时处理是最常见的。软中断被触发的频率有时可能很高(如大量网络通信)。

但软中断存在一个问题:处理函数有时会自行重复触发。i.e. 当一个软中断执行的时候,它可以重新触发自己以便再次得到执行(如网络子系统)。如果软中断本身出现的频率高,再加上又有将自己重新设为可执行状态的能力,那么可能会导致用户空间进程无法得到足够的处理器时间,因而处于饥饿状态。而且,对重新触发的软中断不采取立即处理的策略,也无法让人接受。

解决方案:

  • 方案一:只要还有被触发并等待处理的软中断,本次执行就要负责处理,重新触发的软中断也在本次执行返回前被处理。

这样做可以保证对内核的软中断采取即时处理的方式,关键在于,对重新触发的软中断也会立即处理。

存在的问题:当负载很高时,会有大量被触发的软中断,而它们本身又会重复触发。系统可能一直在处理中断,根本没时间完成其他任务,从而导致用户空间的任务被忽略。

  • 方案二:选择不处理重新触发的软中断。在从中断返回时,内核和平常一样,也会检查所有挂起的软中断并处理它们。

任何自行重新触发的软中断都不会马上处理,它们被放到下一个软中断执行时机去处理。i.e. 重新触发的软中断的时机通常是下一次中断返回的时候,一定的等一段时间,新的软中断才能被执行。

存在的问题:在比较空闲的系统中,立即处理软中断才是比较好的做法。尽管这样做能宝座用户空间不处于饥饿状态,但却让软中断忍受饥饿的痛苦,没有好好利用闲置的系统资源。

  • 方案三(折中方案):最终的内核中实现的方案,不会立即处理重新触发的软中断,而作为改进,当大量软中断出现时,内核会唤醒一组内核线程来处理这些负载。这些线程中最低的优先级上运行(nice值19),能避免它们跟其他重要的任务抢夺资源,但最终肯定会被执行。

优势:折中方案能保证在软中断负担很重时,用户程序不会因为得不到处理时间而处于饥饿状态;也能保证“过量”的软中断终究会得到处理。在空闲系统上,该方案同样表现良好,软中断处理得非常迅速。

ksoftirad线程:每个处理器都有一个名为ksoftirad/n的线程,其中,n对应处理器的编号。例如,在一个双CPU的机器上,有2个这样的线程,分别叫ksoftirad/0,ksoftirad/1。为了保证只要有空闲的处理器,它们就会处理软中断,所以给每个处理器都分配一个这样的线程。
一旦线程被初始化,就会执行类似下面死循环:

/* ksoftirq线程执行的死循环 */
for (; ;) {
    if (!softirq_pending(cpu))
        schedule();
    set_current_state(TASK_RUNNING);

    while (softirq_pending(cpu)) { /* 判断是否还有待处理软中断 */
        do_softirq();              /* 处理软中断 */
        if (need_resched())        /* 需要重新调度 */
            schedule();
    }

    set_current_state(TASK_INTERRUPTIBLE);
}

只要有待处理的软中断(由softirq_pending()负责发现),ksoftirq就会调用do_softirq去处理它们。通过重复执行这样的操作,重新触发的软中断也会被执行。如果有必要,每次迭代后,都会调用schedule()以便让更重要的进程得到处理机会。当所有需要执行的操作都完成后,该内核线程将自己设为TASK_INTERRUPTIBLE状态,唤起调度程序选择其他可执行进程投入运行。

只要do_softirq() 发现已经执行过的内核线程重新触发自己,软中断内核线程就是被唤醒。

老的BH机制

已废弃。2.6内核已不再使用BH机制执行中断下半部。所有BH静态定义,最多32个,由于处理函数必须在编译时就被定义好,所以实现模块时不能直接使用BH接口。不过,已经存在的BH倒可以利用。随着时间推移,这种静态要求和最大32个的数目限制了它们的应用。

每个BH处理程序都严格按顺序执行,不允许任何两个BH处理程序同时执行,即时类型不同。这样做同步更简单,但不利于多处理器的扩展,也不利于大型SMP的性能。使用BH的驱动程序很难从多个处理器上收益,特别网络层。

除了这些特点,BH机制和tasklet很像。2.4内核,BH基于tasklet实现;2.4内核以前,BH机制独立实现,不依赖任何低级BH机制。

[======]

工作队列

工作队列(work queue)是另一种将工作推后执行的形式,它可以把工作推后,交由一个内核线程去执行(这个下半部分总是会在进程上下文执行)。这样,通过工作队列执行的代码能占尽进程上下文的所有优势,最重要的是工作队列允许重新调度甚至睡眠。

通常,在工作队列和软中断/tasklet中作出选择很容易:如果推后执行的任务需要睡眠,那么选择工作队列;如果推后的任务不需要睡眠,那么久选择软中断或tasklet。
如果需要一个可以重新调度的实体来执行你的下半部处理,应该使用工作队列。它是唯一能在进程上下文运行的下半部实现的机制,只有它才可以睡眠。

实际上,工作队列通常可以用内核线程替换,但内核开发者非常反对创新新内核线程,所以推荐使用工作队列。

工作队列的实现

工作队列子系统是一个用于创建内核线程的接口,通过它创建的进程负责执行由内核其他部分排到队列里的任务。它创建的这些内核线程被称为工作者线程(worker thread)。系统提供一个默认的worker thread来处理需要推后的工作,而不是让你的驱动程序创建。

除非不得已,通常推荐使用默认的工作者线程。

默认工作者线程叫events/n,n代表处理器编号;每个处理器对应一个线程。默认工作者线程会从多个地方得到被推后的工作。许多内核驱动程序都把它们的下半部交给默认的工作者线程去做。

1)表示线程的数据结构

工作者线程用workqueue_struct结构表示

/* 外部可见的工作队列抽象是由每个CPU的工作队列组成的数组 */
struct workqueue_struct {
    struct cpu_workqueue_struct cpu_wq[NR_CPUS];
    const char* name;
    struct list_head list;
};

该结构由workerqueue_struct结构组成的数组,定义在kernel/workqueue.c中,数组每项对应系统中的一个处理器(因为每个处理器对应一个工作者线程)。每个工作者线程对应一个cpu_workqueue_struct结构体:

struct cpu_workqueue_struct {
    spinlock_t lock;              /* 锁定以保护该结构体 */
    
    long remove_sequence;         /* 最近一个被加上的(下一个要运行的) */
    long insert_sequence;         /* 下一个要加上的 */
    struct list_head worklist;    /* 工作列表 */
    wait_queue_head_t more_work;  
    wait_queue_head_t work_done;
    
    struct workqueue_struct* wq;  /* 有关联的workerqueue_struct结构 */
    task_t* thread;               /* 有关联的线程 */

    int run_depth;                /* run_workqueue() 循环深度 */
};

每个工作者线程类型,关联一个自己的workqueue_struct,给每个线程分配一个cpu_workqueue_struct,因此也就是给每个处理器分配一个,因为每个处理器都有一个该类型的工作者线程。

2)表示工作的数据结构

所有工作者线程都用普通的内核线程实现,都要执行worker_thread()。初始化完后,该函数执行一个死循环并开始休眠。当有操作被插入到队列时,线程就会被唤醒,以便执行这些操作。当没有剩余操作时,继续休眠。

工作用<linux/workqueue.h>中定义的work_struct结构体表示:

struct work_struct{
    unsigned long pending;      /* 这个工作正等待处理吗? */
    struct list_head entry;     /* 连接所有工作的链表 */
    void (*func)(void*);        /* 处理函数 */
    void* data;                 /* 传递给处理函数的参数 */
    void* wq_data;              /* 内部使用 */
    struct timer_list timer;    /* 延迟的工作队列所用到的定时器 */
};

这些结构体被连成链表,在每个处理器上的每种类型的队列都对应这样一个链表。比如,每个处理器上用于执行被推后的工作的那个通用线程,就有一个这样链表。当一个工作者线程背唤醒时,它会执行链表上的所有工作;工作被执行完时,就将相应的work_struct对象从链表移去。当链表上不在有对象时,它就会继续休眠。

worker_thread()核心流程,简化后:

for (; ; ) {
    set_task_state(current, TASK_INTERRUPTIBLE); /* 线程将自己设为休眠状态, 可被wake_up()唤醒 */
    add_wait_queue(&cwq->more_work, &wait);      /* 向等待队列插入工作 */

    if (list_empty(&cwq->worklist)) /* 如果工作链表为空, 线程调用schedule()进入睡眠状态 */
        schedule();
    else
        set_task_state(current, TASK_RUNNING); /* 如果链表中有对象, 线程不会睡眠. 将自己设为TASK_RUNNING状态, 脱离等待队列 */

    remove_wait_queue(&cwq->more_work, &wait);  /* 脱离等待队列 */

    if (!list_empty(&cwq->worklist))  /* 如果链表非空, 调用run_workqueue()执行被推后的工作 */
        run_workqueue(cwq);
}

3)run_workqueue()

由run_workqueue() 实际完成推后到此的工作:

while (!list_empty(&cwq->worklist)) {
    struct work_struct* work;
    void (*f)(void *);
    void *data;

    work = list_entry(cwq->worklist.next, struct work_struct, entry);
    f = work->func;
    ddata = work->data;

    list_del_init(cwq->worklist.next);

    clear_bit(0, &work->pending);
    f(data);
}

该函数循环遍历链表上每个待处理的工作,执行链表每个节点上的workqueue_struct(应该是work_struct)中的func成员函数:

  • 当链表不为空时,选取下一个节点对象。
  • 获取我们希望执行的函数func及其参数data。
  • 把该节点从链表上解下来,将待处理标志位pending清0。
  • 调用函数。
  • 重复执行。

4)这些数据结构的关系
工作work,工作队列workqueue,工作者线程之间的关系:

最高一层的是工作者线程。系统允许有多重类型的工作者线程存在。对于指定一个类型,系统的每个CPU上都有一个该类的工作者线程。内核中有些部分可根据需要来创建工作者线程。默认情况下,内核只有events这种类型工作者线程,每个工作者线程都由一个cpu_workqueue_struct结构体表示。

workqueue_struct结构体表示给定类型的所有工作者线程。

工作处于最底一层。你的驱动程序创建这些需要推后执行的工作(用“工作”这种接口封装我们实际需要推后的工作)。以便后续的工作者线程处理。它们用work_struct结构来表示。工作用work_struct结构表示。结构中最重要的部分是一个指针,指向一个函数,负责需要推后执行的具体任务。工作会被提交给某个具体的工作者线程 ---- 此时,就是特殊是falcon线程。然后,这个工作者线程会被唤醒并执行这些安排好的工作。

使用工作队列

1)创建推后的工作

首先要做的是实际创建一些需要推后完成的工作。可以同DECLARE_WORK在编译时静态地创建该结构体:

/* 静态创建工作 */
DECLARE_WORK(name, void (*func)(void*), void* data);

这样静态地创建一个名为name,处理函数为func,参数为data的work_struct的结构体。

同样,也可以运行时通过指针创建工作:

/* 动态创建工作 */
INIT_WORK(struct work_struct* work, void(*func)(void*), void* data);

这样动态地初始化一个由work指向的工作,处理函数为func,参数data。

2)工作队列处理函数

函数原型:

void work_handler(void* data);

该函数会由一个工作者线程执行,因此,函数会运行在进程上下文中。默认情况下,允许响应中断,并且不持有任何锁。如果需要,函数可以睡眠。需要注意到是,尽管操作处理函数运行在进程上下文中,但它不能访问用户空间,因为内核线程中用户空间没有相关内存映射。通常在系统调用发生时,内核会代表用户空间的进程运行,此时它才能访问用户空间,也只有在此时它才会映射用户空间的内存。

在工作队列和内核其他部分之间使用锁机制,就像在其他进程的上下文中使用锁机制一样方便。这使编写处理函数变得相对容易。

3)对工作进行调度

现在工作已被创建,可以调度之。想要把给定工作的处理函数提交给默认的events工作线程,只需要调用

/* 将指定工作交给默认events工作线程执行 */
schudule_work(&work);

work马上会被调度,一旦其所在的处理器上的工作者线程背唤醒,它就会被执行。有时,你并不希望工作马上就被执行,而希望它经过一段延迟后再执行。此时,你可以调度它在指定的时间执行:

/* 延迟一段时间再交由events工作线程执行指定工作 */
schedule_delayed_workd(&work, delay);

这样,&work指向的work_struct直到delay指定的时钟节拍用完以后才会执行。

4)刷新操作

排入队列的工作会在工作者线程下一次被唤醒时执行。有时,在继续下一步工作前,你必须保证一些操作已经执行完毕。这点对模块来说很重要。而内核的其他部分,为了防止竞争调节的出现,也可能需要确保不再有待处理的工作。

内核准备了一个用于刷新指定工作队列的函数:

/* (同步)刷新指定工作队列的函数 */
void flush_scheduled_work(void);

函数一直等待,直到队列中所有对象都被执行以后才返回。等待时,该函数会进入休眠,因此只能在进程上下文使用。
注意:该函数并不取消任何延迟执行的工作。任何通过schedule_delayed_work()调度的工作,如果其延迟时间未结束,它并不会因为调用flush_scheduled_work()而被刷新掉。

取消延迟执行的工作,应该调用:

/* 取消延迟执行的工作 */
int cancel_delayed_work(struct work_struct* work);

该函数取消任何与work_struct相关的挂起工作。

大部分驱动程序使用默认工作者线程events/n,简单、方便。有些要求更严格的情况下,驱动程序需要自己的工作者线程。

5)创建新的工作队列(不推荐,除非很有必要)

如果默认的队列不能满足你的需要,你应该创建一个新的工作队列和与之相应的工作者线程。不过,这么做会在每个处理器上都创建一个工作者线程,所以只有你明确了必须要靠自己的一套线程提高性能的情况下,再创建自己的工作队列。

创建一个新的任务队列和与之相关的工作者线程,只需要调用一个简单的函数:

/* 创建一个新任务队列和工作者线程 */
struct workqueue_struct* create_workqueue(const char* name);

name:用于该内核线程都命名。例如,默认events队列的创建就调用的是:

/* 创建默认events队列 */
struct workqueue_struct *keventd_wq;
keventd_wq = create_workqueue("events");

函数会创建所有的工作者线程(每个处理器一个),并且做好所有开始处理工作前的准备工作。

创建一个工作的时候,无需考虑工作队列的类型。创建后,可以调用下面列举的函数,以执行工作。这些函数与schedule_work()及schedule_delayed_work()相近,唯一区别是它们针对给定的工作队列而不是默认的event队列进行操作。

/* 将工作work交给指定工作队列wq执行 */
int queue_work(struct workqueue_struct *wq, struct work_struct *work);

/* 将工作work交给指定工作队列wq 延迟delay时间执行 */
int queue_delayed_work(struct workqueue_struct *wq, struct work_struct *work, unsigned long delay);

/* 刷新指定的工作队列wq */
flush_workqueue(struct workqueeu_struct *wq);

老的任务队列机制

已废弃,由于任务队列接口存在种种缺陷,被工作队列接口取代。

任务队列机制通过定义一组队列来实现其功能,每个队列都有自己的名字,如调度程序队列、立即队列、定时器队列。不同队列在内核中的不同场合使用,如:调度程序队列的相关任务由keventd内核线程负责执行;定时器队列中系统定时器的每个时间节拍时执行;立即队列能得到双倍的运行机会,以保证它能“立即”执行。
另外,也可以动态创建自己的新队列。

任务队列优势:接口特别简单。
劣势:创建随意,散落在内核各处。

目前,大部分任务队列的使用都可以选择tasklet替代,keventd代码演化成了今天的工作队列。

[======]

下半部机制的选择

2.6内核中,有三种选择:软中断,tasklet,工作队列。tasklet基于软中断,两者很相近;工作队列基于内核线程。

软中断与tasklet

从设计角度,软中断提供的执行序列化的保障最少,这要求软中断处理函数必须格外小心地采取一些步骤确保共享数据的安全,两个或更多相同类比的软中断有可能在不同的处理器上同时执行。

  • 选择软中断:如果被考察的代码本身多线索化的工作就做的很好,如网络子系统,它完全使用单处理器变量,那么软中断就是很好的选择。对于时间要求严格和执行频率很高的应用来说,它执行最快。
  • 选择tasklet:如果代码多线索化考虑得不充分,那么更适合用tasklet。其接口简单,而且两个同种类型tasklet不能同时执行,所以实现起来更简单。tasklet是有效的软中断,但不能并发运行。
  • 选择软中断:如果准备利用每个处理器上的变量或类似的情形,以确保软中断能安全地在多个处理器上并发运行,那么选择软中断。
  • 选择tasklet:其他情形,通常优先选择tasklet。

工作队列与软中断/tasklet

  • 选择工作队列:选择如果需要把任务推后到进程上下文中完成,那么这三者中只能选择工作队列。
  • 选择软中断/tasklet:如果进程上下文并非必须条件。那么选择软中断和tasklet更合适。

工作队列造车的开销最大,因为它要牵扯到内核线程,甚至上下文切换。但工作队列效率并不低,如果每秒有几千次中断,那么采用其他机制可能更合适。大部分情况,工作队列都能提供足够的支持。

易用性:工作队列 > tasklet > 软中断。

[======]

在下半部之间加锁

  • tasklet之间同步,只需要考虑不同类型的tasklet共享同一数据时,通过锁机制确保同步。而同种类型不允许同时执行。
  • 软中断共享数据,都需要合适的锁确保同步。
  • 一个可以打断另一个,但反过来不行的情形:
    1)如果进程上下文和一个下半部共享数据,需要禁止下半部的处理并得到锁的使用权。
    2)如果中断上下文和一个下半部共享数据,需要禁止中断并得到锁的使用权。

禁止下半部

为保证共享数据的安全,常见做法是:1)先得到一个锁;2)再禁止下半部的处理。 驱动程序也经常用这种方法,但内核核心代码,可能仅需禁止下半部即可。

如果需要禁止/激活所有下半部(确切地,软中断和tasklet),可以使用下面方法:

/* 注意: 
1) bh接口已让位软中断, 但接口名未改;
2) 必须配对使用, disable/enable次数相等时, 软中断才能被重新激活 */

void local_bh_disable();  /* 禁止本地处理器的软中断和tasklet的处理 */

void local_bh_enable();   /* 激活本地处理器的软中断和tasklet的处理 */

如何实现这2函数的配对使用的呢?
函数通过preempt_count为每个进程维护一个计数器。当计数器值0时,下半部才能被处理。因为下半部已经被禁止,所以local_hb_enable()还需要检查所有现存的下半部并执行它们。

#include <asm/softirq.h>

/*
 * 通过增加preempt_count禁止本地下半部
 */
void local_bh_diable(void)
{
    struct thread_info* t = current_thread_info();

    t->preempt_count += SOFTIRQ_OFFSET;
}

/*
 * 减少preempt_count - 如果该返回值0
 * 将导致自动激活下半部
 * 执行挂起的下半部
 */
void local_bh_enable(void)
{
    struct thread_info* t = current_thread_info();

    t->preempt_count -= SOFTIRQ_OFFSET;

    /*
     * preempt_count 是否为0, 另外是否有挂起的下半部, 如果都满足, 则执行待
     * 执行的下半部
     */
     if (unlikely(!t->preempt_count && softirq_pending(smp_processor_id())))
          do_softirq();
}

这些函数并不能禁止工作队列的执行,因为工作队列是在进程上下文中运行的,不涉及异步执行,没有禁止的必要。而软中断和tasklet是异步执行的(即中断返回时),内核代码必须禁止。

[======]

下半部处理小结

1)Linux中断分为上半部、下半部如何划分由用户决定。原则是:上半部,执行对时间非常敏感的任务、和硬件相关的任务、不能被中断的任务;其他任务,都可放在下半部。
2)中断下半部的三种实现机制:软中断、tasklet、工作队列。考察其设计与实现,讨论如何使用。同时也介绍了已废弃的BH和任务队列。

  • 软中断:静态编译时分配,保留给系统中对时间要求最严格,及最重要的下半部使用,目前只有网络和SCSI 2个子系统使用了软中断。可在多个处理器同时执行。软中断共享数据,需要合适的锁确保同步。

  • tasklet:基于软中断机制实现,接口更简单,动态注册、删除。不能休眠,不能使用信号量;如果使用了共享数据,必须加锁保护或屏蔽中断。同种类型tasklet不允许同时执行。利用ksoftirad线程组,解决大量软中断同时触发问题。推荐使用的下半部机制。

  • 工作队列:通过将工作交给内核线程执行,来推后执行任务。工作队列总是在进程上下文执行,允许重新调度和睡眠。内核提供默认的worker thread(名为events/n,n是处理器编号)处理需要推后的工作,通常不需要创建新的worker thread。推荐使用的下半部机制。

3)中断和进程共享数据

  • 如果进程上下文和下半部共享数据,需要禁止下半部并得到锁使用权;
  • 如果中断上下文和下半部共享数据,需要禁止中断并得到锁的使用权。
posted @ 2022-06-04 20:59  明明1109  阅读(507)  评论(0编辑  收藏  举报