第三章 进程管理
3.1 进程
在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。虽然实际上可能是许多进程正在分享一个处理器,但虚拟处理器给进程一种假象,让这些进程觉得自己在独享处理器。第4章将详细描述这种虚拟机制。而虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的所有内存资源。第12章将描述虚拟内存机制。注意,在线程之间可以共享虚拟内存,但每个都拥有自己的虚拟处理器。
无疑,进程在创建的时刻开始存活。在Linux系统中,这通常是调用fork()的结果,该系统调用通过复制一个现有的进程来创建一个全新的进程。调用fork()的进程称为父进程,新产生的进程称为子进程。在调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。
通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()这组函数就可以创建新的地址空间,并把新的程序载入其中。
最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait4()系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到他的父进程调用wait()或waitpid()为止。
3.2 进程描述符及任务结构
内核把进程的列表存放在叫做任务队列的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息。
3.2.1 分配进程描述符
Linux通过slab分配器分配task_struct结构,task_struct就存在slab分配器分配出来的内存区中,这样能达到对象复用和缓存着色(参见12章)的目的。由于现在用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info(该结构定义在<asm/thread_info.h>中)。每个任务的thread_info结构体在它的内核栈的尾端分配,该结构体中有一个指向该进程的进程描述符的指针。
3.2.2 进程描述符的存放
内核通过一个唯一的进程标识值或PID来标识每个进程。PID是一个数,表示为pid_t隐含类型,实际上就是一个int类型。内核把每个进程的PID存放在他们各自的进程描述符中。PID的最大值默认设置为32768。这个最大值很重要,因为它实际上就是系统中允许同时存在的进程的最大数目。如果确实需要的话,可以不考虑和老式系统的兼容,由系统管理员通过修改/proc/sys/kernel/pid_max来提高上限。
在内核中,访问任务通常通常需要获得指向其task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。硬件体系结构不同,该宏的实现也不同。有的硬件体系结构可以拿出专门的寄存器来存放指向当前进程task_struct的指针,用于加快访问速度。而有些像x86这样的体系结构(其寄存器并不富裕),就只能在内核栈的尾端创建thread_info结构,通过计算偏移间接的查找task_struct结构。
在x86系统上,current把栈指针的后13个有效位屏蔽掉,用来计算出thread_info的偏移。该操作是通过current_thread_info()函数来完成的。
3.2.3 进程状态
进程描述符中的state域描述了进程的当前状态。
- TASK_RUNNING(运行)——进程或者正在执行,或者在运行队列中处于等待状态。
- TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。出于此状态的进程也会因为接收到信号而提前被唤醒,并随时准备投入运行。
- TASK_UNINTERRUPTIBLE(不可中断)——除了就算接收到信号也不会被唤醒或准备投入运行外,这个状态与可中断状态相同。
- __TASK_TRACED——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。
- __TASK_STOPPED——进程停止执行;进程没有投入运行也不能投入运行。
3.2.4 设置当前进程状态
内核经常需要调整某个进程的状态。这时最好使用set_task_state(task,state)函数:
set_task_state(task,state); /*将任务task的状态设置为state*/
该函数将指定的进程设置为指定的状态。
set_current_state(state)和set_task_state(task,state)含义是相同的。参看<linux/sched.h>中对这些相关函数实现的说明。
3.2.5 进程上下文
可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序执行了系统调用(参见第5章)或者触发了某个异常,它就陷入了内核空间。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。
3.2.6 进程家族树
所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本,并执行其他的相关程序,最终完成系统启动的整个过程。
系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有零个或多个子进程。进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程task_struct、叫做parent的指针,还包含一个称为children的子进程链表。
init进程的进程描述符是作为init_task静态分配的。
任务队列本来就是一个双向的循环链表,可以通过任务队列遍历系统中所有进程。
实际上,for_each_process(task)宏提供了依次访问整个任务队列的能力。每次访问,任务指针都指向链表中的下一个元素。
3.3 进程创建
首先,fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程唯一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如挂起的信号,它没有必要被继承)。exec()函数负责读取可执行文件并将其载入地址空间开始运行。
3.3.1 写时拷贝
Linux的fork()使用写时拷贝页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读的方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。
3.3.2 fork()
fork()———>clone()———>do_fork()————>copy_process()
do_fork()完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后让进程开始运行。cop_process()函数完成的工作很有意思:
- 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程与父进程的进程描述符完全相同。
- 检查并确保新创建这个子进程后,当前用户所拥有的所有进程数目没有超出给他分配的资源限制。
- 子进程着手使自己与父进程区别开来。进程描述符中的许多成员都要被清零或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息。task_struct中的大多数数据都依然未被修改。
- 子进程的状态被设置为TASK_UNINTERRUPTIBLE,保证它不会投入运行。
- copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERRPERIV标志被清零。表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
- 调用alloc_pid()为新进程分配一个有效的PID。
- 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件夹、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
- 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。
3.3.3 vfork()
除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。
在执行fork()时引入了写时拷贝并且明确了子进程先执行。
3.4 线程在Linux中的实现
线程机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术。
Linux实现线程的机制非常独特,从内核的角度来说,他并没有线程这个概念。Linux把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,他看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。
3.4.1 创建线程
线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:
clone( CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0 ) ;
上面的代码产生的结果和调用fork()差不多,只是父子两共享地址空间、文件系统资源、文件描述符和信号处理程序。换个说法就是,新建的进程和它的父进程就是流行的所谓线程。
对比一下,一个普通的fork()的实现是:
clone( SIGCHLD, 0 ) ;
传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。这些是在<linux/sched.h>中定义的。
3.4.2 用户线程与内核线程
用户线程(ULT):指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统的核心,应用进程利用线程库创建、同步、调度和管理线程函数来控制用户线程。另外,因为用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心,不需要进行用户态与核心态的切换,速度快,操作系统的内核不知道多线程的存在,因此一个线程阻塞将使得整个进程包括其所有线程阻塞。
内核线程(KLT):线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度进程的其他线程执行,在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但仍比进程创建和管理操作快的多。
即内核线程的每个线程都会映射到操作系统的内核,由内核创建一个线程栈(线程表),之后才能有机会调度CPU。而用户线程的线程栈不会维护在内核中,内核并不知道这个进程中创建了哪些线程,内核只会维护一个进程表,分配CPU去执行进程。
内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成——独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。它们只在内核空间运行,从不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。
实际上,内核线程也只能由其他内核线程创建。内核是通过从kthreadd内核进程中衍生出所有新的内核线程来自动处理这一点的。在<Linux/kthread.h>中申明有接口,于是,从现有内核线程中创建一个新的内核线程的方法如下:
新的任务是由kthread内核进程通过clone()系统调用而创建的。新的进程将运行threadfn函数,给其传递的参数为data。进程会被命名为namefmt,namefmt接受可变参数列表类似于printf()的格式化参数。新创建的进程处于不可运行状态,如果不通过调用wake_up_process()明确的唤醒它,它不会主动运行。创建一个进程并让它运行起来,可以通过调用kthread_run()来达到:
这个函数是以宏实现的,只是简单的调用了kthread_create()和wake_up_process()
内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出,传递给kthread_stop()的参数为kthread_create()函数返回的task_struct结构的地址。
3.5 进程终结
当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知其父进程。
一般来说,进程的析构是自身引起的。它发生在进程调用exit()系统调用时,既可能显式地调用这个系统调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠 do_exit()(定义于kernel/exit.c)来完成,它要做下面这些烦琐的工作:
- 将tast_struct中的标志成员设置为PF_EXITING。
- 调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
- 如果BSD的进程记账功能是开启的,do_exit()调用acct_update_integrals()来输出记账信息。
- 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。
- 接下来调用sem_exit()函数。如果进程排队等候IPC信号,它则离开队列。
- 调用exit_files()和exit_fs(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
- 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
- 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并把进程状态(存放在task_struct 结构的exit_state中)设成EXIT_ZOMBIE。
- do_exit()调用schedule()切换到新的进程(参看第4章)。因为处于EXITZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码。do_exit()永不返回。
至此,与进程相关联的所有资源都被释放掉了(假设该进程是这些资源的唯一使用者)。进程不可运行(实际上也没有地址空间让它运行)并处于EXIT_ZOMBIE退出状态。它占用的所有内存就是内核栈、thread_info 结构和tast_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检素到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存被释放,归还给系统使用。
3.5.1 删除进程描述符
在调用了do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task struct 结构才被释放。
wait()这一族函数都是通过唯一(但是很复杂)的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。
当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:
- 它调用__exit_signal(),该函数调用_unhash_process(),后者又调用detach_pid()从pidhash上删除该进程,同时也要从任务列表中删除该进程。
- _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
- 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。
- release_task() 调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放tast_struct所占的slab高速缓存。
至此,进程描述符和所有进程独享的资源就全部释放掉了。
3.5.2 孤儿进程造成的进退维谷
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。在do_exit()中会调用exit_notify(),该函数会调用forget_original_parent(),而后者会调用find_new_reaper()来执行寻父过程。给第一个子进程找到合适的养父进程后,只需要遍历所有的子进程并为他们设置新的父进程。
init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。