《深入理解Linux内核》-3.2. 进程描述符

3.2. 进程描述符

为了管理进程,内核必须对每个进程正在做什么有一个清晰的画面。比如,它必须知道进程的优先级、它是正在运行还是被某个事件阻塞、它的地址空间是多少、它被允许访问哪些地址等等。进程描述符发挥了这些作用,它是一个task_struct类型的结构,包含与单个进程有关的所有信息。正因为存放了这么多信息,进程描述符是非常复杂的。除了大量的包含进程属性的字段,文件描述符还包含若干个指向其他数据结构的指针,这些数据结构又包含指向其他结构的指针。图3-1形象地展示了linux进程描述符的结构。

3.2.1 进程状态

顾名思义,进程描述符的state字段描述了进程当前正在发生的事情。它包含一组标记值,每个表示一种可能的进程状态。 在现在的linux版本里,这些状态是互斥的,因此总是只有一个标志位被设置;剩下的标志位被清除。下面是这些可能的进程状态:

TASK_RUNNING

  • 进程正在运行或者等待运行。

TASK_INTERRUPTIBLE

  • 进程被挂起(睡眠)直到某些条件变为true。产生一个硬件中断、释放一个进程正在等待的系统资源、或者传递一个信号,这些都会唤起一个进程(把它的状态恢复为TASK_RUNNING)。

TASK_UNINTERRUPTIBLE

  • 类似于TASK_INTERRUPTIBLE,唯一的不同是当传递一个信号给挂起的进程时,它的状态保持不变。这个状态很少被使用,但却是有用的,在某些特殊的情况下,进程在等待一个事件时不能被中断。举个例子,当一个进程打开文件设备,相应的设备驱动程序开始探测对应的硬件设备,在探测结束之前这个设备驱动程序必须不能被中断,否则硬件设备将会停留在不可预知的状态。

图3-1. 进程描述符

进程描述符

TASK_STOPPED

  • 进程已经被暂停运行(只是被暂停运行,并不是终止运行);进程在收到这些信号后进入该状态:SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU。

TASK_TRACED

  • 进程被调试器暂停了运行。当一个进程被其他进程监视时(比如当调试器执行ptrace来监视一个test程序时),每一个信号都会使该进程进入TASK_TRACED状态。

另外,有两个状态可以被同时存储于进程描述符state和exit_state字段;从名字就可以看出,只有在进程终止了才会进入这两种状态:

EXIT_ZOMBIE

  • 进程已终止,但是父进程还没有发起wait4()或waitpid()系统调用来获取僵尸进程的信息。在wait族函数被调用之前,内核不能丢弃僵尸进程的描述符中的任何数据,因为父进程可能会用到。(查看本章最后一节中的“进程移除”一段)。

EXIT_DEAD

  • 这是最后的状态:进程已经被系统移除了,因为父进程刚调用了wait族函数。从EXIT_ZOMBIE到EXIT_DEAD状态的转换避免了一种竞争态:当其他线程对同一个进程调用了wait族函数时(查看第5章)。

state字段的设置通常是用一个简单的赋值语句。比如:

p->state = TASK_RUNNING;

实际上内核会使用set_task_stateset_current_state宏:它们分别设置指定进程的状态和当前进程的状态。另外,这些宏保证了这个赋值语句不和其他编译器或cpu控制单元的指令混合在一起。打乱它们的执行顺序有时候会导致灾难性的后果(查看第5章)。

3.2.2. 标识进程

原则上,每个可被独立调度的执行上下文必须有自己的进程描述符;因此即使是共享大部分内核数据结构的轻量级进程,也有自己的tast_struct结构。

进程和进程描述符严格的一对一关系使得task_struct的32位地址成为内核标识进程的有效手段。这些地址被称为进程描述符指针。内核对进程的大部分引用都是通过进程描述符指针的。

另一方面,类unix操作系统允许用户通过一个叫做进程ID(PID)的数字来标识进程,这个数字被存储在进程描述符的pid字段里。PID按顺序编号:新创建进程的PID通常是在前一个创建的进程的PID上加1。当然,PID值有上限,当内核达到了这个上限,它必须回收更小的未使用的PID。PID的最大默认值为32767(PID_MAX_DEFAULT - 1);系统管理员可以通过向/proc/sys/kernel/pid_max(/proc挂载了一个特殊的文件系统,请查看第12章“特殊文件系统”)文件写入一个更小的值来减小这个上限。在64位架构体系下,系统管理员可以把上限值扩大到4,194,303。

当回收PID数值时,内核维护了一个可以表示哪些PID正在使用、哪些是空闲的pidmap_array位图。因为一个页帧大小是32768比特,在32位体系架构上,pidmap_array存储在单个页面中。 但是,在64位架构上,当内核使用了一个超过当前页大小的PID数值时,更多的页会加到位图中。这些页面从来不被释放。

Linux给系统中每个进程或者轻量级进程关联一个不同的PID。(我们将在本章之后看到,在多处理器操作系统中有小小的例外。)这种方法允许最大的灵活性,因为每个系统中的执行上下文都可以被唯一标识。

另一方面,UNIX程序员们希望同一组中的线程具有相同PID。比如,给一个PID发送信号来影响组中的所有线程。实际上,POSIX 1003.1c标准已经声明一个多线程应用程序中的所有线程必须拥有相同的PID。

为了满足这个标准,Linux使用了线程组技术。线程共享的标识符是线程组leader的PID,也就是组里第一个轻量级进程的PID;它存储在进程描述符的tgid字段里。getpid()系统调用返回当前进程的tgid值,而不是pid,因此一个多线程应用程序中的所有线程共享相同的标识符。大多数进程属于只有一个成员的线程组(单线程),作为组leader,它们的tgid和pid具有相同的值,如此getpid()系统调用对这类进程也可以正常工作。

稍后,我们将向您展示如何从各自的PID中有效地导出真实的进程描述符指针。效率非常重要,因为很多系统调用使用PID来表示受影响的进程,比如kill()。

3.2.2.1. 进程描述符的处理

进程是一个动态的实体对象,它们的生命周期短至几毫秒,长至几个月。因此内核必须能够同时处理很多个进程,把进程描述符存储在动态内存中而不是永久分配给内核的内存区域。对于每个进程,Linux在一个进程内存区域封装了两个不同的数据结构:一个跟进程描述符关联的小数据结构,叫做thread_info结构体,和内核态进程栈。这段内存区域的大小通常是8192字节(两个页帧)。考虑到性能,内核把这个8KB的内存区域存储在两个连续的页帧中,且第一个帧按2^13对齐;当可用内存较少时,这可能导致一个问题,因为空闲内存可能变得高度的分散(查看第8章中“系统算法”一节)。因此,在80x86架构上,可以在内核编译的时候配置,使包含栈和thread_info结构的内存区域跨越一个页帧(4096字节)。

在第二章“Linux的分段”里,我们知道进程在内核态中访问的内核数据段中的栈和用户态的栈是不一样的。因为内核控制路径中很少使用栈,所以内核栈只需要几千个字节就可以满足需求。因此8KB对内核栈和thread_info结构来说是很充足的。然而,内核往往使用一些额外的堆栈来避免深度嵌套的中断和异常造成的溢出(请查看第4章)。

图3-2展示了这两个数据结构是怎样存储在2页(8KB)的内存空间中的。thread_info结构放置在这段内存区域的起始处,栈从末尾处向下增长。这张图还展示了thread_infotask_struct结构分别通过taskthread_info字段互相关联。

Figure 3-2. 把thread_info结构和进程内核栈存储在两个页帧中

这里写图片描述

esp寄存器是cpu的栈指针,用来定位栈顶地址。在80x86系统上,栈从内存区域的末尾向起始位置增长。在进程刚好从用户态向内核态切换后,内核栈总是空的,因此esp寄存器也是指向紧跟在栈后面的字节的。

esp的值随着数据写入栈而减小。因为thread_info结构是52个字节长度,内核栈可以扩展到8140个字节(8K=52+8140=8192)。

C语言允许使用union类型来很方便的表示thread_info结构和内核栈:

union thread_union {
    struct thread_info thread_info;
    unsigned long stack[2048];/* 4*2048=8K */
};

图3-2中显示的thread_info结构的起始地址为0x015fa000,栈的起始地址为0x015fc000。esp寄存器指向栈顶0x015fa878。

内核使用alloc_thread_infofree_thread_info宏来分配和回收存放thread_info结构和内核栈的内存。

3.2.2.2. 标识当前进程

刚才提到的thread_info结构和内核栈的紧密关系对提高效率大有益处:内核通过esp可以很方便的获得当前正在运行的进程的thread_info地址。实际上,如果thread_union大小为8KB(2^13字节),内核通过掩码计算出esp的13个最低有效位来得到thread_info的基地址;类似的,如果thread_union为4KB,则取出12个最低有效位。current_thread_info函数可以实现这些功能,它会产生类似于下面的汇编指令:

movl $0xffffe000,%ecx /* or 0xfffff000 for 4KB stacks */
andl %esp,%ecx
movl %ecx,p

当这三条指令执行完毕,p指向当前运行进程的thread_info结构的地址。

多数情况下内核需要的是进程描述符的地址而不是thread_info的地址。为了得到一个cpu上正在运行的进程的描述符指针,内核使用current宏,它等同于current_thread_info->task,并产生以下汇编指令:

movl $0xffffe000,%ecx /* or 0xfffff000 for 4KB stacks */
andl %esp,%ecx
movl (%ecx),p

因为task为thread_info结构的第一个字段,所以当执行完这些指令后,p刚好指向该CPU当前正在运行进程的描述符。

