Linux内核设计与实现(一)——内核进程管理


前言

进程是Unix操作系统抽象概念中最基本的一种。其中涉及进程的定义以及相关的概念,比如线程;然后讨论Linux内核如何管理每个进程:它们在内核中如何被列举,如何创建,最终如何消亡。我们拥有操作系统就是为了运行用户程序,因此,进程管理就是操作系统的心脏所在,Linux也不例外。

进程

进程就是处于执行期的程序(目标代码存放在某种存储介质上)。但进程并不仅仅局限于一段可执行的代码(Unix称其未代码段,text section)。通常进程还要包含其他资源,像打开的文件,挂起的信号,内核内核数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程(thread of execution),当然还包括用来存放全局变量的数据段等。实际上,进程就是正在执行的程序代码的实时结果。内核需要有效而又透明地管理所有细节。
执行线程,简称线程(thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。在传统的Unix系统中,一个进程只会包含一个线程,但现在的系统中,包含多个线程的多线程程序司空见惯。稍后你会看到,Linux系统的线程实现非常特别:他对线程和进程并不特别区分。对Linux而言,线程只不过是一种特殊的进程罢了。
在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。虽然实际上不可能是许多进程正在分享一个处理器,但虚拟处理器给进程一种假象,让这些进程觉得自己在独享处理器。后面会描述这种虚拟机制。有趣的是·,注意在线程(这里指包含在同一个进程中的线程)之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。
程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。实际上,完全可能存在两个或多个不同的进程执行的是同一个程序。并且两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。
无疑,进程在创建它的时刻开始存活。在Linux系统中,这通常是调用fork()系统的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程称之为父进程,新产生的进程称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。
通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()这组函数就可以创建新的地址空间,并把新的程序载入其中。在现在Linux内核中,fork()实际上是由clone()系统调用实现的,后者将在后面博客讨论。
最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait4()(由内核负责实现wait4()系统调用,Linux系统通过C库通常需要提供wait()、waitpid()、wait3()和wait()4函数。虽然有些细微的语义差别,但所有函数都返回关于进程的状态)系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()或waitpid()为止。
注意:进程的另一个名字是任务(task)。Linux内核通常把进程也叫任务

进程描述符及任务结构

内核把进程的列表存放在叫做任务队列(task list——有些介绍操作系统的教材称这为任务数组task array。由于Linux实现时使用的是队列而不是静态数组,所以就称作为任务队列)的双向循环链表中。链表中的每一个项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具有进程的所有信息
task_struct相对较大,在32位机器上,它大约有1.7KB。但如果考虑到该结构内包含了内核管理一个进程所需的所有信息,那么它的大小也算相当小了。进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。如下图:
在这里插入图片描述

分配进程描述符

Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的——通过预先分配和重复使用task_struct可以避免动态分配和释放带来的资源消耗。在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端。这样做是为了让那些像x86那样寄存器较少的硬件体系结构只要通过栈指针就能计算出它的为止,而避免使用额外的寄存器专门记录。由于slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(向上增长的栈来说)创建一个新的结构struct thread_info——寄存器较弱的体系结构不是引入thread_info结构的唯一原因。这个新建的结构使汇编代码中计算其偏移变得非常容易。见下图。
x86上,struct thread_info在文件<asm/thread_info.h>中定义如下:
在这里插入图片描述
最新的代码肯定是有变化的对比参考

每个任务的thread_info结构在它的内核栈尾端分配。在结构中task域中存放的是指向该任务实际task_struct的指针。

进程描述符的存放

内核通过一个唯一的进程标识值(process identification value)或PID来标识每个进程。PID是一个数,表示为pid_t隐含类型——指数据类型的物理表示是未知的或不相关的,实际上就是一个int类型。为了与老版本的Unix或Linux兼容,PID的最大值默认设置为32768(short int短整型的最大值)。尽管这个值也可以增加到高达400万(这受<linux/threads.h>中所定义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()函数来完成的。汇编代码如下:

movl $-8192, %eax
andl %esp, %eax

这里假定栈的大小为8KB。当4KB的栈启用时,就要用4096,而不是8192.
最后,current再从thread_infotask域中提取并返回task_sturct的地址:

current_thread_info()->task;

对比一下这部分再PowerPC上的实现(IBM基于RISC的现代微处理器),我们可以发现PPC当前的task_struct是保存在一个寄存器中的。也就是说,在PPC上,current宏只需把r2寄存器中的值返回就行了。与x86不一样,PPC有足够多的寄存器,所以它的实现有这样选择的余地。而访问进程描述符是一个重要的频繁操作,所以PPC的内核开发者觉得完全有必要为此使用一个专门的寄存器。

进程状态

进程描述符中的state域描述了进程的当前状态。系统中的每个进程都必然处于五种进程状态中的一种。该域的值也必为下列五种状态标志之一:

  • TASK_RUNNING(运行)——进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程。
  • TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就说它被阻塞),等待某些条件的达成。一旦条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行。
  • TASK_UNINTERRUPTIBLE(不可中断)——除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。这个状态通常进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不做响应,所以较之可中断状态,使用的较少。
  • __TASK_TRACED_——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。
  • __TASK_STOPPED(停止)——进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。
    在这里插入图片描述

设置当前进程状态

内核经常需要调整某个进程的状态。这时最好使用set_task_state(task, state)函数:

set_task_state(task, state); /*将任务task的状态设置为state*/

该函数将指定的进程设置为指定状态。必要的时候,它会设置内存屏障来强制其他处理器作重新排序。(一般只有在SMP系统中有此必要)否则,它等价于:

task->state = state;

set_current_state(state)和set_task_state(current, state)含义是等同的。参看<linux/sched.h>中对这些相关函数实现的说明。

进程上下文

可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序调用执行了系统调用或触发了某个异常,它就陷入了内核空间。此时,我们称内核"代表进程执行"并处于进程上下文中。在此上下文中current宏是有效的。除非在此间隙有更高优先级的进程需要执行并由调度器作出了相应调整,否则在内核退出的时候,程序恢复在用户空间会继续执行。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。

进程家族树

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

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, slibing);
	/*task 现在指向当前的某个子进程*/
}

