Linux内核设计与实现(四)| 中断 & 中断处理(上半部与下半部)

中断和中断处理

  • 概述

这章中我们主要讨论CPU如何对连接上计算机上的硬件进行有效管理的,如果弥补两者之间的差距,两者之间的通信是同步还是异步(CPU一直轮询问是否准备好,还是设备准备好自己叫CPU?)等一系列问题

1.中断

  • 概述

中断使得硬件得以发出通知给CPU,例如你给键盘敲击,键盘控制器就会发送一个中断,通知系统有有按键按下,本质是一种电信号,硬件发生中断具有突发性,因此内核可能因为新来到的中断而被打断

中断信号由中断控制器生成,通过复用只通过一个和处理器相连接的管线(中断线)进行通信,中断来时,CPU处理中断后会通知操作系统已经完成,然后交给OS进行处理

不同的设备中断不同,每个中断都有一个唯一的数字标志,以此来让OS区分,我们称这个标志为中断请求线(IRQ),每个IRQ都会关联一个数值量(不是所有的中断号都有一一对应的设备,有时是动态分配的)

异常

中断和异常不同,异常在产生的时候必须考虑时钟同步,即可以称异常为同步中断我们前面就提到过一个异常,在X86的体系上通过软中断陷入内核进行系统调用处理异常程序,异常和中断的处理方式大致相同

2.中断处理程序

  • 概述

在响应一个特点的中断时,内核会执行一个函数,该函数就叫做中断处理程序,产生中断的每个设备都有一个与之对应的中断处理程序,中断处理程序是设备驱动程序(对设备进行管理的内核代码)的一部分

在Linux中的中断处理程序就是一个个C函数,与其他函数不同的就是声明函数时需要特定的类型,对于内核函数最实质的区别就是中断处理程序被内核调用来响应中断,运行在中断上下文,同时中断上下文是具有原子性的,由于设备的中断具有突发性,我们需要让中断处理程序响应时间尽可能短,处理的尽可能块

3.上半部和下半部的对比

  • 概述

上面说到让中断处理程序运行的快,完成的任务有多显然是矛盾的,所以我们一般将两者分为上半部和下半部

  • 上半部:接收到一个中断,他就会立刻开始执行,但只做严格有时限的任务
  • 下半部:能够被允许稍后完成的工作会推迟到下半部,在合适的实际会被中断执行

网卡例子

当网卡接收来自网络的数据包时,需要通知内核数据包到了。网卡需要立即完成这件事,从而优化网络的吞吐量和传输周期,以避免超时。因此,网卡立即发出中断:嗨,内核,我这里有最新数据包了。内核通过执行网卡已注册的中断处理程序来做出应答。

中断开始执行,通知硬件,拷贝最新的网络数据包到内存,然后读取网卡更多的数据包。这些都是重要、紧迫而又与硬件相关的工作。内核通常需要快速的拷贝网络数据包到系统内存,因为网卡上接收网络数据包的缓存大小固定,而且相比系统内存也要小得多。所以上述拷贝动作一旦被延迟,必然造成缓存溢出—-进入的网络包占满了网卡的缓存,后续的入包只能被丢弃。当网络数据包被拷贝到系统内存后,中断的任务算是完成了,这时它将控制权交还给系统被中断前原先运行的程序。处理和操作数据包的其他工作在随后的下半部中进行。

4.注册中断处理程序

在这里插入图片描述

  • 概述

中断处理程序是管理硬件驱动程序的组成部分,每个设备都有一个硬件驱动程序,如果设备使用中断那么相应的驱动程序就要注册一个中断处理程序,对应函数为request_irq(),成功返回0,常见的错误就是中断线正在使用

/*request_irq:分配一条给定的中断线*/
int request_irq (unsigned int irq,	//中断号,有的动态分配,有的提前设置好了(例如时钟或键盘)
				irq_handler_t handler,	//指向实际的中断处理程序
				unsigned long flags,	//中断处理程序标志
				const char *name,	//中断相关设备的ASCII文本
				void *dev)		//用于共享中断线,提供中断线中的任意中断处理程序,可用于删除,且唯一

注意