current宏经常出现在内核代码中,用来获取进程描述符中的字段。比如:current->pid返回当前进程的PID。

另一个把进程描述符和栈存放在一起的好处体现在多处理器系统上:仅仅通过检查stack就可以获取每个CPU上正在运行的进程。早期的Linux版本没有把这两个结构存储在一起,相反的,它们使用一个全局静态变量current来定义当前运行的进程。在多处理器系统上,就需要把current定义为每个CPU独有的变量。

3.2.2.3. 双向链表

在继续描述内核怎样跟踪系统中各种进程之前,我们引入一个重要的实现双向链表的数据结构。

每个链表都需要实现一系列的基础操作:初始化、插入、删除、遍历等等。对每个不同的链表都重复实现这些基础操作不仅浪费程序员的时间也浪费了内存。

因此,Linux内核定义了list_head结构,它只有next和prev字段名分别表示一个通用双链表的前一个和下一个元素。但是,有一点必须要注意:list_head存放的是其他list_head的地址,而不是整个包含list_head的结构的地址;请看图3-3(a)。

LIST_HEAD(list_name)宏用来新建一个链表。它声明一个list_head类型的变量list_name,这是个傀儡节点,它充当新链表的头节点占位符,并初始化list_head的prev和next字段使得它们指向list_name节点自身。请看图3-3(b)。

图 3-3. list_head构成的双链表

这里写图片描述

一些函数和宏实现了这个双向链表的基本操作,包含在表3-1中:

表3-1 list操作函数和宏

名称 描述
list_add(n,p) 在p之后插入n。(如要插入到链表起始位置,请把p设置为链表头)
list_add_tail(n,p) 在p之前插入n。(如要插入到链表结尾,请把p设置为链表头)
list_del(p) 删除p。(这里不需要制定链表头)
list_empty(p) 检测以p为头的链表是否为空。
list_entry(p,t,m) 返回类型为t的结构的地址,其中包含名称为m和地址为p的list_head字段
list_for_each(p,h) 遍历以h为头的链表,每次迭代,返回新的list_head的地址给p
list_for_each_entry(p,h,m) 类似于list_for_each,只是返回的是包含list_head的结构的地址,而不是list_head的地址

Linux2.6内核支持另一种类型的双向链表,与list_head链表的主要不同在于它不是循环的。它主要在内存比较宝贵的哈希表里会用到,而且不能在O(1)的时间内寻找最后一个元素。链表头部存储在hlist_head结构里面,它只是一个简单的指向链表第一个元素的指针(链表为空时值为NULL)。每个元素保存在hlist_node结构里,它包含一个指向下一个元素的next指针和指向前一个元素的next字段的pprev指针(pprev是个很巧妙的设计)。因为链表不是循环的,最后一个元素的next为NULL(注:原文说第一个元素的pprev也为NULL,应当是不正确的,事实上它指向head的first字段)。对这个链表的基本操作函数和宏类似于表3-1:hlist_add_head( ), hlist_del( ), hlist_empty( ), hlist_entry, hlist_for_each_entry, 等等。

3.2.2.4. 进程列表

我们将要研究的第一个双链表是进程列表,它把所有系统存在的进程描述符串在一起。每个task_struct结构包含一个list_head类型的tasks字段,它的prev和next字段分别指向前一个和后一个task_struct元素。

进程链表的头部是一个task_struct类型的init_task描述符,它是所谓的进程0或交换区的描述符(请查看本章之后的“内核线程”一节)。init_task的tasks->prev字段指向链表最后一个元素的tasks字段。

SET_LINKSREMOVE_LINKS宏分别用来从进程列表中插入和删除进程描述符。这些宏也关系到进程间的父子关系(查看本章之后的“进程是怎么组织的”一节)。

另一个有用的宏,叫做for_each_process,遍历整个进程列表。它的定义如下:

#define for_each_process(p) \
   for (p=&init_task; (p=list_entry((p)->tasks.next, \
                                    struct task_struct, tasks) \
                                   ) != &init_task; )

这个宏是循环控制语句,内核程序员把循环体添加在其后面。请注意init_task是怎样只充当链表头的。这个宏开始时移动init_task到下一个task,然后继续循环一直到重新遇到init_task时为止(多亏了循环链表)。每次迭代,传递给宏的参数包含的当前扫描的进程描述符,也就是list_entry宏返回的值。

3.2.2.5. TASK_RUNNING进程链表

当寻找一个新的进程来运行在CPU上时,内核只需要关注那些可运行的进程(即处于TASK_RUNNING状态的进程)。

早期的Linux版本把所有可运行的进程放在同一个runqueue链表里面。因为要按进程优先级来维护链表成本太高,早期的调度器被迫扫描整个链表,为了选出最好的进程来运行。

Linux2.6用不同的方式来实现这个运行队列。目的是为了能使调度器在常量的时间内选出最好的可运行进程,而与可运行进程的数量无关。我们会在之后的第7张详细描述这种运行队列,这里只提供一些基本信息。