init进程的进程描述符是作为init_task静态分配的。下面的代码可以很好地演示所有进程之间的关系;

struct task_struct *task;
for(task = current; task != &init_task; task = task->parent)
/*task现在指向init*/

实际上,你可以通过这种继承体系从系统的任何一个进程中出发查找到任意指定的其他进程。但大多数时候,只需要通过简单的重复方式就可以遍历系统中的所有进程。这非常容易做到,因为任务队列本来就是一个双向的循环链表。对于给定的进程,获取链表中的下一个进程:

list_entry(task->tasks.next, struct task_struct, tasks)

获取前一个进程的方法与之相同:

list_entry(task->tasks.prev, struct task_struct, tasks)

这两个例程分别通过next_task(task)宏和prev_task(task)宏实现。而实际上,for_each_process(task)宏提供了依次访问整个任务队列的能力。每次访问,任务指针都指向链表中的下一个元素:

struct task_struct *task;
for_each_process(task){
    
    
	/*它打印出每一个任务的名称和PID*/
	printk("%s[%d]\n",task->comm, task->pid);
}

特别提醒:在一个拥有大量进程的系统中通过重复来遍历所有的进程代价是很大的。因此,如果没有充足的理由(或者别无他法),别这样做

进程创建

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

写时拷贝

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映象,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。
只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读的方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会被写入的情况下(举例来说,fork()后立即调用exec())它们就无须复制了。
fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行文件,这种优化可以避免拷贝大量根本就不会使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。

fork()

Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork()、vfork()和__clone()库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork()
do_fork完成了创建种的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后让进程开始运行。copy_process()函数完成的工作很有意思:

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

vfork()

除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。在过去的3BSD时期,这个优化是很有意义的,那时并未使用写时拷贝来实现fork()。现在由于在执行fork()时引入了写时拷贝并且明确了子进程先执行,vfork()的好处就仅限于不拷贝父进程的页表项了。如果Linux将来fork()有了写时拷贝页表项,那么vfork()就彻底没用了。另外由于vfork()语义非常微妙(试想,如果exec()调用失败会发生什么),所以理想情况下,系统还是不要调用vfork(),内核也不要使用它。完全可以把vfork()实现成一个普普通通的fork()——实际上,Linux2.2以前都是这么做的。
vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行的。
1、调用copy_process()时,task_structvfor_done成员被置为NULL
2、在执行do_fork()时,如果给定特别标志,则vfor_done会指向一个特定地址。
3、子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfok_done指针向他发送信号。
4、在调用mm_release()时,该函数用于进程退出内存地址空间,并检查vfork_done是否为空,如果不为空,则会向父进程发送信号。
5、回到do_fork(),父进程醒来并返回
如果一切顺利,子进程在新的地址空间里运行而父进程也恢复了在原地址空间的运行,这样开销确实降低了,不过它的实现并不是优良的。