request_irq()函数可能会睡眠,因此,不能在中断上下文或其他不允许阻塞的代码中调用该函数。天真地在睡眠不安全的上下文中调用request_irq()函数,是一种常见错误。造成

重入和中断程序

Linux中的中断处理程序是无须重入的。当一个给定的中断处理程序正在执行时,相应的中断线在所有处理器上都会被屏蔽掉,以防止在同一中断线上接收另一个新的中断。通常情况下,所有其他的中断都是打开的,所以这些不同中断线上的其他中断都能被处理,但当前中断线总是被禁止的。由此可以看出,同一个中断处理程序绝对不会被同时调用以处理嵌套的中断。

4.1 中断处理程序标志(中断等级)

  • 概述

flags参数可以是0,也可以是一个或多个标志的掩码

分类
IRQF_DISABLED内核在处理中断处理程序本身不可被中断
IRQF_SAMPLE_RANDOM:—此标志表明这个设备产生的中断对内核饪池(entropy pool)有贡献。内核嫡池负责提供从各种随机事件导出的真正的随机数。如果指定了该标志,你可理解为中断处理的时间不确定

IRQF_TIMER:该标志是特别为系统定时器的中断处理而准备的。
IRQF_SHARED:此标志表明可以在多个中断处理程序之间共享中断线

4.2 释放中断处理程序

  • 概述

卸载驱动程序时,需要注销相应的中断处理程序,并释放禁用中断线(不共享的情况下)。共享则会删除对应的dev的处理程序

void free_irq (unsigned int irq, void *dev)

5.中断上下文

  • 进程上下文和中断上下文的区别

当执行一个中断处理程序时,内核处于中断上下文,注意这个跟进程上下文是两个概念,进程上下文是一种内核所处的操作模式,此时内核代表进程执行,执行系统调用或运行内核线程。在进程上下文中,可以通过current宏关联当前进程。此外,因为进程是以进程上下文的形式连接到内核中的,因此,进程上下文可以睡眠,也可以调用调度程序。

中断上下文和进程没有任何瓜葛,current会指向被中断的进程,因为没有后备进程(因为系统进入中断之前会关闭系统调用)所以中断上下文不可以睡眠,否则怎么在对他重新调度?不能休眠,这使得中断处理程序所能进行的操作较之运行在进程上下文中的系统调用所能进行的操作受到了极大的限制。

不能从中断上下文中调用某些函数。如果一个函数睡眠,就不能在你的中断处理程序中使用它——这是对什么样的函数可以在中断处理程序中使用的限制。

中断处理的要求

中断上下文具有严格的时间限制,因为它可以打断其他代码,中断上下文的代码应当迅速、简介,尽量不要使用循环去处理任务,中断处理程序打断其他代码(甚至可以打断其他中断线的另一个中断程序),所以应该把耗时的任务放在下半部去执行,挑选合适的时间

中断处理程序栈

中断处理程序没有自己的栈,都是共享其内核栈,内核栈的大小是两页,64位上是16KB,空间很小使用需要十分谨慎,自2.6后中断程序有了自己的栈,每个处理器是一页

6.中断处理机制的实现

  • 概述

中断处理系统是必须配合软硬件上下游一起实现的,下图描述了中断从硬件到内核的路由

  • 中断由硬件产生电信号发送给中断控制器
  • 查看中断线是否为激活,激活则发送至处理器
  • 处理器立刻停止正在做的事情,关闭中断系统
  • 然后处理器在内核开始执行预先设定代码(中断处理程序的入口)在这里插入图片描述

内核的视角

  • 在内核中,中断的开始在于中断处理程序的入口处,类似于系统调用通过软中断设置的异常进入内核,对于每条中断线都会跳至一个唯一的位置,这个位置也就是对应中断的IRQ(中断线值)号了
  • 然后计算出中断号,对接收的应答进行响应,并且禁止这条线上的中断传递,然后需要确保这条线上有一个有效的处理程序并进行绑定