实现提高调度速度的策略包括把运行队列分割成许多可执行进程链表,每个优先级一个链表。每个task_struct描述符包含一个list_head类型的run_list字段。如果一个进程优先级为k(k为0-139之间的一个值),run_list字段把这个进程描述符链接到优先级为k的可运行进程链表。此外,对于多处理器系统,每个cpu有自己的运行队列。这是一个典型的增加数据结构复杂性来提高性能的例子:为了更高效地调度,把运行队列分割成140个不同的链表。

我们将会看到,内核必须为每个运行队列保存许多数据;但是,运行队列的主要数据结构是多个进程描述符链表;所有这些链表由一个prio_array_t数据结构实现,它的字段如表3-2所示:

表3-2. prio_array_t的字段

类型 字段 描述
int nr_active 链接到该链表的进程描述符个数
unsigned long [5] bitmap 优先级位图:当且仅当对应的优先级列表不为空时,其标志位才被设置为1
struct list_head [140] queue 140个优先级链表的头

enqueue_task(p,array)函数插入一个进程描述符到运行链表里面;它的核心代码大致如下:

list_add_tail(&p->run_list, &array->queue[p->prio]);
__set_bit(p->prio, array->bitmap);
array->nr_active++;
p->array = array;

进程描述符的prio字段存储这个进程的动态优先级,而array字段是一个指向它当前运行队列的prio_array_t指针。类似的,dequeue_task(p,array)函数从运行队列里面移除一个进程描述符。

3.2.3 进程之间的关系

一个程序和它创建进程之间有父子关系。当一个进程创建多个子进程时,这些子进程具有兄弟关系。为了表示这些关系,必须在进程描述符里引入几个字段,表3-3里列出了给定进程P的这些字段。进程0和进程1由内核创建;我们将在本章之后看到进程1是所有其他进程的祖先。

表3-3. 用来表示父子关系的进程描述符字段

字段名 描述
real_parent 指向创建进程P的进程的描述符,若不存在则指向进程1(init)的描述符。(因此,当用户启动一个后台进程并退出shell后,这个后台进程会立刻变成init进程的子进程。)
parent 指向进程P的真实父进程(它是当该进程终止后必须通知的进程);它和real_parent极为相似,只在某些情况下可能会不同,比如另一个进程对P进程发起ptrace系统调用(请查看第20章“跟踪执行”一节)。
children 包含所有由P创建的所有子进程的链表的头
sibling 指向兄弟进程链表中的下一个和前一个元素,它们和P具有相同的父进程。

图3-4阐述了一组进程的父子和兄弟关系。进程P0连续创建了P1,P2,P3。P3又创建了P4。

此外,进程之间还有其他关系:一个进程可以是一个进程组或一个登陆会话的leader(请参考第一章“进程管理”),它也可以是一个线程组的leader(参考本章之前的“标识进程”一节),它也可以跟踪其他进程的执行(参考第20章的“跟踪执行”一节)。表3-4列出了进程描述符的这些字段,它们建立了进程P和其他进程的这些关系。

表3-4. 建立非父子进程之间关系的描述符字段

字段名 描述
group_leader P所属进程组leader进程的描述符指针
signal->pgrp P所属进程组leader进程的PID
tgid P所属线程组leader进程的PID
signal->session P所属登录会话leader进程的PID
ptrace_children 被调试器跟踪的P的子进程链表的头
ptrace_list 指向实际父进程的被跟踪进程列表的下一个和前一个元素(当P被跟踪时用到)

图3-4. 五个进程之间的父子关系

这里写图片描述

3.2.3.1. PID哈希表和链表

在一些情况下,内核必须能够由PID导致相应的进程描述符。比如在kill()系统调用里面。当进程P1想要发送一个信号给另一个进程P2时,它调用kill()系统调用,以P2的PID作为参数。内核由P2的PID导出进程描述符指针,然后从进程描述里提取出记录未决信号的数据结构指针。

顺序扫描进程列表并校验pid字段是可行的方案但是非常低效。为了加速搜索,引入了四个哈希表。为什么有多个哈希表?原因很简单,因为进程描述符对不同类型的PID有不同的字段(见表3-5),每种类型的PID需要有自己的哈希表。

表3-5. 四个哈希表和他们对应的进程描述符字段

哈希表类型 字段名 描述
PIDTYPE_PID pid 进程的PID
PIDTYPE_TGID tgid 线程组leader的PID
PIDTYPE_PGID pgrp 进程组leader的PID
PIDTYPE_SID session 会话leader的PID

这四个哈希表在内核初始化的时候被动态分配,它们的地址存储在pid_hash数组里。单个哈希表的大小取决于可用的RAM;比如,对于512M内存的系统,每个哈希表存放在四个页帧中并包含2048条数据。