线程在Linux中的实现

线程机制是现代编程技术中常用的一种抽象概念。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术(concurrent programming),在多处理系统上,它也能保证真正的并行处理(parallelism)。
Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念Linux把所有的线程都当作进程来实现。内核并没有准备特别的调度算法或者定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。
上述线程机制的实现与Microsoft Windows或是Sun Solaris等操作系统的实现差异非常大。这些系统都在内核中提供了专门支持线程的机制(这些系统常常把线程称作为轻量级进程(lightweight process))。"轻量级进程"这种叫法本身就概括了Linux在此处与其他系统的差异。在其他的系统中,相较于重量级进程,线程被抽象成一种耗费较少资源、运行迅速的执行单元。而对Linux来说,它只是一种进程间共享资源的手段(Linux的进程本身就够轻量级了)。举个例子来说,假如我们有一个包含四个线程的进程。在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程的指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建四个进程并分配四个普通的task_struct结构。建立这四个进程时指定他们共享某些资源,这是相当高雅的做法。

创建线程

线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

上面的代码产生的结果和调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序。换个说法,新建的进程和它的父进程就是流行的所谓线程。
对比一下普通的fork()的实现是:

clone(SIGHLD, 0);

vforkl()的实现是:

clone(CLONE_VFORK | CLONE_VM | SIGHLD, 0);

传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。下表列举了这些clone()用到的参数标志以及它们的作用,这些是在<linux/sched.h>中定义的。
在这里插入图片描述

内核线程

内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成——独立运行在内核空间的标准进行。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被置为NULL)。它们只在内核空间运行,从来补切换到用户空间去。内核进程和普通进程一样可以被调度,也可以被抢占。
Linux确实会把一些任务交给内核线程去做,像flushksofirqd这些任务就是明显的例子。在装有Linux系统的机子上运行ps -ef命令,你可以看到内核线程,有很多!这些线程在系统启动时由另外一些内核线程创建。实际上,内核线程也只能由其他内核线程创建。内核是通过从kthreadd内核进程中衍生出所有新的内核线程来自动处理这一点的。在<linux/kthread.h>中声明接口,于是,从现有内核线程中创建一个新的内核线程的方法如下:

struct task_struct *kthread_create(int (*threadfn)(void *data), 
									void *data, 
									const char namefmt[],
									...)

新的任务是由kthread内核进程通过clone()系统调用而创建的。新的进程将运行kthreadfn函数,给其传递的参数为data。进程会被命名为namefmt,namefmt接受可变参数列表类似于printf()的格式化参数。新创建的进程处于不可运行状态,如果不通过wake_up_process()明确地唤醒它,它不会主动运行。创建一个进程并让它运行起来。可以通过调用kthread_run()来达到:

struct task_struct *kthread_run(int (*threadfn)(void *data)
								void *data,
								const char namefmt[],
								...)

这个例程是以宏实现的,只是简单地调用了kthread_create()wake_up_process():

#define kthread_run(threadfn, data, namefmt, ...)        		 \
({
      
      														 		 \
	struct task_struct *k;								 		 \
														         \
	k = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
	if(!IS_ERR(k))												 \
		wake_up_process(k);										 \
	k;															 \
})

内核线程启动后就一直运行直到do_exit()退出,或者内核的其他部分调用kthread_stop()退出,传递给kthread_stop()的参数为kthread_create()函数返回的task_struct结构的地址:

int kthread_stop(struct task_struct *k)

后面的博客会详细讨论内核线程。

进程终结

虽然让人伤感,但进程终归是要终结的。当一个进程终结时,内核必须释放它所占有的资源并把这一不幸的消息告知其父进程。
一般来说,进程的析构是自身引起的。它发生在进程调用exit()系统调用时,既可能显示地调用这个系统调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在main()函数的返回节点后放置exit()的代码)。当进程接收到它既不能处理也不能忽略的信号或异常时,他还能被动地终结。不管进程是怎么终结的,该任务大部分都要靠do_exit()(定义于kernel/exit.c)来完成,它要做下面这些烦琐的工作:

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

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

删除进程描述符