下面的几步其实就是引起中断的时候内核在干嘛,恢复到那个时候就行

  • 中断处理完后会回到起点会检查是否设置了need_resched,前面说过这个函数代表进行线程调度(挂起),如果设置了那么重新调度正在挂起,内核正在返回用户空间(中断了用户进程),schedule被调用,选择下一个可运行的进程
  • 如果内核正在返回内核空间(中断了内核本身),只有返回(中断)成功才会调用schedule, 否则没有中断成功抢占内核是不安全的
  • schedule返回后如果没有挂起的工作,那么原来的寄存器被恢复,内核恢复到曾经中断的点

7.中断控制

  • 概述

Linux内核提供了一组接口用于操作机器上的中断状态。这些接口为我们提供了能够禁止当前处理器的中断系统,或屏蔽掉整个机器的一条中断线的能力

中断、锁和同步的配合

一般来说,控制中断系统的原因归根结底是需要提供同步。通过禁止中断,可以确保某个中断处理程序不会抢占当前的代码。此外,禁止中断还可以禁止内核抢占。然而,不管是禁止中断还是禁止内核抢占,都没有提供任何保护机制来防止来自其他处理器的并发访问。

Linux 支持多处理器,因此,内核代码一般都需要获取某种锁,防止来自其他处理器对共享数据的并发访问。获取这些锁的同时也伴随着禁止本地中断。锁提供保护机制,防止来自其他处理器的并发访问,而禁止中断提供保护机制,则是防止来自其他中断处理程序的并发访问。

禁止和激活中断、禁止指定中断线和中断系统的状态相关函数

注意这些函数都是对于当前处理器的,以前会有命令(cli函数)对全部的处理器进行中断的禁止和激活,这个安全性很高但是并发度就上不去了,所以取消该函数后就将禁止和激活的粒度划分为更小的函数,虽然复杂但是并发度上去了
在这里插入图片描述

下半部和推后执行的工作

  • 概述

上一章我们讨论了内核中断机制的实现,是内核必不可少的一部分。但是由于其本身的一些局限:

  • 中断处理时异步的,有可能中断其他重要代码(或者中断别的中断程序),因此中断程序程序应该执行的越来越好
  • 如果当前的有一个中断正在执行,好的情况与该中断同级(同一中断线上)的其他中断会被屏蔽,坏的情况当前处理器上所有其他中断会被屏蔽,因为禁止中断后硬件与操作系统无法通信,因此,中断处理程序执行得越快越好
  • 中断往往需要对硬件进行操作,那么时限要求就更高了
  • 中断处理程序不在进程上下文运行,所以不能进行阻塞

关于局限性

上面局限性是关于中断处理程序的局限,可见如果只把中断处理程序作为整个中断处理的构成是不妥当的,虽然对于异步、快速等需求中断处理程序完成的很出色,但是向一些对时间相对宽松的任务就应该推迟到中断被激活再去运行,那么就引出这些任务的处理流程:下半部

1.下半部

  • 概述

按照我们追求极限响应的方向,我们应该把全部工作都交给下半部来,少量给中断处理程序,让他尽可能快的返回,但是不能什么都不给中断处理程序做,有一些是必须做的(确认中断的到达、拷贝硬件的数据等)对时间很敏感的

对于两个部分的任务分工没有明确的指示,全靠驱动程序开发者的判断,但宽泛上会这样规定:

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

在下面我们会主要介绍三种延迟Linux内核工作的三种机制:软中断、tasklet和工作队列

1.1 为什么要用下半部

起点

我们希望上半部尽可能的快,让其处理的任务越少越好

执行时机

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

1.2 下半部的环境

  • 概述

和上半部不同,下半部的实现机制多种多样,由不同的接口和子系统组成,

下半部实现的进化历程

  1. 起源

关于起源就是起名为"bottom half",简称BH,他提供了静态创建的32个bottom halves组成的链表,上半部通过32位的整数来识别要执行的节点,32个节点全局共享,无论是不同处理器,所以不存在两个节点同时执行的情况,这种机制不够灵活

  1. 任务队列

内核定义一组队列,替代BH,每个队列都包含一个由等待调用的函数组成链表,根据在链表的位置就知道什么时候执行,有改进但依旧不够灵活

  1. 软中断和tasklet

软中断和tasklet可以完全替代软中断

软中断

