进程管理

3/3/2017来源:C/C++教程人气:2480

一、进程

进程就是处于执行期的程序(目标码存放在某种存储介质上)。 进程并不仅仅局限于一段可执行程序代码(Unix称其为代码段,text section)。还包含其他资源,如打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程,还包括用来存放全局变量的数据段等。进程就是正在执行的程序代码的实时结果。内核需要有效而又透明地管理所有细节。

执行线程(简称线程thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。在传统的Unix系统中,一个进程只包含一个线程,但现在的系统中,包含多个线程的多线程程序司空见惯。 linux系统的线程实现非常特别:它对线程和进程并不特别区分。对Linux而言,线程是一种特殊的进程。

进程的调度单位是线程

一个进程至少打开了三个文件:标准输入、标准输出、错误输出。

程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。实际上,完全可能存在两个或多个不同的进程执行的是同一个程序。并且两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。

进程的另一个名字是任务(task)。Linux内核通常把进程也叫做任务。后面会交替使用这两个术语。

在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。 虚拟处理器给进程一种假象,让共享一个处理器的进程觉得自己在独享处理器。虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的所有内存资源。

进程在创建它的时刻开始存活。在Linux系统中,这通常是fork()系统调用的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程称为父进程,新产生的进程称为子进程。 在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。

通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()这组函数就可以创建新的地址空间,并把新的程序载入其中。在现代Linux内核中,fork()实际上是由clone()系统调用实现的。 最终,程序通过exit()系统调用退出执行。该函数会终结进程并将其占用的资源释放掉。父进程可以通过wait4()系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()或waitpid()为止。

fork() 调用一次,返回两次(分别向父、子进程写PID) exec() 子进程丢弃原来的地址空间,重新建立地址段和数据段 exit() 在子进程中,释放资源 wait() 查询子进程是否终结

二、进程描述符及任务结构

内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为taske_struct称为进程描述符(PRocess descriptor)的结构,进程描述符中包含一个具体进程的所有信息。 task_struct相对较大,在32位机器上,它大约有1.7KB。但如果考虑到该结构内包含了内核管理一个进程所需的所有信息,那么它的大小也算相当小了。进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。

分配进程描述符 Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的。 由于用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个 新的结构strut thread_info。 每个任务的thread_info结构在它的内核栈的尾端分配。其中task域存放的是指向该任务实际task_struct的指针。

进程描述符的存放 内核通过一个唯一的进程标识值(process identifiation value)或PID来标识每个进程。PID是一个数,表示为pid t隐含类型。实际上就是一个int类型。为了与老版本的Unix和Linux兼容,PID的最大值默认设置为32768(short int短整型的最大值),尽管这个值也可以增加到高达400万。内核把每个进程的PID存放在它们各自的进程描述符中。

这个最大值很重要,因为它实际上就是系统中允许同时存在的进程的最大数目。尽管32768对于一般的桌面系统足够用了,但是大型服务器可能需要更多进程。如果确实需要的话,可以不考虑与老式系统的兼容性,由系统管理员通过修改/proc/sys/kernel/pid_max来提高上限。

在内核中,访问任务通常需要获得指向其task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。 硬件体系结构不同,该宏的实现也不同,它必须针对专门的硬件体系结构做处理。有的硬件体系结构可以拿出一个专门寄存器来存放指向当前进程task_struct的指针,用于加快访问速度。而有些像x86这样的体系结构(其寄存器并不富余),就只能在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。

三、进程状态

进程描述符中的state域描述了进程的当前状态。系统中的每个进程都必然处于五种进程状态中的一种。 1. TASK_RUNNING(运行)——进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程。 2. TASK_INTERRUPTIBLE(可中断)——进程正在睡眠,等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时淮备投入运行。

3. TASK_UNINTERRUTIBLE(不可中断)——除了就算是接收到信号也不会被唤醒或谁备投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不做响应,所以较之可中断状态,使用得较少。 4. TASK_TRACED——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。 5. TASK_STOPPED(停止)——进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。

设置当前进程状态

内核经常需要调整某个进程的状态。使用set_task_state(task, state)函数设置进程为指定的状态。 进程上下文 可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载人到进程的地址空间执行。一般程序在用户空间执行。当一个程序调执行了系统调用或触发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。

四、进程家族树

Linux系统的进程之间存在一个明显的继承关系,在Linux系统中也是如此。所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。 系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有零个或多个子进程。拥有同一个父进程的所有进程被称为兄弟。进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程task_struct、叫做parent的指针,还包含一个称为children的子进程链表。

父进程若死去、则子进程寄养在其他进程处。1号进程由系统初始化而成。

对于当前进程,可以通过下面的代码获得其父进程的进程描述符 struct task_struct *my_parent = current->parent 也可以按以下方式依次访问子进程 struct task_struct *task; struct list_head *list; list_for_each(list, &current->children) task = list_entry(list, struct task_struct, sibling);

五、进程创建

许多其他的操作系统都提供了产生进程的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。 Unix采用了与众不同的实现方式,它把上述步骤分解到两个单独的函数中去执行:fork()和exec()。 首先,fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程唯一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量。exec()函数负责读取可执行文件井将其载入地址空间开始运行。把这两个函数组合起来使用的效果跟其他系统使用的单一函数的效果相似。

写时拷贝

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立 即执行一个新的映像,那么所有的拷贝都将前功尽弃。 写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。

只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会被写人的情况下(举例来说,fork()后立即调用exec())它们就无须复制了。 fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。 Linux的fork()使用写时拷贝页实现。

fork()->clone()->do_fork()->copy_process() 1. 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct。这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的。 2. 检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出给它分配的资源的限制。 3. 子进程着手使自己与父进程区别开来。进程描述符内的非继承成员,主要是统计信息都要被清0或设为初始值。task_struct中的大多数数据都依然未被修改。 4. 子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。 5. copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0,表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。 6. 调用alloc_pid()为新进程分配一个有效的PID。 7. 根据传递给clone()的参数标志,copy_process拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。 8. 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。

再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

六、线程在Linux中的实现

线程机制是现代编程技术中常用的一种抽象概念。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术(concurrent programming),在多处理器系统上,它也能保证真正的并行处理(parallelism)。 Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。 在其他的系统中,相较于重量级的进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元。而对于Linux来说,线程只是一种进程间共享资源的手段(Linux的进程本身就够轻量级了)。 线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源 clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND,0); 父子进程共享地址空间、文件系统资源、文件描述符和信号处理程序。 普通的fork()实现是 clone(SIGCHLD,0);

