前几天就把文章写了大半部分,可惜今天一打开,发现全部没有了 。伤心之余,还是把它重新整理了一遍,不容易啊
……话不多说,马上步入正题,欢迎各位拍砖哈,谢谢~~~
本书前面两章内容:“Linux内核介绍”以及“对内核源代码的获取和编译”这里就不多说了,相信网上也有很多这一类的文章,直接进入第三章“进程管理”!
一、纵观大局
这也许是一个老生常谈的问题了吧,哈哈!在操作系统课程的学习中,我最深的体会就是“抽象”。进程提供了虚拟处理器和虚拟内存的抽象。何谓进程?简而言之,一个执行中的程序!当然,这只是其中之一。它还包含了正在执行的程序段以及涉及的各种资源(比如文件、信号、内核数据结构、进程状态、地址空间)。线程,作为进程的执行者,存在于进程之中。内核必须对这一切进行管理。在Linux中,进程和线程被同等对待,线程可以看作一种特殊的进程(所谓的“轻量级进程”也许就是这个意思吧!)。
关于这一点在下文的分析中即可得到证明。
用户推进的任务被OS映射为进程,进程作为资源分配的基本单位,而处理机调度时以线程为单位,这一切的顺利进行需要系统与进程的交互,这其中就涉及到系统对进程的管理:进程该什么时候产生?如何产生?另一方面,系统资源是有限的,一个进程的退出,它所占据的资源必须回收重利用,那这些资源什么时候才能回收……一系列的问题……
二、穿针引线
A、数据结构
进程描述符 task_struct
线程信息 thread_info
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 */
int preempt_count; /* 0 => preemptable, <0 => BUG */
mm_segment_t addr_limit; /* thread address space:
0-0xBFFFFFFF for user-thead
0-0xFFFFFFFF for kernel-thread
*/
void *sysenter_return;
struct restart_block restart_block;
unsigned long previous_esp; /* ESP of the previous stack in case
of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
};
注意,该结构体的第一个成员为tast_struct类型的指针!
在Linux中,内核以双向循环链表为数据结构存储所有进程,也称为“任务列表”,链表结点称为“进程描述符”----- 这就是上面提到的task_struct。也就是说,一个进程描述符表示了其相对应的进程的所有信息,可想而知,tast_struct必然是一个庞大的结构体!(总共300多行,其定义存在于linux.x.y.z / include / linux / sched.h文件中,所以上面没有列出其定义)。
系统的所有资源都是通过某一种机制进行分配的。对于每一种机制都将涉及分配者、分配策略、分配的对象等问题。
进程描述符由slab内存分配器动态分配。在Linux中,task_struct存储于每个进程所对应的内核栈的尾部。每个任务的thread_info结构在它的内核栈的尾端分配,而thread_info结构中task域中存放的是指向该任务实际task_struct的指针。如下图:
这种设计使得“获取结构体task_struct内容”的操作变得简单且无需额外的寄存器存储的支持,仅需由栈指针通过一定的操作便可得到。(这个操作是什么呢?下文为你分解!)。
至此,我们可以得出结论:进程描述符代表了相应进程的所有信息,故内核与进程的交互必然是通过进程描述符进行的!
事实正是如此。系统使用了current宏查找当前正在执行的任务:它利用task_struct与thread_info在内核栈中的位置关系,通过把栈指针的后13个有效位屏蔽掉,计算出thread_info的偏移。thread_info的位置得到了,再通过其task域得到相对应的task_struct,从而实现系统与进程交互的初衷!
B、进程与线程的创建
在Linux中,进程的创建通过fork系统调用产生。在用户层面,我们知道,fork调用一次,返回两次:父子进程各一次,子进程返回值为0,父进程返回值为子进程的PID。然而在内核层面,这一过程的实现却不是这么简单的!其中它涉及到一个重要的技术:写时拷贝(copy on write,简称COW)。
fork的实现如下列过程:
fork( ) ---> clone( ) ---->do_fork( ) --->copy_process( ),而copy_process( ) 完成的工作如下:
(1) 调用dup_task_struct( )为新进程创建内核栈、thread_info结构以及task_struct结构,此时这些结构所有的值都跟父进程的值一样!
(2) 检查新创建的这个子进程后,确保当前用户所拥有的进程数目没有超出给他分配的资源的限制。
(3) 更新进程描述符内的某些成员的值,使得子进程与父进程区别开来。
(4) 置TASK_UNINTERRUPTIBLE标志确保新进程不被运行。
(5) 调用copy_flags( )更新进程描述符的flags成员,清除PF_SUPERPRIV标志(标志进程是否拥有超级用户权限),设置 PF_FORKNOEXEC标志(标志进程还没有调用
exec)。
(6) 调用alloc_pid( )为新进程申请PID。
(7) 根据传递进来的参数标志的值(标志由clone( )传递给do_fork( ),然后传递给copy_process( )),以决定拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址
空间和命名空间等。在 一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
(8) 扫尾工作,返回指向子进程的指针
再回到do_fork( )函数:如果copy_process( )函数成功返回,新创建的子进程被唤醒并让其投入运行。
内核有意选择子进程首先执行可以避免写时拷贝的额外开销。因为一般子进程都会马上调用exec( )函数,如果父进程首先执行的话,它有可能会开始向新地址空间写入,这时候便产生了写时复制的开销。至此,进程的创建过程结束!
至于线程的创建,它同样通过clone系统调用实现,只不过传递给clone的标志与创建进程时相比不一样。
前面已说过,在Linux中,进程和线程被同等看待。基于这一理念,线程就是一种特殊的进程,一种与其它进程共享某些资源的进程。当然,每一个线程也对应一个task_struct结构,这样内核才能像管理进程一样对它进行管理,从而实现进程与线程的一致性!当然,对于其它平台,如Microsoft Windows或是Sun Solaris等OS,它们都显性提供了对线程的支持,自然而然,进程与线程就被区分开来。For Example ,假如我们有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程的指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建四个进程并分配四个普通的task_sturct结构,然后在建立这四个进程时指定它们共享某些资源(通过传递给clone的标志的值来指定)。
C、进程的执行
程序的执行总的来说,可以分为两个阶段:进程创建、程序执行。
第一阶段:进程创建
进程创建成功后,就为下一阶段的进行提供了基础:地址空间、资源
第二阶段:
在进程开辟成功后,系统读取要运行的可执行程序文件头信息,根据这些信息加载要运行的程序文本段到进程的地址空间并运行。
PS:当然,这里所说的“进程的执行”只是一个很大概的过程,具体可参考相关书籍(Windows平台下见《Windows核心编程》一书)。
D、进程的终止
万物有生有灭!进程执行完它的任务后,也就是宣告其生命周期结束的时候了。作为一般性,进程调用exit 标志着进程的结束(不管主动调用还是被动调用),大部分的终止工作都由do_exit 来完成:
(1) 设置task_struct结构的flags成员的值为PF_EXITING。
(2) 调用del_timer_sync( )移除内核定时器。
(3) 如果BSD的进程计账功能是开启的,do_exit( )调用acct_process( )来输出计账信息。
(4) 调用_exit_mm( )函数放弃进程占用的mm_struct,如果没有别的进程使用它们(也就是说,它们没被共享),就彻底释放它们。
(5) 调用exit_sem( )函数。如果进程排队等候IPC信号,它则离开队列。
(6) 调用_exit_files( )、_exit_fs( )、exit_namespace( )和exit_sighand( ),以分别递减文件描述符、文件系统数据,进程名字空间和信号处理函数的引用计数。如果其中某些引
用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
(7) 把存放在task_struct的exit_code成员中的任务退出代码置为exit( )提供的代码中,或者去完成任何其他由内核机制规定的退出动
作。退出代码存放在这里供父进程随时检索。
(8) 调用exit_notify( )向父进程发送信号,将子进程的父进程重新设置为线程组中的其他线程或init进程,并把进程状态设成
TASK_ZOMBIE。
(8) 调用schedule( )切换到其他进程(参看后续章节)。因为处于TASK_ZOMBIE状态的进程不会再被调度,所以这是进程所执行的
最后一段代码。
到此,如果进程是某些资源的唯一使用者,那么这些资源在进程终止时都得到释放。当然,此时进程已然不可运行(实际上也没有地址空间让它运行)并处于TASK_ZOMBIE状态。唯一剩下的就是进程所占据的内核栈、thread_info结构以及task_struct结构。也就是说,此时终止后的进程所占据的资源仅仅只有这三者了。可见,进程的终止与进程描述符的回收是分离的,如此设计是为了让父进程或系统可以获取终止进程的相关信息!
通常来说,进程的终止信息获取会在wait()这一族函数中进行。wait()这一族函数都是通过惟一(但是很复杂)的一个系统调用wait4()实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。
在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:
(1) 调用free_uid()来减少该进程拥有者的进程使用计数。Linux用一个单用户高速缓存统计和记录每个用户占用的进程数目、文
件数目。如果这些数目都将为0,表明这个用户没有使用任何进程和文件,那么这块缓存就可以销毁了。
(2) 调用unhash_process()从pidhash上删除该进程,同时也要从task_list中删除该进程。
(3) 如果这个进程正在被ptrace跟踪,release_task()将跟踪进程的父进程重设为其最初的父进程并将它从ptract list上删除。
(4) 最后,调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。
对于孤儿进程来说,进程终止的顺利进行需要作这样一个前提假设:孤儿进程已重新找到自己的父进程,否则孤儿进程在退出时会永远处于僵死状态而白白耗费内存。一般来说,会给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。合适的父进程也经找到了,只需要遍历所有子进程并为它们设置新的父进程。
一旦系统给进程成功地找到并设置了新的父进程,就不会再有出现驻留僵死进程的现象。init进程会例行调用wait()来等待其子进
程,并清除所有与其相关的僵死进程。
这里顺便说一下,对于被跟踪的进程,在以前的Linux内核版本中,这些进程会被设置成调试进程的子进程。此时如果该进程的父进
程退出了,那么当调试进程退出时,这些子进程就成了孤儿进程,于是必须为这些子进程及其兄弟进程重新定位它们的父进程。从
前面进程的组织方式可以得出结论:系统将通过遍历内部所有的进程来寻找这些子进程,这样的遍历开销是很大的,因而在新的
Linux内核版本中,通过多出一个链表,称为ptrace子进程链表来跟踪这些子进程,以此来减少遍历所带来的开销。
三、蓦然回首
本文讨论了进程管理的内核实现的相关内幕,先由进程的概念引入,然后展现进程的相关数据结构以及它们之间的联系,接着浏览了进程的整个生命周期(创建-----执行----终止)。我们可领会到Linux设计的一些哲学理念,不妨思考以下几个问题:
在Linux中,进程与线程有什么区别吗?
进程描述符task_struct的分配有什么特点,其设计有什么优势?
子进程在创建后为什么先运行?
ptrace子进程链表的背后思想是什么?
好了,今天就写到这里……(待续)~~