PID到哈希表索引的转换是通过pid_hashfn宏,它的定义如下:

#define pid_hashfn(x) hash_long((unsigned long) x, pidhash_shift)

pidhash_shift变量存储哈希表索引的长度(在我们的例子里是11)。hash_long()函数被很多哈希函数用到,在32位系统上等价于:

unsigned long hash_long(unsigned long val, unsigned int bits)
{
    unsigned long hash = val * 0x9e370001UL;
    return hash >> (32 - bits);
}

在我们的例子里,因为pidhash_shift等于11,pid_hashfn输出的值在0和2^11-1=2047之间。

【神奇的常量】 你也许会好奇0x9e370001(=2,654,404,609)这个常量是怎么来的。上面这个哈希函数基于索引和一个合适的大数的相乘,因此结果会溢出,剩下的在32位变量里的值可以看做一次取模操作的的结果。Knuth建议当大的乘数是一个2^32(32位是80x86寄存器的大小)的黄金比例左右的质数时,可以得到比较好的结果。这里,2,654,404,609是一个2^32×(√5-1)/2左右的质数,它可以轻易的用加法和移位操作来代替乘法,因为它等价于2^31+2^29-2^25+2^22-2^19-2^16+1。

正如每个计算机基础课程都会解释的那样,哈希函数不总能保证PID和表索引是一对一的关系。两个不同的PID哈希到同一个索引被叫做碰撞。

Linux使用链表来解决碰撞问题;每个表项存放的是碰撞的进程描述符的双向链表的头。图3-5描绘了有两个链的PID哈希表。PID为2890和2938的检测哈希到表的第200个元素,而PID为29385的进程哈希到了第1466个元素里。

链式哈希优于从PID到表索引的线性转换,因为在任意给定情况下,系统中进程的数目常常远小于32768(最大允许的PID数)。当大多数哈希表项没被使用,定义一个32768大小的哈希表是对内存的浪费。

哈希表里面的数据结构非常精致,因为它们必须记录进程之间的关系。举例来说,内核有时候需要获取一个线程组的所有进程,即所有tgid等于某个值的所有进程。从哈希表里查找指定的线程组号只能返回一个进程描述符,也就是线程组leader的描述符。为了能快获取组中其他进程,内核必须为每个线程组维护一个链表。当查找指定登录会话或者指定进程组中的所有进程时也会遇到相同的问题。

图3-5. 一个简单的PID哈希表和它的链表

这里写图片描述

PID哈希表的数据结构解决了所有这些问题,因为它们允许在哈希表定义一串任意进程的PID。核心的数据结构是一个包含四个pid结构的pids数组;pid结构的字段如表3-6所示:

表3-6. pid结构的字段

类型 名词 描述
int nr pid号
struct hlist_node pid_chain 指向哈希链表的下一个和前一个元素的链接
struct list_head pid_list PID链表头

图3-6展示了一个基于PIDTYPE_TGID类型的哈希表。pid_hash的第二个元素存储了这个哈希表的地址,即表示链表头的hlist_head结构数组的地址。哈希表的第71个条目的根链表中存储了两个PID为246和4351的进程描述符(双箭头的一行代表了一对向前和向后的指针)。PID值存储在进程描述符的pid结构的nr字段(顺便说一下,因为线程组号和它的leader进程的PID相等,这个数值还存储在进程描述符的pid字段)。让我们考虑下线程组4351的per-PID链表(同一线程组的进程):链表头存储在哈希表中的进程描述符的pid_list字段,指向链表下一个和前一个元素的指针也存储在pid_list中。

图3-6. PID哈希表

这里写图片描述

下面的函数和宏是用来处理这个PID哈希表的:

do_each_task_pid(nr, type, task)

while_each_task_pid(nr, type, task)

  • 以上两个宏分别用来标记一个do-while循环的开始和结束,这个循环用来遍历类型为type且PID为nr的per-PID链表。每次迭代,task指向当前扫描的元素。

find_task_by_pid_type(type, nr)

  • 从type类型的哈希表里查找PID为nr的进程。找到则返回进程描述符指针,否则返回NULL。

find_task_by_pid(nr)

  • 等同于find_task_by_pid_type(PIDTYPE_PID, nr)

attach_pid(task, type, nr)

  • 插入一个task指向的进程描述符到type类型的哈希表里PID为nr的链表。如果哈希表中已存在PID为nr的进程,则简单的插入一个task到per-PID链表里。

detach_pid(task, type)

  • 从type类型的per-PID链表里移除task指向的进程描述符。如果移除后per-PID链表不为空,则函数终止。否则从哈希表中移除该进程描述符;最后,如果PID没有在任何其他哈希表中出现,则从PID位图中清除相应的bit,以使这个PID号能被回收。

next_thread(task)

  • 返回PIDTYPE_TGID类型的哈希链表中位于task之后的轻量级进程的进程描述符地址。因为哈希表的链是循环的,当task指向的是传统进程(单进程,非多线程),则返回这个进程本身的描述符。