软中断是一组静态定义的下半接口,有32个,可以在所有处理器上同时执行(两个类型相同的也可以),对于网络性能要求高的会使用到软中断,使用软中断需要特别小心,因为两个相同的软中断有可能同时被执行。此外,软中断还必须在编译期间就进行静态注册。与此相反,tasklet可以通过代码进行动态注册。

tasklet

tasklet无法顾名思义,这个下半部实现机制是基于软中断的,但是灵活性更强,两个不同的tasklet可以不同的处理器同时执行,但类型相同的不可以,tasklat是一个性能和易用性的平衡点,对于通常的下半部处理都可以应对,与上面相反,tasklet可以通过代码进行动态注册。你可以把tasklet当成青春版的软中断

  1. 内核定时器

该机制就是你推迟的任务需要指定某个确定的时间去执行,即你需要保证一个时间段后在执行时你可以使用内核定时器

  1. 工作队列

任务队列接口也被工作队列接口取代了。工作队列是一种简单但很有用的方法,它们先对要推后执行的工作排队,稍后在进程上下文中执行它们。

总结
在这里插入图片描述

2.软中断

2.1 软中断的实现

  • 概述

软中断是在编译期间静态分配的。它不像tasklet那样能被动态地注册或注销。每个被注册的软中断都占据该数组的一项,因此最多可能有32个软中断。注意,这是一个定值,注册的软中断数目的最大值没法动态改变。在当前版本的内核中,这32个项中只用到9个曰。

struct softirq_action {
	void (*action ) (struct softirq_action *);
);
//kernel/softirq.c中定义了一个包含有32个该结构体的数组。
static struct softirq_action softirq_vec[NR_SOFTIRQS];

软中断处理程序

软中断的执行是触发一个名为action函数,参数是传递相应softirq_action结构体的指针。传递整个结构体在后续变动时就不会影响到别的软中断处理程序,一个软中断不会抢占另外一个软中断。实际上,唯一可以抢占软中断的是中断处理程序。不过,其他的软中断(甚至是相同类型的软中断)可以在其他处理器上同时执行。

执行软中断

一个软中断必须在被标记后才会执行,标记称为触发软中断,通常在中断处理程序会在返回前标记它的软中断,使其稍后被执行,这个合适的时机会在下面几个地方去检查待处理的软中断:

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

不管什么办法唤醒的,都会调用do_softirq执行,主要就是循环遍历32位位图,待处理的软中断在对应类型的软中断标记为1

