2012年(52)
分类:
2012-05-30 20:05:23
原文地址:进程相关的数据结构 作者:yulianliu1218
为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。例如,内核必须知道进程的优先级,它是正在CPU上运行还是因某些事件而被阻塞,给它分配了什么样的地址空间,允许它访问哪个文件等等。
这些正是进程描述符的作用——进程描述符都是task_struct数据结构,它的字段包含了与一个进程相关的所有信息。因为进程描述符存放了那么多的信
息,所以它是相当复杂滴。。。。不过别怕,我们解决主要矛盾,其他的部分就会迎刃而解,不攻自破的。下图就显示了Linux的进程描述符的主要矛盾:
本博,我们将集中讨论进程的基本信息、进程的状态以及进程间关系。其他的我们会在以后博文中涉及到。
1、进程的基本信息
1.1 标识一个进程——PID
每个进程都必须拥有它自己的进程描述符;因此,即使共享内核大部分数据结构的轻量级进程(后面会提到),也有它们自己的task_struct结构。进程
和进程描述符之间有非常严格的一一对应关系,所以我们可以方便地使用32位进程描述符地址标识进程。进程描述符指针(task_struct*)指向这些
地址。内核对进程的大部份引用都是通过进程描述符指针进行的。
另一方面,类Unix橾作系统允许用户使用一个叫做进程标识符processID
(PID)的数来标识进程,PID存放在task_struct的pid字段中。PID被顺序编号,新创建进程的PID通常是前一个进程的PID加1。不
过,PID的值有一个上限,当内核使用的PID达到这个峰值的时候,就必须开始循环使用已闲置的小PID号。在缺省情况下,最大的PID号是32767
系统管理员可以通过往/proc/sys/kernel/pid_max 这个文件中写入一个更小的值来减小PID的上限值,使PID的上限小于32767。在64位体系结构中,系统管理员可以把PID的上限扩大到4194304。
由于循环使用PID编号,内核必须通过管理一个pidmap_array位图来表示当前已分配的PID和闲置的PID号。因为一个页框包含32768个位
(4*1024*8),所以在32位体系结构中pidmap_array位图正好存放在一个单独的页中。系统会一直保存这些页而不释放的。
再告诉你一个知识,Linux只支持轻量级进程,不支持线程,但为了弥补这样的缺陷,Linux引入线程组的概念。一个线程组中的所有线程使用和该线程组
的领头线程相同的PID,也就是该组中第一个轻量级进程的PID,它被存入进程描述符的tgid字段中。getpid()系统调用返回当前进程的tgid
值而不是pid值,因此,一个多线程应用的所有线程共享相同的PID。绝大多数进程都属于一个线程组;而线程组的领头线程其tgid与pid的值相同,因
而getpid()系统调用对这类进程所起的作用和一般进程是一样的。
所以,我们得出一个重要的结论,Linux虽不支持线程,但是它有具备支持线程的操作系统的所有特性,后面讲解轻量级进程的概念中还会详细讨论。
1.2 进程描述符定位
进程是动态实体,其生命周期范围从几毫秒到几个月。因此,内核必须能够同时处理很多进程,并把对应的进程描述符存放在动态内存中,而不是放在永久分配给内核的内存区(3G之上的线性地址)。
那么,怎么找到被动态分配的进程描述符呢?我们需要在3G之上线性地址的内存区为每个进程设计一个块——thread_union。对每个进程来说,我们
需要给其分配两个页面,即8192个字节的块。Linux把两个不同数据结构紧凑地存放在一个单独为进程分配的存储区域内:一个是内核态的进程堆栈,另一
个是紧挨着进程描述符的小数据结构thread_info,叫做线程描述符。
考虑到效率问题,内核让这8k的空间占据连续两个页框并让第一个页框的起始地址是2^13的倍数。当几乎没有可用的动态内存空间时,就会很难找到这样的两
个连续页框,因为空闲空间可能存在大量的碎片(注意,这里是物理空间,见“伙伴系统算法”博文)。因此,在80x86体系结构中,在编译时可以进行设置,
以使内核栈和线程描述符跨越一个单独的页框(因为主要存在的单页的碎片)。在“Linux中的分段”的博文中我们已经知道,内核态的进程访问处于内核数据
段的栈,也就是我们Linux在3G以上内存空间为每个进程设计这么一个栈的目的,这个栈不同于用户态的进程所用的栈。因为内核控制路径使用很少的栈,因
此只需要几千个字节的内核态堆栈。所以,对栈和thread_info来说,8KB足够了。不过,如果只使用一个页框存放这两个结构的话,内核要采用一些
额外的栈以防止中断和异常的深度嵌套而引起的溢出(见“异常处理”博文)。
下图显示了在2页(8KB)内存区中存放两种数据结构的方式。线程描述符驻留于这个内存区的开始位置,而栈从末端向下增长。该图还显示了如何通过task字段与task_struct结构相互关联。
struct thread_info {
struct task_struct *task; /* main task structure */
struct exec_domain *exec_domain; /* execution domain */
unsigned long flags; /* low level flags */
unsigned long status; /* thread-synchronous flags */
__u32 cpu; /* current CPU */
__s32 preempt_count; /* 0 => preemptable, <0 => BUG */
mm_segment_t addr_limit; /* thread address space:
0-0xBFFFFFFF for user-thead
0-0xFFFFFFFF for kernel-thread
*/
struct restart_block restart_block;
unsigned long previous_esp; /* ESP of the previous stack in case
of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
};
esp为CPU栈指针寄存器,用来存放栈顶单元的地址。在80x86系统中,栈起始于末端,并朝这个内存区的起始方向增长。从用户态切换到内核态以后,进程的内核栈总是空的,因此,esp寄存器指向这个栈的顶端。
一旦数据写入堆栈,esp的值就递减。特别要注意,这里的数据是指内核数据,其实用得很少,所以大多数时候这个内核栈是空的。因为thread_info
结构是52个字节的长度,所以内核栈能扩展到8140个字节。C语言使用下列联合结构,方便地表示一个进程的线程描述符和内核栈:
union thread_union {
struct thread_info thread_info;
unsigned long stack[2048]; /* 1024 for 4KB stacks */
};
内核使用alloc_thread_info 和 free_thread_info宏分配和释放存储thread_info结构和内核栈的内存区。
1.3 标识当前进程
我们再从效率的观点来看,刚才所讲的thread_info结构与内核态堆栈之间的紧密结合提供的主要好处还在:内核很容易从esp寄存器的值获得当前在
CPU上正在运行进程的thread_info结构的地址。事实上,如果thread_union的长度是8K(213字节),则内核屏蔽掉esp的低
13位有效位就可以获得thread_info结构的基地址;而如果thread_union的长度是4K,内核需要蔽掉esp的低12位有效位。这项工
作由current_thread_info()函数来完成,它产生如下一些汇编指令:
movl $0xffffe000,%ecx /* or 0xfffff000 for 4KB stacks */
andl %esp,%ecx
movl %ecx,p
这三条指令执行后,p就是在执行指令的CPU上运行的当前进程的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结构中的偏移量为0,所以执行完这三条指令之后,p就是CPU上运行进程的描述符指针。
current宏经常作为进程描述符字段的前缀出现在内核代码中,例如,current->pid返回在CPU上正在执行CPU的进程的PID。
2、进程状态
2.1 进程链表
Linux内核把进程链表把所有进程的描述符链接起来。每个task_struct结构都包含一个list_head类型的tasks字段,这个类型的prev和next字段分别指向前面和后面的的task_struct元素。
进程链表的头是init_task描述符,它是所谓的0进程或swapper进程的进程描述符。init_task的tasks.prev字段指向链表中最后插入的进程描述符的tasks字段。
SET_LINKS 和 REMOVE_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; )
2.2 state字段
进程描述符task_struct结构的state字段描述了进程当前所处的状态。它由一组标志组成,其中每个标志描述一种可能的进程状态。在当前的Linux版本中,这些状态是互斥的,因此,严格意义上来说,只能设置一种状态,其余的标志位将被清除。下面是可能的状态:
可运行状态(TASK_RUNNING)
进程要么在CPU上执行,要么准备执行。
可中断的等待状态(TASK_INTERRUPTIBLE)
进程被挂起(睡眠),直到某个条件变为真。产生一个硬件中断、释放进程正在等待的系统资源、或传递一个信号都是可以唤醒进程的条件(把进程状态放回到TASK_RUNNING)。
不可中断的等待状态(TASK_UNINTERRUPTIBLE)
与可中断的等待状态类似,但有一个例外,把信号传递到该睡眠进程时,不能改变它的状态。这种状态很少用到,但在一些特定条件下(进程必须等待,直到一个不
能被中断的时事件发生),这种状态是很有用的。例如,当进程打开一个设备文件,其相应的设备驱动程序开始探测相应的硬件设备时会用到这种状态。探测完成以
前,设备驱动程序不能被中断,否则,硬件设备会处于不可预知的状态。
暂停状态(TASK_STOPPED)
进程的执行被暂停。当进程接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信号后,进人暂停状态。
跟踪状态(TASK_TRACED)
进程的执行已由debugger程序暂停。当一个进程被另一个进程监控时(例如debugger执行ptrace()系统调用监控一个测试程序)任何信号都可以把这个进程置于TASK_TRACED状态。
还有两个进程状态既可以存放在进程描述符的state字段啊中,也可以存放在exit_state中字段中。从这两个字段的名称可以看出,只有当进程的执行被终止时,进程的状态才会变成此两种中的一种:
僵死状态(EXIT_ZOMBIE)
进程的执行被终止,但是父进程还没发布wait4()或waitpid()系统调用来返回有关死亡进程的信息。发布wait()类系统调用前,内核不能丢弃包含在死进程描述符中的数据,因为父进程可能还需要它。
僵死撤销状态(EXIT_DEAD)
最终状态:由于父进程刚发出wait4()或waitpid()系统调用,因而进程由系统删除。为了防止其他执行线程在同一个进程上也执行wait()类
系统调用(这也是一种竞争条件),而把进程的状态由僵死(EXIT_ZOMBIE)状态改为僵死撤销状态(EXIT_DEAD)
state字段的值通常用一个简单的赋值语句设置,例如:
p->state = TASK_RUNNING;
内核也使用set_task_state和set_current_state宏:它们分别设置指定进程的状态和当前执行进程的状态。此外,这些宏确保编译程序或CPU控制单元不把赋值操作和其他指令混合。混合指令的顺序有时会导致灾难性的后果。
2.3 TASK_RUNNING状态的进程链表
当内核寻找到一个新进程在CPU上运行时,必须只考虑可运行进程(即处在TASK_RUNNING状态的进程)。
早先的Linux版本把所有的可运行进程都放在同一个叫做运行队列(runqueue)的链表中,由于维持链表中的进程优先级排序的开销过大,因此,早期的调度程序不得不为选择“最佳”可运行进程而扫描整个队列。
Linux 2.6实现的运行队列有所不同。其目的是让调度程序能在固定的时间内选出“最佳”可运行队列,与进程中可运行的进程数无关。我们仅在此提供一些基本信息,《进程调度》博文中会详细描述这种新的运行队列。
提高调度程序运行速度的诀窍是建立多个可运行进程链表,每种进程优先级对应一个不同的链表。每个task_struct描述符包含一个list_head
类型的字段run_list。如果进程的优先权等于k(其取值范围从0到139),run_list字段就把该进程的优先级链入优先级为k的可运行进程的
链表中。此外,在多处理器系统中,每个CPU都有它自己的运行队列,即它自己的进程链表集。这是一个通过使数据结构更复杂来改善性能的典型例子:调度程序
的操作效率的确更高了,但运行队列的链表却为此被拆分成140个不同的队列!
内核必须为系统中每个运行队列保存大量的数据,不过运行队列的主要数据结构还是组成运行队列的进程描述符链表,所有这些链表都由一个单独的prio_array_t数据结构来实现。
enqueue_task(p,array)函数把进程描述符(p参数)插入到某个运行队列的链表(基于prio_array_t结构的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字段是一个指针,指向当前运行队列的proo_array_t数据结构。类似地,dequeue_task(p,array)函数从运行队列的链表中删除一个进程的描述符。
3、进程间关系
3.1 父子兄弟关系
程序创建的进程具有父/子关系。如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。进程0和进程1是由内核创建的;进程1(init)是所有进程的祖先。
在进程描述符中引入几个字段来表示这些关系,我们假设拥有该task_struct结构的这个进程叫P:
real_parent——指向创建了P进程的描述符,如果进程P的父进程不存在,就指向进程1的描述符(因此,如果用户运行了一个后台进程而且退出了shell,后台进程就会变成init的子进程)。
parent——指向P的当前父进程(这种进程的子进程终止时,必须向父进程发信号)。它的值通常与reak_parent一致,但偶尔也可以不同,例如,当另一个进程发出监控P的ptrace系统调用请求时。
children——链表的头部,链表中所有的元素都是P创建的子进程。
sibling——指向兄弟进程链表中的下一个元素或前一个元素的指针,这些兄弟进程的父进程跟P是一样的。
下图显示了一组进程间的亲属关系,进程P0创建了P1,P2,P3,进程P3又创建了P4。
3.2 其他关系
此外,进程之间还存在其他关系:一个进程可能是一个进程组或登录会话的领头进程,也可能是一个线程组的领头进程,他还可能跟踪其他进程的执行,下面就列出进程描述符中的一些字段,这些字段建立起了进程P和其他进程之间的关系:
group_leader——P所在进程组的领头进程的描述符指针
signal->pgrp——P所在进程组的领头进程的PID
tgid——P所在线程组的领头进程的PID
signal->session——P的登录会话领头进程的PID
ptrace_children——链表的头,该链表包含所有被debugger程序跟踪的P的子进程
ptrace_list——指向所跟踪进程其实际父进程链表的前一个和下一个元素(用于P被跟踪的时候)
再来,内核必须能从进程的PID导出对应的进程描述符指针。例如,为kill()系统调用提供服务时就会发生这种情况:当进程P1希望向另一个进程P2发
送一个信号时,P1调用kill()系统调用,其参数为P2的PID,内核从这个PID导出其对应的进程描述符,然后从该task_struct中取出记
录挂起信号的数据结构指针。
那么如何得到这个task_struct呢?首先想到for_each_process(p)。不行,虽然顺序扫描进程链表并检查进程描述符的pid字段
是可行的,但相当低效。为了加速查找,Linux内核引入了4个散列表。需要4个散列表是因为进程描述符包含了表示不同类型PID的字段,而且每种类型的
PID需要它自己的散列表:
PIDTYPE_PID pid 进程的PID
PIDTYPE_TGID tgid 线程组领头进程的PID
PIDTYPE_PGID pgrp 进程组领头进程的PID
PIDTYPE_SID session 会话领头的PID
内核初始化期间动态地为4个散列表分配空间,并把它们的地址存入pid_hash数组。一个散列表的长度依赖于可用的RAM的容量,例如:一个系统拥有512MB的RAM,那么每个散列表就被存在4个页框中,可拥有2048个表项。
用pid_hashfn宏把PID转化为表索引:
#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。
正如计算机科学的基础课程所阐述的那样,散列函数并不总能确保PID与表的索引一一对应。两个不同的PID散列到相同的表索引称为冲突
(colliding)。Linux利用链表来处理冲突的PID:每个表项是由冲突的进程描述符组成的双向循环链表。具体方法我们就不展开了,有兴趣的同
志可以看看关于内核前期技术准备专题的相关博文,这里只把相关的数据结构用我们的老方法,图形的方式展现出来: