第三章 进程管理
3.1 进程
- 进程就是处于执行期的程序(目标码存放在某种存储介质上)。
- 执行线程,简称线程(thread), 是在进程中活动的对象。每个钱程都拥有一个独立的程序计数器、进程技和一组进程寄存器。内核调度的对象是线程,而不是进程。
- 在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。
- 程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。
- 通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用 exec。这组函数 就可以创建新的地址空间,并把新的程序载入其中。
- 最终,程序通过 exi的系统调用退出执行。
3.2 进程描述符及任务结构
- 内核把进程的列表存放在叫做任务队列(task list) 的双向循环链表中。链表中的每一 项都是类型为 task_struct、称为进程描述符(process descriptor)的结构,该结构定义在 <linux/ sched.h> 文件中。进程描述符中包含一个具体进程的所有信息。
3.2.1 分配进程描述符
- Linux 通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色的目的。
3.2.2 进程描述符的存放
- 内核通过一个唯一的进程标识值(process identification value)或PID 来标识每个进程。 PID 是 一个数,表示为 pid_t 隐含类型,实际上就是一个int类型。为了与老版本的Unix和Linux兼容, PID的最大值默认设置为 32768 (short int短整型的最大值),尽管这个值也可以增加到高达400万 (这受 <linux/也reads.h> 中所定义 PID 最大值的限制)。内核把每个进程的 PID 存放在它们各自的进程描述符中。
- 在内核中,访问任务通常需要获得指向其 task_struct 的指针。
3.2.3 进程状态
进程描述符中的 state 域描述了进程的当前状态(见图 3-3)。系统中的每个进程都必然处于隐含类型指数据类型的物理表示是未知的或不相关的,五种进程状态中的一种。该域的值也必为下列五种状态标志之- :
- TASK_RUNNING (运行)一一进程是可执行的:它或者正在执行,或者在运行队列中等待执行。
- TASK_INTERRUPTIBLE (可中断)一一进程正在睡眼(也就是说它被阻塞),等待某些条 件的达成。 一且这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会 因为接收到信号而提前被唤醒并随时准备投入运行。
- TASK_UNINTERRUPTffiLE (不可中断〉 一一除了就算是接收到信号也不会被唤醒或准备 投入运行外,这个状态与可打断状态相同。
- _TASK_TRACED-一被其他进程跟踪的进程
- _ TASK_STOPPED (停止〉一一进程停止执行:进程没有投入运行也不能投入运行.通常这种状态发生在接收到 SIGSTOP、 SIGTSTP、 SIGTTIN、 SIGTTOU 等信号的时候。此外, 在调试期间接收到任何信号,都会使进程进入这种状态。
3.2.4 设置当前进程状态
内核经常需要调整某个进程的状态。这时最好使用 set_task_state(task, state)函数:
set_task_state(task, state); /*将任务 task 的状态设置为 state */
该函数将指定的进程设置为指定的状态。
3.2.5 进程上下文
- 可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。
- 系统调用和异常处理程序是对内核明确定义的接口。 进程只有通过这些接口才能陷入内核执行一一对内核的所有访问都必须通过这些接口。
3.2.6 进程家族树
- Unix 系统的进程之间存在一个明显的继承关系,在Linux系统中也是如此。 所有的进程都是PID为1的init进程的后代。
- 系统中的每个进程必有一个父进程, 相应的,每个进程也可以拥有零个或多个子进程。拥 有同一个父进程的所有进程被称为兄弟。进程间的关系存放在进程描述符中。每个 task_struct 都包含一个指向其父进程tast_struct、叫做 parent 的指针,还包含一个称为 children 的子进程链表。
3.3 进程创建
Unix 的进程创建很特别。许多其他的操作系统都提供了产生(spawn)进程的机制,首先在 新的地址空间里创建进程,读入可执行文件,最后开始执行。 Unix 采用了与众不同的实现方式, 它把上述步骤分解到两个单独的函数中去执行: forkO 和 exec():
- 首先, fork()通过拷贝当前进 程创建一个子进程。子进程与父进程的区别仅仅在于 PID (每个进程唯一)、 PPID (父进程的进程号,子进程将其设置为被拷贝进程的 PID)和某些资源和统计量。
- exec()函数负责读取可执行文件并将其载入地址空间开始运行。把这两个函数组 合起来使用的效果跟其他系统使用的单一函数的效果相似。
3.3.1 写时拷贝
- 传统的 fork()系统调用直接把所有的资源复制给新创建的进程。 Linux的 fork()使用写时拷贝 (copy-on-write)页实现。 写 时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间, 而是让父进程和子进程共享同-个拷贝。
- 只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。
- fork()的实际开销就是复制父进程的页表以反给予进程创建唯一的进程描述符。
3.3.2 fork()
- Linux 通过 clone()系统调用实现 fork()。
- do_fork 完成了创建中的大部分工作,它的定义在 kemeVfork.c 文件中。该函数调用 copy_ process()函数,然后让进程开始运行。 copy_process()函数完成的工作很有意思:
- 调用 dup_task_ struct()为新进程创建一个内核枝、也read_info 结构和 task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的。
- 检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出绘色分配的资源的限制。
- 子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。
- 子进程的状态被设置为 TASK_UNJNTERRUPTIBLE,以保证它不会投入运行。
- copy _process()调用 copy_flags()以更新 task_struct 的组ags 成员。
- 调用 alloc _pid()为新进程分配一个有效的 PID。
- 根据传递给 clone()的参数标志, copy_process()拷贝或共享打开的文件、文件系统信息、 信号处理函数、进程地址空间和命名空间等。
- 最后, copy_process()傲扫尾工作并返回一个指向子进程的指针。
- 再回到 do_fork()函数,如果 copy_process()函数成功返回,新创建的子进程被唤醒并让其 投入运行。
3.3.3 vfork()
- 除了不拷贝父进程的页表项外, vfork()系统调用和 fork()的功能相同。
- vfork()系统调用的实现是通过向 clone()系统调用传递一个特殊标志来进行的。
- 在调用 copy_process()时, task_struct 的 vfor_done 成员被设置为 NULL。
- 在执行 do_fork()时,如果给定特别标志,则 vfork_done 会指向一个特定地址。
- 子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过 vfork_done 指针向它发送信号。
- 在调用 mm_release()时,该函数用于进程退出内存地址空间, 并且检查 vfork_done 是否为空,如果不为空,则会向父进程发送信号。
- 回到 do_fork(),父进程醒来并返回。
3.4 线程在Linux中的实现
线程机制是现代编程技术中常用的一种抽象概念. 该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。
Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。 Linux把所有的线程都当做进程来实现。
3.4.1 创建线程
- 线程的创建和普通进程的创建类似,只不过在调用 clone()的时候需要传递一些参数标志来 指明需要共享的资源:
clone(CLONE_VM | CLONE_FS | CLONE_FLIES | CLONE_SIGHAND,0);
3.4.2 内核编程
- 内核经常需要在后台执行一些操作。 这种任务可以通过内核线程(kernel thread)完成-一 独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为 NULL)。
- Linux 确实会把一些任务交给内核线程去傲,像 flush 和 ksofirqd 这些任务就是明显的例子。
- 内核钱程启动后就一直运行直到调用 do_exit()退出,或者内核的其他部分调用 kthread_ stop()退出,传递给 kthread_stop()的参数为 kthread_create()函数返回的 task_struct 结构的地址:
int kthread_stop(struct task_struct *k)
3.5 进程终结
- 不管进程是怎么终结的,该任务大部分都要靠 do_exit()(定义于 kemel/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()切换到新的进程 。 do_exit()永不返回。
- 至此,与进程相关联的所有资源都被释放掉了(假设该进程是这些资源的唯一使用者)。 进程不可运行(实际上也没有地址空间让它运行)并处于 EXIT_ZOMBIE 退出状态。
3.5.1 删除进程描述符
- 在调用了 do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。
- wait()这一族函数都是通过唯一(但是很复杂)的一个系统调用 wait4()来实现的
- 它调用_exit_signal(),该函数调用_unhash _process(),后者又调用 detach_pid()从pidhash 上删除该进程,同时也要从任务列表中删除该进程。
- _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
- 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么 release_task()就要通知僵死的领头进程的父进程。
- release_task()调用 put_task_ struct()释放进程内核技和也read_info 结构所占的页,并释放 tast_struct 所占的 slab 高速缓存。
- 至此,进程描述符和所有进程拙享的资源就全部释放掉了。
当最终需要释放进程描述符时, release_task()会被调用,用以完成以下工作:
3.5.2 孤儿进程造成的进退维谷
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成 为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存.前面的部分已经有所暗示,对 于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init它们的父进程。
一但系统为进程成功地找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。 init 进程会例行调用 wait()来检查其子进程,清除所有与其相关的僵死进程。
3.6 小结
在本章中,我们考察了操作系统中的核心概念一一进程。我们也讨论了进程的一般特性, 它为何如此重要,以及进程与线程之间的关系。然后,讨论了 Linux 如何存放和表示进程(用 task_ struct 和 thread_info),如何创建进程(通过 fork(),实际上最终是 clone()),如何把新的执 行映像装入到地址空间(通过 execO 系统调用族),如何表示进程的层次关系,父进程又是如何收集其后代的信息(通过 wait()系统调用族),以及进程最终如何消亡(强制或自愿地调用 exit())。 进程是一个非常基础、非常关键的抽象概念,位于每一种现代操作系统的核心位置,也是我们拥有操作系统(用来运行程序)的最终原因。