u32 pending;
..
pending = local_softirq_pending(;
if (pending) {
	struct softirq_action *h;
	
	/*重设待处理的位图*/
	set_softirq pending( 0);
	
	h = softirq_vec;
	do {
		if (pending & 1)
			h->action(h);
		h++;
		pending >>= 1;
	} while (pending);
}

2.2 使用软中断

  • 概述

软中断保留给系统中对时间要求最严格以及最重要的下半部使用。目前,只有两个子系统(网络和SCSI)直接使用软中断。此外,内核定时器和tasklet都是建立在软中断上的。

分配索引

在编译期间,通过在<linux/interrupt.h>中定义的一个枚举类型来静态地声明软中断。内核用这些从0开始的索引来表示一种相对优先级。索引号小的软中断在索引号大的软中断之前执行。下面可以看看tasklet的类型列表
在这里插入图片描述

注册你的处理程序

通过调用open _softirq()方法,传递优先级索引和处理函数两个参数

软中断中的加锁细节

软中断处理程序执行的时候,允许响应中断,但它自己不能休眠。在一个处理程序运行的时候,当前处理器上的软中断被禁止。但其他的处理器仍可以执行别的软中断。实际上,如果同一个软中断在它被执行的同时再次被触发了,那么另外一个处理器可以同时运行其处理程序。这意味着任何共享数据(甚至是仅在软中断处理程序内部使用的全局变量)都需要严格的锁保护。这点很重要,它也是tasklet更受青睐的原因。

单纯地禁止你的软中断处理程序同时执行不是很理想。如果仅仅通过互斥的加锁方式来防止它自身的并发执行,那么使用软中断就没有任何意义了(因为不同处理器可以运行相同的软中断,互斥就不行了)。因此,大部分软中断处理程序,都通过采取单处理器数据(仅属于某一个处理器的数据,因此根本不需要加锁)或其他一些技巧来避免显式地加锁,从而提供更出色的性能。

触发你的软中断

通过在枚举类型的列表中添加新项以及调用open_softirq()进行注册以后,新的软中断处理程序就能够运行。raise_softirq()函数可以将一个软中断设置为挂起状态,让它在下次调用do_softirq()函数时投入运行。

在中断处理程序中触发软中断是最常见的形式。在这种情况下,中断处理程序执行硬件设备的相关操作,然后触发相应的软中断(标记),最后退出。内核在执行完中断处理程序以后(检查标记),马上就会调用do_softirq()函数。于是软中断开始执行中断处理程序留给它去完成的剩余任务。

3.tasklet

  • 概述

前面我们说到tasklet利用软中断实现的一种下半部机制,与进程无关,与软中断相比,接口更加简单,锁保护也要求较低;我们一般都会选择使用tasklet

3.1 tasklet的实现

  • 概述

上面的tasklet的类型表,可以看到主要有两类软中断代表:HI_SOFTIRQ和TASKLET_SOFTIRQ。这两者之间唯一的实际区别在于,HI_SOFTIRQ类型的软中断先于TASKLET_SOFTIRQ类型的软中断执行。

tasklet结构体

我们重点说一下两个属性:

  • state:只能在0、TASKLET_STATE_SCHED和TASKLET_STATE_RUN之间取值。TASKLET_STATE_SCHED 表明tasklet已被调度,正准备投入运行,TASKLET_STATE_RUN表明该tasklet正在运行。TASKLET_STATE_RUN 只有在多处理器的系统上才会作为–种优化来使用,单处理器系统任何时候都清楚单个tasklet是不是正在运行(它要么就是当前正在执行的代码,要么不是)。
  • count:是tasklet的引用计数器。如果它不为0,则tasklet被禁止(前面说到过tasklet不允许两个处理器执行相同的tasklet),不允许执行﹔只有当它为0时,tasklet才被激活,并且在被设置为挂起状态时,该tasklet才能够执行。
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

前面我们说到tasklet会大致分为两种类型的,即普通优先级和高优先级的tasklet,实际存储是由两个链表分别存储的(每个处理器都有这两个链表),结构都是tasklet的结构体;分别对应两个函数进行调度tasklet_schedule()tasklet_hi_schedule()

调度函数所做的事情

主要就是检查状态,保存中断状态,禁止本地中断,根据类型优先级加入到不同的链表,唤起该对应优先级的软中断下一次调用执行软中断就会执行该tasklet

执行tasklet的核心函数tasklet_action()和 tasklet_hi_action()

会禁止中断,检索两条链表,将当前处理器的该链表设置为NULL,清空。允许响应中断,循环遍历获得链表的每一个待处理tasklet,如果多处理就会检查是否别的处理器在运行,别的在运行现在就不要执行了跳至下一个待处理tasklet,如果没有别的处理器在运行,那么设置状态为运行态,检查count是否为0,如果为0那么此时tasklet可以执行对应的处理程序了;运行完毕清理状态,直到执行没有剩余等待tasklet

总结

tasklet的实现很简单,但非常巧妙。我们可以看到,所有的tasklet都通过重复运用H_SOFTIRQ和TASKLET_SOFTIRQ这两个软中断实现(普通优先级和高优先级)。当一个tasklet被调度时,内核就会唤起这两个软中断中的一个。随后,该软中断会被特定的函数处理,执行所有已调度的tasklet。这个函数保证同一时间里只有一个给定类别的tasklet会被执行(但其他不同类型的tasklet可以同时执行)。所有这些复杂性都被一个简洁的接口隐藏起来了。

3.2 使用tasklet

  • 概述

一般常用的设备都是用tasklet来配合上半部执行的,经过上面我们的介绍你大概需要以下几个步骤就能使用tasklet了

  1. 声明自己的tasklet
  2. 编写自己的tasklet处理程序
  3. 调度自己的tasklet
  4. ksoftirqd
ksoftirqd
  • 概述

前面我们说到过内核会选择三个合适的时间点去对软中断处理,一般常见的就是从中断处理程序返回时处理,处理这个方式还有未提及的ksoftirqd,每个处理器都有一个辅助处理软中断和tasklet的内核线程

如果还采用从中断处理程序返回时处理,在大流量的网路通信期间软中断被触发的频率会变得很高,并且软中断自己的处理程序还会自行重复触发(网络子系统一般都会这么做来提高效率),如果软中断本身出现的频率很高,处理程序又会重复执行,那么会导致用户空间进程饥饿

两种不足的解决方案

  1. 及时处理软中断

对重新触发的软中断也会立即处理。当负载很高的时候这样做就会出问题,此时会有大量被触发的软中断,而它们本身又会重复触发。系统可能会一直处理软中断,根本不能完成其他任务。用户空间的任务被忽略了——实际上,只有软中断和中断处理程序轮流执行,而系统的用户只能等待。只有在系统永远处于低负载的情况下,这种方案才会有理想的运行效果;只要系统有哪怕是中等程度的负载量,这种方案就无法让人满意。用户空间根本不能容忍有明显的停顿出现。

  1. 不处理重新触发的软中断

从中断返回,内核会照常检查挂起的软中断,但如果是重新触发的并不会马上处理,会被延后至下一个软中断即下一次中断返回的时候。可是,在比较空闲的系统中,立即处理软中断才是比较好的做法。很不幸,这个方案显然又是一个时好时坏的选择。尽管它能保证用户空间不处于饥饿状态,但它却让软中断忍受饥饿的痛苦,而根本没有好好利用闲置的系统资源。

ksoftirqd

这个方案是对于上面第二种的改进,当大量软中断出现的时候,内核会唤醒一组内核线程(即ksoftirqd)来处理这些负载。这些线程在最低的优先级上运行(nice值是19),这能避免它们跟其他重要的任务抢夺资源。但它们最终肯定会被执行,所以,这个折中方案能够保证在软中断负担很重的时候,用户程序不会因为得不到处理时间而处于饥饿状态。相应的,也能保证“过量”的软中断终究会得到处理。最后,在空闲系统上,这个方案同样表现良好,软中断处理得非常迅速(因为仅存的内核线程肯定会马卜调度)

每个处理器都有一个这样的线程,名字就为ksoftirqd/n,区别在于n,这个n代表不同的处理器编号,从0开始,为了保证只要有空闲的处理器,它们就会处理软中断,所以给每个处理器都分配一个这样的线程。一日该线程被初始化,它就会执行类似下面这样的死循环

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);
}

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

