(1)底层数据结构:双向链表
在进程管理中,双向链表是一个基础性的数据结构(后面涉及到的运行队列和等待队列等都使用了这个数据结构)。它的声明如下(虽然名称中含有head,但实际上每个结点都是相同的):
struct list_head *next, *prev;
};
其中含有指向前一节点和后一节点的指针。而作为双向链表,提供的主要操作就是添加/删除元素、遍历链表(特别是list_for_each()函数很重要,可以对每个元素采取相同的操作)。
(2)进程描述符
进程描述符是一个名为task_struct的C结构(进程也就是任务,所以叫task),其中包含了进程所有的信息。其中有几个成员变量是我们下面将要用到的(图中用小黑框标出):run_list,tasks。(点击这里看大图)
(3)双向链表与宿主的结合
我们回忆一下,如果我们需要实现二叉树数据结构,那么往往需要先实现其树节点的数据结构(含有左右子节点的指针),而且一般是包含在二叉树内部;高级的数据结构需要先实现底层的数据结构。我们将二叉树等这一类高级的数据结构称为“宿主”,其中包含有底层数据结构的节点。Linux内核中也不会直接应用双向链表这种数据结构,但是确实在很多地方都需要链表的接口,于是也采用了这种宿主与节点的模式。这实际上是面向对象设计中的组合,实现了代码复用以及降低了耦合性。
具体代码实现如下:
我们现在知道list成员是可以添加/删除元素、遍历的,但是如何才能遍历所有的foo对象或者foo对象中的data成员呢?内核采用了一个技巧,即知道宿主对象的首地址以及某成员相对首地址的偏移,就能知道某成员的地址了,然后就能取出相应的值。表示成公式就是:首地址+某成员偏移=某成员地址
int data;
struct list_head list;
};
这样的简单代码用过C语言的也应该都写过:
typedef struct _test
{ char i;
int j;
char k;
}Test;
int main()
{ Test *p = 0; printf("%p\n", &(p->k)); }
这里就可以打印出成员k相对于首地址的偏移。当然,上面的公式移项就可以换一种使用方法,已知某成员地址及其偏移量,即可求出宿主的首地址。
内核中实际上使用的是几个宏来具体计算:offsetof()/container_of()/typeof()。
其中typeof()宏就是获得其参数的类型,是由GCC编译器提供的。
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})
这里唯一需要解释的就是offsetof宏中的(size_t) &((TYPE *)0)->MEMBER语句:(TYPE*)0其实就是相当于上面例子代码中的Test *p=0语句,将0看做一个地址,然后通过转型为TYPE型的指针,就创建了一个起始地址为0的TYPE型的对象,然后用&取出其MEMBER成员的地址,转化成以size_t为单位的偏移字节量。
最终我们拥有了已知某成员即可得到宿主及其成员变量的方法,这样也就是说最终宿主也拥有了添加/删除成员、遍历的接口。
(4)双向链表与进程的结合实例:进程链表,运行队列与等待队列
这里所说的进程链表(见《深入》p.93)是指把系统中所有的进程都串起来的链表。使用了进程描述符中的tasks字段,这个字段是list_head型。
运行队列则是将所有状态为TASK_RUNNING的进程(可运行进程,即可被调度马上执行的进程)串接起来的链表。由于2.6版的内核采用了新的调度系统,所有的可运行进程按照优先级被串在了140个不同的队列中(即共有140个高低不同的优先级)。使用的是进程描述符中的run_list字段。
等待队列是指状态为TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE的进程的组织方式。它的底层数据结构也是双向链表。其头结点和普通节点的声明如下(重要字段用粗体标出):
spinlock_t lock;
struct list_head task_list;
};
struct __wait_queue {
unsigned int flags;
struct task_struct * task;
wait_queue_func_t func;
struct list_head task_list;
};
我们可以看到无论是头结点还是普通节点仍然通过包含一个list_head来实现串接,但是明显的与上面谈到的两个队列不同的是:(a)队头节点中含有自旋锁(具体原因请自行查阅);(b)等待队列不是包含在进程描述符中,而是在队列节点中包含了进程描述符task字段。
(5)进程哈希表pidhash及其中的链表
上面我们说到的数据结构都是链表,但是我们在进程管理中也用到一个hash表。为什么要使用hash表呢?因为我们在linux中经常会用到此类操作:给出进程号pid,要求得到进程描述符(例如kill()系统调用,参数为pid,但是函数要去改变进程描述符中的state字段)。如果我们在进程队列中遍历然后看其pid是否为所需的pid,这样做效率是很低的。而hash就是一个以空间换时间的方法。具体的hash函数就不写了(见《深入》p.97)。但是这里仍然使用了一个链表,是因为凡是hash表,就会发生冲突(因为我们一般不会使用一一对应的hash表,这里所用的hash表一般是2048项,但系统中的进程往往可以最大到32767项)。冲突的解决方法采用了分离链接法:即将所有冲突的项都串联到一个表项上,于是形成了链表。理论上会形成2048个链表,但是基本上冲突的概率比较小,所以链表都不会很长。
(6)进程的生命周期
进程的生命周期本章里面主要包括:创建、切换、撤销(调度将在第七章)。而这些功能主要都是由一些wrapper模式的内核函数实现的。
创建:clone()调用do_fork(),do_fork()再调用copy_process()。
切换:switch_to()宏
撤销:exit_group()调用do_group_exit()终止整个线程组;exit()调用do_exit()终止单个的线程。