传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。

七、内核线程

内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成——独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。 Linux会把一些任务交给内核线程去做,像flush和ksofirqd这些任务就是明显的例子。内核线程也只能由其他内核线程(kthreadd)创建。 通过kthread_create()函数从现有内核线程中创建一个新的内核线程,然后调用wake_up_process()函数唤醒它。kthread_run()将二者合二为一。 内核线程启动后就一直达行直到调用do_exit()退出,或者内核的其他部分调用kthead_stop()退出。

八、进程终结

当一个进程终结时,内核必须释放它所占有的资源并通知其父进程。 一般来说,进程的析构是自身引起的。它发生在进程调用exit()系统调用时,既可能显式调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或异常时,也可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠do_exit()完成。

do_exit() 1. 将task_struct中的标志成员设置为PF_EXITING 2. 调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。 3. 如果BSD的进程记账功能是开启的,do_exit()调用acct_update_integrals()输出记账信息。 4. 调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。 5. 调用sem_exit()函数。如果进程排队等候IPC信号,它则离开队列。 6. 调用exit_files()和exit_fs(),分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。 7. 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。 8. 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并把进程状态(存放在task_struct结构的exit_state中)设成EXIT_ZOMBIE。 9. do_exit()调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码。do_exit()永不返回。

至此,与进程相关联的所有资源都被释放掉了(假设该进程是这些资源的唯一使用者)。进程不可运行(实际上也没有地址空间让它运行)并处于EXIT_ZOMBIE退出状态。它占用的所有内存就是内核栈、thread_info结构和task_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存披释放,归还给系统使用。

删除进程描述符 wait()这一族函数都是通过唯一的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。当最终需要释放进程描述符时,relaese_task()会被调用。

wait()->wait4()->release_task() 1. 调用__exit_signal(),该函数调用__unhash_process(),后者又调用detach_pid()从pidhash上删除该进程,同时也要从任务列表中删除该进程。 2. _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。 3. 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。 4. release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。

孤儿进程 如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。对于这个问题,它们的父进程解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。 一旦系统为进程成功地找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。