4.工作队列

  • 概述

工作队列的大致思想也是将任务推后执行的形式,工作队列可以把工作推后,交由一个内核线程去执行,这个下半部分总是会在进程上下文中执行(唯一下半部在进程上下文实现的机制)。这样,通过工作队列执行的代码能占尽进程上下文的所有优势。最重要的就是工作队列允许重新调度甚至是睡眠。

  • 如果在软中断/tasklet和工作队列做出选择
  • 通常,在工作队列和软中断/tasklet中做出选择非常容易。如果推后执行的任务需要睡眠或需要用一个可以重新调度的实体来执行你的下半部处理,那么就选择工作队列。如果你需要大量内存时、需要获取信号量时、需要阻塞式IO操作时,工作队列提供的睡眠机制都可以满足
  • 如果推后执行的任务不需要睡眠,那么就选择软中断或tasklet。
  • 不用创建内核线程去代替工作队列的原因

实际上,工作队列通常可以用内核线程替换。但是由于内核开发者们非常反对创建新的内核线程(在有些场合,使用这种冒失的方法可能会吃到苦头),所以我们也推荐使用工作队列。当然,这种接口也的确很容易使用。

4.1 工作队列的实现

  • 概述

工作队列子系统通过创建内核线程(称为工作线程)去执行队列中的任务,即工作队列可抽象成一个把需要推后执行的任务交给特定的通用线程的这样一种接口。

  • 缺省工作线程

工作队列子系统提供一个默认处理的线程,每个缺省工作线程叫做events/nn为处理器编号,每个处理器对应一个线程,工作线程会从很多地方得到被延后的任务,这个缺省工作线程是通用的,可以处理不同的驱动程序或子系统传来的延后任务

如果你自己创建工作线程,那么也能有效的减轻缺省工作线程的负担,避免工作队列中其他需要帮助的任务一直处于饥饿状态

表示线程的数据结构

工作线程用workqueue_struct表示,该结构是由不同处理器的cpu_workqueue_struct数组所构成的,数组每一项就代表一个处理器,每个处理器就代表一个工作线程;所以对于给定的某台计算机来说,就是每个处理器,每个工作者线程对应一个这样的cpu_workqueue_struct结构体。

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

struct cpu_workqueue_struct {
	spinlock_t lock ;/*锁保护这种结构*/
	
	struct list_head worklist;/*工作列表*/
	
	wait__queue_head_t more_work;
	struct work_struct*current_struct;
	
	struct workqueue_struct *wq ;/*关联工作队列结构*/
	task_t *thread;/*关联线程*/
};

表示工作的数据结构

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

struct work_struct {
	atomic_long_t data;
	struct list_head entry;
	work_func_t func;
};

这个结构其实就是链表的节点结构,每个处理器的工作线程都会都有一个这样的链表,当链表有任务,工作线程被唤醒,就会指向任务,指向完毕会将节点移除,不在有节点时工作线程继续休眠

worker_thread()的核心流程

  1. 工作线程将自己设置为休眠状态(TASK_INTERRUPTIBLE),并把自己加入到任务队列中
  2. 如果链表为空,线程调用schedule函数进入睡眠状态
  3. 如果不为空,则会把自己设置成TASK_RUNNING状态,脱离工作队列
  4. 然后调用run_workqueue函数执行被推后的工作。
for (;;) {
	prepare_to_wait ( &cwq->more_work,&wait,TASK_INTERRUPTIBLE);
	if ( list_empty ( &cwq->worklist))
		schedule();
	finish_wait ( &cwq->more_work,&wait);
	run_workqueue( cwq) ;
}

run_workqueue()所做的工作

该函数会循环遍历链表上的每个待处理工作,执行每个节点的func函数

  1. 当链表不为空时,选取下一个节点对象。
  2. 获取我们希望执行的函数func及其参数data。
  3. 把该节点从链表上解下来,将待处理标志位pending 清零。
  4. 调用函数。
  5. 重复执行。
while ( list_empty ( &cwq->worklist)) {
	struct work_struct*work;
	work_func_t f;
	void *data;
	work = list_entry(cwq->worklist.next,struct work_struct,entry);
	f = work->func;
	list_del_init(cwq->worklist.next);
	work__clear _pending (work) ;
	f (work);
}

工作队列实现机制的总结

由下图可得知,OS允许多种类型的工作线程存在,对于某一类型,每个CPU都有一个该类型的工作线程,内核可按需建立不同类型的工作线程,默认内核只有event这一种类型的工作线程(缺省工作线程),每个工作线程都由一个cpu_workequeue_struct结构体表示。而workqueue_struct结构体则表示给定类型的所有工作者线程。

在这里插入图片描述
举个例子

例如,除系统默认的通用events工作者类型之外,我自己还加入了一种falcon工作者类型。并且使用的是一个拥有四个处理器的计算机。那么,系统中现在有四个event类型的线程(因而也就有四个cpu_workqueue_struct结构体)和四个falcon类型的线程(因而会有另外四个cpu_workqueue_struct结构体)。同时,有一个对应event类型的workqueue_struct 和一个对应falcon类型的workqueue_struct。

当任务来临时,会通过一个指针来分辨交给那个具体的工作者线程

4.2 使用工作队列

  • 概述

先来看看缺省events的任务队列

创建推迟后的任务

主要就是分为静态创建和动态织入

//静态
DECLLARE_WORK(name,void (*func)(void *), void *data) ;
//动态
INIT_wORK(struct work_struct *work,void(*func)(void *)void *data)

工作队列处理函数

该函数由工作线程执行,函数处于进程上下文(但不能访问用户空间,内核线程在用户空间没有内存映射,当发生系统调用时,内核会代表用户进程执行,才会访问用户空间,才有内存映射),允许响应中断,不持有任何锁;函数还可睡眠