在调用了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结构所占的页。并释放task_struct所占的slab高速缓存。
    至此,进程描述符和所有进程独享的资源就被全部释放掉了。

孤儿进程造成的进退维谷

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init作为它们的父进程。在do_exit()中会调用exit_notify(),该函数会调用forget_origin_parent(),而后者会调用find_new_reaper()来执行寻父过程:

static struct task_struct *find_new_reaper(struct task_struct *father)
{
    
    
	struct pid_namespace *pid_ns = task_active_pid_ns(father);
	struct task_struct *thread;
	
	thread = father;
	while_each_thread(father, thread){
    
    
		if(thread->flags & PF_EXITING)
			continue;
		if(unlikely(pid_ns->child_reaper == father))
			pid_ns->child_reaper = thread;
		return thread;
	}
	
	if(unlikely(pid_ns->child_reaper == father){
    
    
		write_unlock_irq(&tasklist_lock);
		if(unlikey(pid_ns == &init_pid_ns))
			panic("Attemped to kill init!");
		zap_pid_ns_process(pid_ns);
		write_lock_irq(&tasklist_lock);
		/*
		* We can not clear ->child_reaper or leave it alone.
		* There may by stealth EXIT_DEAD tasks on ->children,
		* forget_original_parent() must move them somewhere. 
		*/
		pid_ns->child_reaper = init_pid_ns.child_reaper;
	}
	return pid_ns->child_reaper;
}

这段代码试图找到进程所在的线程组内的其他进程,如果线程组内没有其他的进程,它就找到并返回的是init进程。现在,给子进程找到合适的养父进程了,只需要遍历所有的子进程并为它们设置新的父进程:

reaper = find_new_reaper(father);
list_for_each_entry_safe(p, n, &father->children, sibling){
    
    
		p->real_parent = reaper;
		if(p->parent == father ){
    
    
			BUG_ON(p->ptrace);
			p->parent = p->real_parent;
		}
		reparent_thread(p, father);
}

然后调用ptrace_exit_finish()同样进行新的寻父过程,不过这次是给ptraced的子进程寻找父亲。

void exit_ptrace(struct task_struct *tracer)
{
    
    
	struct task_struct *p, *n;
	LIST_HEAD(ptrace_dead);
	
	while_look_irq(&tasklist_lock);
	list_for_each_entry_safe(p, n, &tracer->ptraced, ptrace_entry){
    
    
		if(_ptrace_detach(tracer, p))
			list_add(&p->ptrace_entry, &ptrace_dead);
	}
	wirte_unlock_irq(&tasklist_lock);

	BUG_ON(!list_empty(&tracer->ptraced));

	list_for_each_entry_safe(p, n, &ptrace_dead, ptrace_entry){
    
    
		list_del_init(&p->ptrace_entry);
		release_task(p);
	}
}

这段代码遍历了两个链表:子进程链表和ptrace子进程链表,给每个子进程设置新的父进程。这两个链表同时存在的原因很有意思,它也是2.6内核的一个新特性。当一个进程被跟踪时,它的临时父亲设定为调试进程。此时如果它的父进程退出了,系统会为它和它的所有兄弟重新找一个父进程。在以前的内核中,这就需要遍历系统所有的进程来找到这些子进程。现在的解决办法是在一个单独的被ptrace跟踪的子进程链表中搜索相关的兄弟进程——用两个相对较小的链表减轻了遍历带来的消耗。
一旦系统为进程成功地找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait()来检查其子进程,清楚所有与相关的僵死进程。

总结

本篇我们考察了操作系统中的核心概念——进程。也讨论了进程的一般特性,它为何如此重要,以及进程与线程之间的关系。然后,讨论了Linux如何存放和表示进程(用task_structthread_info),如何创建进程(通过fork(),实际上最终是clone()),如何把新的执行映象装入到地址空间(通过exec()系统调用族),如何表示进程的层次关系,父进程又是如何收集其后代的信息(通过wait()系统调用族),以及进程最终如何消亡(强制或自愿地调用exit())。进程是一个非常基础、非常关键的抽象概念,位于每一种现代操作系统的核心位置,也是我们拥有操作系统(用来运行程序)的最终原因。
下一篇将讨论进程调度,内核以这种微妙而有趣的方式来决定那个进程运行,何时运行,以何种顺序运行。

猜你喜欢

转载自blog.csdn.net/m0_56145255/article/details/130929670
今日推荐