3.2.4. 进程之是怎么被组织的

运行队列链表把所有处于TASK_RUNNING状态的进程组织在一起。对于处在其他状态的进程,则需要不同的对待,Linux有以下两种选择。

  • 处于TASK_STOPPEDEXIT_ZOMBIE或者EXIT_DEAD状态的进程没有用特殊的链表来连接。没有必要把任何处于这些状态的进程组织在一起,因为暂停、僵尸或者死亡的进程只通过PID或者通过某个特定进程的子进程链表来访问。
  • 处于TASK_INTERRUPTIBLE或者TASK_UNINTERRUPTIBLE状态的进程被分成很多种类,每种对应一个特殊的事件。在这种情况下,只从进程状态不能快速获取到进程,所以必须要引入额外的进程链表。这些就是接下来将要探讨的等待队列。

3.2.4.1. 等待队列

等待队列在内核里有几个用处,尤其是中断处理,进程同步,和定时器。因为接下来的章节会讨论这些话题,这里我们只简单的说一个进程必定经常等待某些事件的发生,比如磁盘操作的完成,系统资源的释放,定时器的触发等。等待队列实现了事件上的条件等待:一个希望等待某个特殊事件的进程把自己放到合适的等待队列里面并让出控制权。因此,等待队存放了一组睡眠中的进程,这些进程将会在某个条件为true时被内核唤醒。

等待队列本质是一个双链表,存放包含进程描述符指针的元素。每个等待队列由一个wait_queue_head_t类型的队列头来定义:

struct _ _wait_queue_head {
    spinlock_t lock;
    struct list_head task_list;
};
typedef struct _ _wait_queue_head wait_queue_head_t;

因为等待队列会被各种中断处理函数和内核函数修改,当并发访问这个双链表时必须对其加以保护,否则可能导致不可预知的结果。同步是用队列头中的自旋锁lock来实现的。task_list字段是等待进程链表的头部。

等待队列的数据元素类型为wait_queue_t

struct _ _wait_queue {
    unsigned int flags;
    struct task_struct * task;
    wait_queue_func_t func;
    struct list_head task_list;
};
typedef struct _ _wait_queue wait_queue_t;

等待队列中的每个元素都代表了一个睡眠中并等待某个事件发生的进程;它的描述符地址存储在task字段。task_list字段则保存了所有等待相同事件的进程链表。

然而,唤起等待队列中的全部进程并不总是合适的。举例来说,比如两个或者更多的进程正等待对某个待释放资源的互斥访问,这时候只唤起等待队列中的一个进程是有意义的。一个进程得到资源,其他进程继续睡眠。(这避免了有名的“惊群”问题,即多个竞争同一个资源的进程被同时唤醒,而这个资源只能同时被一个进程访问,结果导致其他进程又重新进入sleep状态。)

因此,sleep中的进程可以分为两种:互斥的进程(相应等待队列元素中的flag等于1)被内核有选择的唤醒,而非互斥的进程都被唤醒。如果进程等待的资源只能授予一个进程,则它们是互斥进程,相反,如果等待的事件与所有等待中的进程都相关,则它们是非互斥的。比如,考虑一组进程正等待磁盘数据块的传输,一旦传输完成,所有的进程必须被唤醒。我们接下来会看到,等待队列元素中的func字段定义了等待的进程是怎样被唤醒的。

3.2.4.2. 处理等待队列

可以用DECLARE_WAIT_QUEUE_HEAD(name)宏来定义一个新的等待队列,它声明一个队列头静态变量name,并初始化locktask_list字段。init_waitqueue_head()函数可以用来初始化一个动态分配的等待队列头变量。

init_waitqueue_entry(q,p)函数是这样初始化一个wait_queue_t结构的:

q->flags = 0;
q->task = p;
q->func = default_wake_function;

这个非互斥的进程会被default_wake_function()唤醒,它其实是对第7章中讨论的try_to_wake_up()函数的简单封装。

此外,DEFINE_WAIT宏用来声明一个新的wait_queue_t变量,并用CPU当前运行进程的描述符和autoremove_wake_function()唤醒函数的地址来初始化这个变量。这个函数调用default_wake_function()来唤醒这个睡眠的进程,然后从等待队列里删除其对应的元素。内核开发者可以自定义一个唤醒函数:通过init_waitqueue_func_entry()来初始化等待队列元素。

每当一个元素被定义后,它必须被插入到等待队列。add_wait_queue()函数在等待队列的第一个位置插入一个非互斥的进程,add_wait_queue_exclusive()函数插入一个互斥进程到等待队列的末尾。remove_wait_queue()函数从等待对列里移除一个进程。waitqueue_active()函数检查一个给定的等待队列是否为空。