对工作进行调度

工作创建好后,如果想要把任务交给缺省线程则调用schedule_work ( &work),该函数使得任务会被立刻执行,如果你想要延迟效果,则调用schedule_delayed_work ( &work,delay) ;

刷新操作

排入队列的任务会在工作线程被唤醒的时候执行,有时你要继续一个工作,那么需要确保一些操作已经执行完成,例如在卸载之前,他就有可能调用下面的函数,在内核部分为了防止竞争的出现也会确保不再有处理的工作;所说的这些对应到工作队列就是刷新去检查队列是否还有任务对象

  • 前者就是刷新函数针对于没有延迟需求的任务,直到队列的所有对象都被执行以后才会返回,等待过程中函数会一直阻塞,所以只能在进程上下文使用它
  • 后者如果指定的延迟时间未结束,那么不会调用前者进行刷新的
void flush_scheduled_work(void) ;
int cancel_delayed_work (struct work_struct *work);

创建新的工作队列

如果每个处理器默认的缺省队列无法满足你,此时你就可以创建新的工作队列即工作线程,这样会为每个处理器都创建一个工作线程,如果你无法保证能增强性能还是别趟这潭浑水

4.3 老的任务队列机制

  • 概述

就像BH接口被软中断和tasklet替代一样,任务队列也被工作队列逐渐替代,老的任务队列与进程也没什么关系,由于任务队列错综复杂的配合,之间联系也不大,所以任务队列接口实际上是一团乱麻。这些队列基本上都是些随意创建的抽象概念,散落在内核各处,就像飘散在空气中。唯有调度队列有点意义它能用来把工作推后到进程上下文完成。

任务队列的唯一优点就是接口简单,但如果抛开这点就一无是处了

5.下半部的总结

5.1 下半部机制的选择

各部分的优点上面介绍的时候已经很详细的说过了,在这里大致总结下

  • 如果你希望下半部中也要追求效率,执行时间尽可能底(例如网络系统)那么你就可以用软中断机制,但必须保证其数据安全(两个同类型支持在不同的处理器同时执行)
  • 如果要求简单,并且有安全要求但又不想费心思(两个类型不能同时在不同的处理器执行)的tasklet是一个非常值得选的
  • 如果你想将任务推迟到进程上下文,那么工作队列值得你的信赖;如果你对易用性,工作队列的缺省工作线程也是你选择的一大理由
    在这里插入图片描述

5.2 在下半部之间加锁

  • 概述

这里我们简单扩展以下,稍后一个博文会详细介绍锁与同步,在使用下半部机制时,即使使用在单处理器系统,避免数据被同时访问是很重要的

使用tasklet的好处,就是自身就实现了锁机制(序列化保障),两个同类型的tasklet不允许在不同的处理器上同时执行

  • 注意

如果进程上下文和一个下半部共享数据,在访问这些数据之前,你需要禁止下半部的处理并得到锁的使用权。做这些是为了本地和SMP的保护并且防止死锁的出现。

如果中断上下文和一个下半部共享数据,在访问数据之前,你需要禁止中断并得到锁的使用权。所做的这些也是为了本地和SMP的保护并且防止死锁的出现。

任何在工作队列中被共享的数据也需要使用锁机制。其中有关锁的要点和在一般内核代码中没什么区别,因为工作队列本来就是在进程上下文中执行的。

5.3 禁止下半部

  • 概述

一般单纯禁止下半部的处理是不够的。为了保证共享数据的安全,更常见的做法是,先得到一个锁然后再禁止下半部的处理。驱动程序中通常使用的都是这种方法

如果需要禁止所有的下半部处理(明确点说,就是所有的软中断和所有的tasklet),可以调用local_bh_diasble()函数。允许下半部进行处理,可以调用local_bh_enable()函数。

  • 为什么上面函数禁止不了工作队列

是因为工作队列是同步阻塞的执行方式,在进程上下文,有任务来工作线程就会唤醒执行;而软中断和tasklet显然是异步执行,任务形成被挂起需要等到下一个中断返回才会被执行,所以内核代码必须禁止他们

  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值