分类: LINUX
2011-11-01 15:16:50
进程是Unix操作系统最基本的抽象之一。一个进程就是处于执行期的程序(目标码存放在某种存储介质上)。但进程并不仅仅局限于一段可执行程序代码(Unix称其为代码段(text section))。通常进程还要包含其他资源,像打开的文件、挂起的信号、内核内部数据、处理器状态、地址空间及一个或多个执行线程(thread of execution)、当然还包括用来存放全局变量的数据段等。 实际上,进程就是正在执行的程序代码的活标本。
执行线程,简称线程(thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。Linux系统的线程实现非常特别—它对线程和进程并不特别区分。对Linux而言,线程只不过是一种特殊的进程罢了。
程序本身并不是进程;进程是处于执行期的程序以及它所包含的资源的总称。实际上完全可能存在两个或多个不同的进程执行的是同一个程序。并且两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。
在Linux系统中,这通常是调用fork()系统调用的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程被称为父进程,新产生的进程被称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork() 系统调用从内核返回两次:一次回到父进程,另一此回到新诞生的子进程。
通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec*()这族函数就可以创建新的地址空间,并把新的程序载入。在现代Linux内核中,fork()实际上是由clone()系统调用实现的,后者将在后面讨论。
最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait4()系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()或waitpid()为止
1.进程描述符及任务结构
内核把进程存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在
2.进程状态
进程描述符中的state域描述了进程的当前状态(参见图3-3)。系统中的每个进程都必然处于五种进程状态中的一种。该域的值也必为下列五种状态标志之一:
TASK_RUNNING(运行)—进程是可执行的;它或者正在执行,或者在运行队列中等待执行(运行队列将会在第4章中讨论)。这是进程在用户空间中执行惟一可能的状态;也可以应用到内核空间中正在执行的进程.
TASK_INTERRUPTIBLE(可中断)—进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并投入运行。
TASK_UNINTERRUPTIBLE(不可中断)—除了不会因为接收到信号而被唤醒从而投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不作响应,所以较之可中断状态,使用得较少。
TASK_ZOMBIE(僵死)—该进程已经结束了,但是其父进程还没有调用wait4()系统调用。为了父进程能够获知它的消息,子进程的进程描述符仍然被保留着。一旦父进程调用了wait4(),进程描述符就会被释放。
TASK_STOPPED(停止)—进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。
3.进程上下文
可执行程序代码是进程的重要组成部分。这些代码从可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序调执行了系统调用(参见第5章)或者触发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复在用户空间继续执行。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行—对内核的所有访问都必须通过这些接口。
4.写时拷贝
传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下—举例来说,fork()后立即调用exec()—它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。
5.线程在Linux中的实现
Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有惟一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程(只是该进程和其他一些进程共享某些资源,如地址空间)。
6.内核线程
内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成—独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。
7.进程终结
一般说来,进程的析构发生在它调用exit()之后,既可能显式地调用这个系统调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠do_exit()来完成。
在调用了do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则的话这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的耗费内存。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。。在do_exit()中会调用notify_parent(),该函数会通过forget_original_parent()来执行寻父过程:
这段代码将reaper设置为该进程所在的线程组内的其他进程。如果线程组内没有其他的进程,它就将reaper设置为child_reaper,也就是init进程。现在,合适的父进程也经找到了,只需要遍历所有子进程并为它们设置新的父进程:
这段代码遍历了两个链表:子进程链表和ptrace子进程链表,给每个子进程设置新的父进程。这两个链表同时存在的原因很有意思,它也是2.6内核的一个新特性。当一个进程被跟踪时,它被暂时设定为调试进程的子进程。此时如果它的父进程退出了,系统会为它和它的所有兄弟重新找一个父进程。在以前的内核中,这就需要遍历系统所有的进程来找这些子进程。现在的解决办法是在一个单独的被ptrace跟踪的子进程链表中搜索相关的兄弟进程—用两个相关链表减轻了遍历带来的消耗。
8.进程小结
在本章中,我们考察了操作系统中闻名遐尔的概念—进程。我们还讨论了进程的一般特性,它为何如此重要,以及进程与线程之间的关系。然后,讨论了Linux如何存放和表示进程(用task_struct 和 thread_info),如何创建进程(通过clone() 和 fork()),如何把新的执行映像装入到地址空间(通过exec()系统调用族),如何表示进程的层次关系,父进程又是如何收集其后代的信息(通过wait()系统调用族),以及进程最终如何死亡(强制或自愿地调用exit())。
进程是最基本、最重要的一种抽象,位于每个现代操作系统的核心位置,也是最终导致我们拥有操作系统的根源(通过执行程序)。