进程若要等待一个特定的条件,可以调用下面列出的任意一个函数:

  • sleep_on()函数作用于当前进程:

    void sleep_on(wait_queue_head_t *wq)
    {
        wait_queue_t wait;
        init_waitqueue_entry(&wait, current);
        current->state = TASK_UNINTERRUPTIBLE;
        add_wait_queue(wq,&wait); /*  wq points to the wait queue head  */
        schedule( );
        remove_wait_queue(wq, &wait);
    }
    

    该函数把当前进程的状态设置为TASK_UNINTERRUPTIBLE并插入到这个等待队列中。然后唤起调度器来恢复另一个进程的执行。当这个睡眠的进程被唤醒时,调度器恢复sleep_on()的执行,并从等待队列中删除这个进程。

  • interruptible_sleep_on()函数类似于sleep_on(),不同之处是它把当前进程的状态设置为TASK_INTERRUPTIBLE而不是TASK_UNINTERRUPTIBLE,所以进程仍然会被接收到的信号唤起。

  • sleep_on_timeout()interruptible_sleep_on_timeout函数类似于之前的函数,只是它们允许调用者定义一个时间间隔,当超过这个时间,进程会被内核唤起。为了做到这个,需要用schedule_timeout()来代替schedule()(参考第6章中“一个动态定时器应用:nanpsleep()系统调用”一节)。
  • prepare_to_wait()prepare_to_wait_exclusive(),和finish_wait()是Linux2.6引入的函数,提供了另一种使当前进程sleep并把它放进等待队列的方法。通常,它们的使用方式如下:

    DEFINE_WAIT(wait);
    prepare_to_wait_exclusive(&wq, &wait, TASK_INTERRUPTIBLE);
                            /* wq is the head of the wait queue */
    ...
    if (!condition)
        schedule();
    finish_wait(&wq, &wait);
    

    prepare_to_wait()prepare_to_wait_exclusive()函数把进程的状态设置为传入的第三个参数的值,然后分别设置等待队列元素中的互斥标志为0(非互斥)或者1(互斥),最后将队列元素wait插入到以wq为头的等待队列中。

    只要进程被唤醒,它会执行finish_wait()函数:把进程状态恢复为TASK_RUNNING(以防万一condition在schedule()之前就变为true,finish_wait()的作用仅在于此),然后从等待队列中删除对应的元素(除非唤醒函数已经做了这个)。

  • wait_eventwait_event_interruptible宏使调用进程进入等待队列里休眠,直到一个给定的条件被满足,wait_event(wq,condition)宏实质产生以下代码:

    DEFINE_WAIT(_ _wait);
    for (;;) {
        prepare_to_wait(&wq, &_ _wait, TASK_UNINTERRUPTIBLE);
        if (condition)
            break;
        schedule( );
    }
    finish_wait(&wq, &_ _wait);
    

关于上面列出的函数的一些注释:sleep_on()-类函数不能用在这样一个常见的场景下:先判断条件,当条件不满足时才使进程进入睡眠状态,这是一个众所周知的竞争源(资源竞争),因此不建议使用这类函数。而且,当在等待队列里插入一个互斥进程时,必须使用prepare_to_wait_exclusive()函数(或者直接调用add_wait_queue_exclusive());其他辅助函数都会将进程当做非互斥的插入。最后一点,除非使用了DEFINE_WAIT()或者finish_wait(),内核必须在进程被唤醒后从等待队列里删除相应的元素。

内核使用以下宏把进程从等待队列中唤醒,并将其置为TASK_RUNNING状态:wake_up, wake_up_nr, wake_up_all, wake_up_interruptible, wake_up_interruptible_nr, wake_up_interruptible_all, wake_up_interruptible_sync, wake_up_locked。从它们的命名就可以知道它们的功能:

  • 所有宏会处理处于TASK_INTERRUPTIBLE状态的休眠进程;名字中不含有“interruptible”字符串的宏,同时会处理TASK_UNINTERRUPTIBLE状态的进程。
  • 所有宏会唤起全部的符合状态要求(TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE)的非互斥进程(参考前一条的介绍)。
  • 名字中包含“nr”的宏唤醒指定数量且符合状态要求的互斥进程,这个数字是宏的参数。名字中包含“all”的宏唤醒所有符合状态要求的互斥进程。最后,对于名字中既不含有“nr”也不含有“all”的宏只唤醒一个符合状态要求的互斥进程。
  • 名字中包含“sync”的宏会在唤醒进程时检查它的优先级是否比当前运行的进程高并在必要的时候调用schedule()。名字中不包含“sync”的宏,将不会做这些检查,因此一个高优先级的进程的执行可能会被稍微延后。
  • wake_up_locked宏类似于wake_up,不同之处是它在已经持有wait_queue_head_t内的自旋锁时调用。

举个例子,wake_up宏本质上等价于以下代码:

void wake_up(wait_queue_head_t *q)
{
    struct list_head *tmp;
    wait_queue_t *curr;

    list_for_each(tmp, &q->task_list) {
        curr = list_entry(tmp, wait_queue_t, task_list);
        if (curr->func(curr, TASK_INTERRUPTIBLE|TASK_UNINTERRUPTIBLE,
                       0, NULL) && curr->flags)
            break;
    }
}

list_for_each宏扫描双向链表q->task_list中的所有元素,即等待队列中的所有进程。对每个元素,list_entry计算出相应的wait_queue_t变量的地址。该变量的func字段存储唤醒函数的地址,唤醒函数尝试唤醒队列元素的task字段标识的进程。如果进程被成功唤醒(函数返回1)并且是互斥(curr->flag==1)的,则循环结束。因为非互斥进程总是在链表的前端,而互斥进程在末尾,因此该函数总是先唤醒非互斥进程,再唤醒互斥进程。

(译者注:等待队列是怎样和各种事件关联起来的?是不是每种事件一个队列?)

3.2.5. 进程资源限制

每个进程都有一组关联的资源限制,用于指定进程可用的系统资源数量。这些限制阻止了用户压垮系统(CPU,磁盘,等待)。表3-7列出了Linux支持的资源限制。

当前进程的资源限制存储在current->signal->rlimit字段,即进程的信号描述符中(参考第11章“与信号有关的数据结构”)。这个字段是struct rlimit类型的数组,每个元素对应一个资源限制:

struct rlimit{
    unsigned long rlim_cur;
    unsigned long rlim_max;
};

表3-7. 资源限制

字段名 描述
RLIMIT_AS 进程占用的最大地址空间,单位为字节。当进程使用malloc()或者相关的函数来增大地址空间的时候,内核会检查这个值(参考第9章“进程地址空间”一节)。
RLIMIT_CORE 最大core文件大小,以字节为单位。进程崩溃时,在生成core文件前内核会校验这个值(参考第11章“传递信号时执行的操作”一节)。 如果这个值为0,内核不会创建core文件。
RLIMIT_CPU 进程可使用的最大CPU时间,以秒为单位。如果进程超过这个值,内核发送一个SIGXCPU信号给它, 如果进程没有终止,再发送SIGKILL信号(参考第11章)。
RLIMIT_DATA 最大堆大小,以字节为单位。内核在扩大进程堆空间时会检查这个值(参考第9章中“管理堆”一节)。
RLIMIT_FSIZE 最大允许的文件大小,以字节为单位。如果进程尝试增大文件到超过这个值,内核给其发送SIGXFSZ信号。
RLIMIT_LOCKS 最大锁个数(目前不是强制的)。
RLIMIT_MEMLOCK 不可交换的内存的最大大小,单位为字节。当进程尝试使用mlock()或mlockall()来锁住一个内存页面时,内核会检查这个值(参考第9章中“分配一个线性地址间隔(Linear Address Interval)”一节)。
RLIMIT_MSGQUEUE POSIX消息队列的大小,单位为字节(参考19章中“POSIX消息队列”一节)。
RLIMIT_NOFILE 打开文件描述符的最大数目。当进程打开一个新的文件或者复制一个文件描述符时,内核会检查这个值(参考第12章)。
RLIMIT_NPROC 当前用户可拥有的最大进程数(参考本章之后的“clone(), fork(), vfork()系统调用”一节)。
RLIMIT_RSS 进程可拥有的最大页面数(目前没有强制)。
RLIMIT_SIGPENDING 进程的最大未决信号数(参考第11章)。
RLIMIT_STACK 最大栈空间,单位为字节。内核在扩大进程的用户态栈空间时检查这个值(参考第9章中“页面错误异常处理”一节)。

rlim_cur字段是资源当前的限制值。比如,current->signal->rlim[RLIMIT_CPU].rlim_cur代表正在运行进程的当前的CPU时间限制。

rlim_max字段最大允许的资源限制值。使用getrlimit()setrlimit()可以增大资源的rlim_currlim_max,只有超级用户(或者更精确的说是拥有CAP_SYS_RESOURCE权限的用户)才能增大rlim_max或者设置rlim_cur超过rlim_max(译者注:从源码上来看rlim_cur小于等于rlim_max是无条件要求的,任何用户都不能违背,这点跟原文相悖)。

大多数的资源限制都有RLIM_INFINITY(0xffffffff)这个值,表示对资源没有限制(当然,实际的限制仍然存在,比如内核设计的限制,可用的内存数,可用的磁盘空间等)。然而,系统管理员很可能对一些资源做很严格的限制。每当用户登录系统,内核创建一个超级用户的进程,这个进程可以调用setrlimit()来减小一些资源的rlim_maxrlim_cur的值,然后运行一个属于当前用户的登录shell。用户新建的每个进程从父进程继承rlim数组,因此这个用户不能超越并覆盖管理员设置的资源限制。

猜你喜欢

转载自blog.csdn.net/ybxuwei/article/details/78602474