这篇文章记述了关于进程的知识点。
C语言中return和exit的区别
exit用于结束进程,返回的状态码是给操作系统使用或父进程使用的。return是堆栈返回,返回的值是给主调函数用的。主线程结束前会默认调用exit结束进程。
exit函数运行时首先会执行由atexit()函数登记的函数,然后会做一些自身的清理工作,同时刷新所有输出流、关闭所有打开的流并且关闭通过标准I/O函数 tmpfile()创建的临时文件。exit是结束一个进程,它将删除进程使用的内存空间,同时把错误信息返回父进程,而return是返回函数值并退出函数。
main函数结束时也会隐式地调用exit函数。
孤儿进程与僵尸进程
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作.
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。
孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤 儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。
任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个 子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
僵尸进程危害场景:
例如有个进程,它定期的产生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程 退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用ps命令查看的话,就会看到很多状态为Z的进程。 严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大 量僵死进程的那个元凶枪毙掉(也就是通过kill发送SIGTERM或者SIGKILL信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被init进程接管,init进程会wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程 就能瞑目而去了。
需要注意的是,用户层守护进程的父进程是 init进程(进程ID为1),从上面的输出PPID
一列也可以看出,内核守护进程的父进程并非是 init进程。对于用户层守护进程, 因为它真正的父进程在 fork 出子进程后就先于子进程 exit 退出了,所以它是一个由 init 继承的孤儿进程。
进程组 :
- 每个进程除了有一个进程ID之外,还属于一个进程组
- 进程组是一个或多个进程的集合,同一进程组中的各进程接收来自同一终端的各种信号
- 每个进程组有一个组长进程。组长进程的进程组ID等于其进程ID
会话:会话(session)是一个或多个进程组的集合,进程调用 setsid 函数(原型:pid_t setsid(void)
)建立一个会话。
进程调用 setsid 函数建立一个新会话,如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话。具体会发生以下3件事:
- 该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。此时,该进程是新会话的唯一进程。
- 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID
- 该进程没有控制终端。如果调用setsid之前该进程有一个控制终端,那么这种联系也被切断
如果该调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,而子进程则继续。因为子进程继承了父进程的进程组ID,而其进程ID是重新分配的,两者不可能相等,这就保证了子进程不是一个进程组的组长。
创建守护进程的过程:
- 调用fork创建子进程。父进程终止,让子进程在后台继续执行。
- 子进程调用setsid产生新会话期并失去控制终端调用setsid()使子进程进程成为新会话组长和新的进程组长,同时失去控制终端。
- 忽略SIGHUP信号。会话组长进程终止会向其他进程发该信号,造成其他进程终止。
- 调用fork再创建子进程。子进程终止,子子进程继续执行,由于子子进程不再是会话组长,从而禁止进程重新打开控制终端。
- 改变当前工作目录为根目录。一般将工作目录改变到根目录,这样进程的启动目录也可以被卸掉。
- 关闭打开的文件描述符,打开一个空设备,并复制到标准输出和标准错误上。 避免调用的一些库函数依然向屏幕输出信息。
- 重设文件创建掩码清除从父进程那里继承来的文件创建掩码,设为0。
- 用openlog函数建立与syslogd的连接。
/dev/null是一个特殊的设备文件,这个文件接收到的任何数据都会被丢弃。因此,null这个设备通常也被成为位桶(bit bucket)或黑洞。
进程通信
1、管道
管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程);
单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在与内存中。
数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。
2、信号
信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
如果该进程当前并未处于执行状态,则该信号就有内核保存起来,知道该进程回复执行并传递给它为止。
如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消是才被传递给进程。
Linux系统中常用信号:
(1)SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
(2)SIGINT:程序终止信号。程序运行过程中,按Ctrl+C
键将产生该信号。
(3)SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\\
键将产生该信号。
(4)SIGBUS和SIGSEGV:进程访问非法地址。
(5)SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
(6)SIGKILL:用户终止进程执行信号。shell下执行kill -9
发送该信号。
(7)SIGTERM:结束进程信号。shell下执行kill 进程pid
发送该信号。
(8)SIGALRM:定时器信号。
(9)SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。
3、消息(Message)队列
消息队列特点总结:
(1)消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识.
(2)消息队列允许一个或多个进程向它写入与读取消息.
(3)管道和消息队列的通信数据都是先进先出的原则。
(4)消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
(5)消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。
(6)目前主要有两种类型的消息队列:POSIX消息队列以及System V消息队列,系统V消息队列目前被大量使用。系统V消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。
4、共享内存(share memory)
使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
由于多个进程共享一段内存,因此需要依靠某种同步机制(如信号量)来达到进程间的同步及互斥。
5、信号量
信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。
为了获得共享资源,进程需要执行下列操作:
(1)创建一个信号量:这要求调用者指定初始值,对于二值信号量来说,它通常是1,也可是0。
(2)等待一个信号量:该操作会测试这个信号量的值,如果小于0,就阻塞。也称为P操作。
(3)挂出一个信号量:该操作将信号量的值加1,也称为V操作。
为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号量通常是在内核中实现的。Linux环境中,有三种类型:Posix(可移植性操作系统接口)有名信号量(使用Posix IPC名字标识)、Posix基于内存的信号量(存放在共享内存区中)、System V信号量(在内核中维护)。这三种信号量都可用于进程间或线程间的同步。
信号量与互斥量之间的区别:
(1)互斥量用于线程的互斥,信号量用于线程的同步。这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源
(2)互斥量值只能为0/1,信号量值可以为非负整数。
也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。
(3)互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。
6、socket
线程通信
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
锁机制
互斥锁、条件变量、读写锁和自旋锁。
互斥锁确保同一时间只能有一个线程访问共享资源。当锁被占用时试图对其加锁的线程都进入阻塞状态(释放CPU资源使其由运行状态进入等待状态)。当锁释放时哪个等待线程能获得该锁取决于内核的调度。
读写锁当以写模式加锁而处于写状态时任何试图加锁的线程(不论是读或写)都阻塞,当以读状态模式加锁而处于读状态时“读”线程不阻塞,“写”线程阻塞。读模式共享,写模式互斥。
条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
自旋锁上锁受阻时线程不阻塞而是在循环中轮询查看能否获得该锁,没有线程的切换因而没有切换开销,不过对CPU的霸占会导致CPU资源的浪费。 所以自旋锁适用于并行结构(多个处理器)或者适用于锁被持有时间短而不希望在线程切换产生开销的情况。
信号量机制(Semaphore)
包括无名线程信号量和命名线程信号量。线程的信号和进程的信号量类似,使用线程的信号量可以高效地完成基于线程的资源计数。信号量实际上是一个非负的整数计数器,用来实现对公共资源的控制。在公共资源增加的时候,信号量就增加;公共资源减少的时候,信号量就减少;只有当信号量的值大于0的时候,才能访问信号量所代表的公共资源。
版权声明:本文为CSDN博主「流烟默」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/J080624/article/details/87454764
进程启动
内核使程序执行的唯一方法是调用exec函数。
exec函数
当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec不会创建新的进程,所以前后的进程ID并未改变。
exec只是用磁盘上的一个新的程序替换了当前进程的正文段、数据段以及堆、栈段
事实上,exec
是一系列函数,它至少包括:
1 | int execl(const char *path, const char *arg, ...); |
这些函数内部都会调用库函数 int execve(const char *filename, char *const argv[],char *const envp[]);
,该函数会将当前进程空间清空,而后根据传入的参数装载指定的可执行文件(二进制或者脚本)来执行。
1 | say_yes.sh |
执行结果
1 | I'm the father, and I have 42 apple(s). |
这里我们可以看出,在父进程中,「我有 42 个苹果」顺利被执行;同时在子进程中,我们使用了 execl
函数调用了外部可执行脚本,它成功地打印了预期的内容。值得注意的是,printf("Something that will never be printed.\n");
并没有执行。这是因为,在子进程执行到 execl
之后,进程空间中的内容就被清空了,execl
之后的指令永远不会有机会执行。
可见:使用 fork()
函数可以创建子进程;使用 fork()
函数以及 exec
函数则可以在子进程里执行新的任务。
进程终止
进程自愿终止的唯一方法是显式或隐式的(通过调用exit函数)调用_exit 或 _Exit 函数。
可以有8种方法使进程终止。有五种正常终止,它们是:
1)从main返回;
2)调用exit;(这个长得比较周正所以会去做一些善后工作:它会先执行一些清理工作(例如IO库的清理关闭工作),然后返回内核)
3)调用_exit 或者 _Exit ;(前者是POSIX.1标准, 后者是ISO标准)
4)最后一个线程从其启动例程中返回;
5)从最后一个线程调用pthread_exit
进程的异常终止有三种,他们是:
6)调用abort();(将SIGABRT信号发给调用进程(进程不应忽略此信号))
7)接到一个信号;
8)最后一个线程对取消请求做出响应
《Linux内核设计与实现》–进程
Linux系统的线程实现非常特别:它对线程和进程并不特别区分,对Linux而言,线程只不过是一种特殊的进程罢了。
现代处理器中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。虚拟处理器给进程一种假象,让这些进程觉得自己在独享处理器;虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的内存资源。有趣的是,在线程之间可以共享虚拟内存,但是每个线程都拥有各自的虚拟处理器。
任务队列
内核把进程的列表存放在叫做任务队列的双向循环链表中。链表中的每一项都是类型为 task_struct、称为进程描述符的结构,该结构定义在 <linux/sched.h> 文件中。进程描述符中包含一个具体进程的所有信息。
task_struct 相对较大,在32位机器上,一个大小约有1.7KB。进程描述符中的数据能完整的描述一个正在执行的数据:它打开的文件、进程的地址空间、挂起的信号、进程的状态以及其他信息。
Linux通过slab 分配器分配 task_struct 结构。这样能达到对象复用的目的(通过预先分配和重复使用 task_struct ,可以避免动态分配和释放带来的资源消耗。可以达到进程迅速创建的效果)
过程大概是 slab 分配器在内核栈的最上面创建一个新的结构 struct thread_info
,这个结构体里面有一个指向 task_struct 的指针。
在内核中,访问任务需要获得指向其 task_struct 的指针。可以通过 current 宏查找到当前正在运行进程的进程描述符的速度显得尤为重要。类似下面这样:
current_thread_info()->task
进程状态
进程描述符中的 state 域描述了进程的当前状态。有以下几种:
TASK_RUNNING:进程是可执行的;它或者正在运行,或者在运行队列里等待运行;
TASK_INTERRUPTIBLE:可中断,进程正在睡眠(被阻塞住了),等待某些条件的达成,一旦条件达成就会把进程状态置为运行。也会因为接收到信号而提前被唤醒并随时准备投入运行。
TASK_UNINTERRUPTIBLE:不可中断,除了就算接收到信号也不会被唤醒或准备投入运行外,这个状态和可打断状态相同。
__TASK_TRACED_ :被其他进程跟踪的进程,例如通过ptrace 对调试程序进行调试;
__TASK_STOPPED_:进程停止执行,在调试期间接收到任何信号都会使进程进入这种状态。
进程创建
unix 创建进程很特别:其他操作系统都提供产生进程的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。unix 则首先通过 fork() 拷贝当前进程创建一个子进程。子进程与父进程区别在于PID(每个进程唯一)、PPID(子进程将其设置为父进程的PID)和某些资源和统计量(例如挂起的信号,它没有必要被继承)。exec() 函数则负责读取可执行文件并将其载入地址空间开始运行。
写时拷贝(COW)
传统的fork() 系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单且效率低下,因为它拷贝的数据或许并不共享,假设新进程打算立即执行一个新的映像(就是新的a.out),那么它拷贝的数据都将前功尽弃。
补充:在shell 中经常喜欢这样用 ps -aux | grep xxx 这里 ps 会通过管道将数据发给新的 grep 进程。 grep 进程是 ps fork 出来的,但是它却执行了新的代码。根本没用到 ps 的一些数据。
所以Linux 采用了写时拷贝页技术。只有要写的时候,数据才会被复制。
fork() 的实际开销就是复制父进程的页表以及给子进程创建唯一的PID。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。这个优化是很重要的。
fork()
Linux 通过 clone() 系统调用实现 fork()。这个调用通过一系列的参数标志来指明父子进程需要共享的资源。fork vfork 和 _clone()库函数都是根据自己的参数标志来调用 clone(),然后右 clone() 来调用 do_fork().
do_fork() 函数调用 copy_process 函数然后让进程开始运行。copy_process 完成如下工作:
1)调用 dup_task_struct() 为新进程创建一个内核栈(有点不明白这个栈有啥用)、thread_info结构和 task_struct 。这些值和当前进程的值相同,此时子进程和父进程的PID是完全一样的。
2)检查创建完子进程后,当前用户所拥有的进程数目没有超过给它分配的资源的限制;
3)子进程着手使自己与父进程区别开来,进程描述符内的许多成员都要被清零或者设为初始值。
4)子进程的状态设置为TASK_UNINTERRUPTIBLE,以保证他不会被投入运行;
5)调用alloc_pid() 为新进程分配一个有效的PID;
6)根据传递给 clone 的参数标志, copy_process 拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里;
7)返回一个指向子进程的指针;
vfork()
vfork 函数用于创建一个新进程,而新进程的目的是 exec 一个新的程序,vfork 并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec ,于是也不会引用该地址空间。(这是为什么呢?)
vfork 保证子进程先执行,在它调用 exec 或 exit 之后父进程菜可能被调度执行。
—以上为APUE 解释
—以下为内核解释
除了不拷贝父进程的页表外,vfork 系统调用和 fork 功能一样。子进程作为父进程的一个单独的线程在它的地址空间里运行直到子进程退出或执行 exec 。子进程不能向地址空间写入。在过去没有出现 COW 技术前,这个优化是很有意义的,
(为啥有意义:因为以前的 fork 当它创建一个子进程时,将会创建一个新的地址空间,并且拷贝父进程的资源,而往往在子进程中会执行 exec 调用,这样,前面的拷贝工作就是白费力气了,这种情况下,聪明的人就想出了 vfork,它产生的子进程刚开始暂时与父进程共享地址空间(其实就是线程的概念了),因为这时候子进程在父进程的地址空间中运行,所以子进程不能进行写操作,并且在儿子“霸占”着老子的房子时候,要委屈老子一下了,让他在外面歇着(阻塞),一旦儿子执行了exec 或者 exit 后,相当于儿子买了自己的房子了,这时候就相当于分家了。此时 vfork 保证子进程先运行,在她调用 exec 或 exit 之后父进程才可能被调度运行。
因此 vfork 设计用以子进程创建后立即执行 execve 系统调用加载新程序的情形。在子进程退出或开始新程序之前,内核保证了父进程处于阻塞状态(用 vfork 函数创建子进程后,子进程往往要调用一种 exec 函数以执行另一个程序,当进程调用一种 exec 函数时,该进程完全由新程序代换,而新程序则从其 main 函数开始执行,因为调用 exec 并不创建新进程,所以前后的进程 id 并未改变,exec 只是用另一个新程序替换了当前进程的正文,数据,堆和栈段。)
现在由于在执行 fork 时引入了 COW 技术并且明确了子进程先执行,vfork 的好处就仅限于不拷贝父进程的页表项了。如果 Linux 将来 fork 有了写时拷贝页表项,那么 vfork 就彻底没用了。
线程在 Linux 中的实现
线程机制提供了在同一程序内共享内存地址空间运行的一组线程,这些线程可以共享打开的文件和其他资源。
从 unix 内核的角度来说,它没有线程这个概念,Linux 把所有的线程当作进程来实现。内核并没有准备特别的调度算法或者定义特别的数据结构来表征线程。相反,线程仅仅被视作为一个与其他进程共享某些资源的进程。每个线程都有唯一隶属于自己的 task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。
Windows系统在内核中提供了专门支持线程的机制,叫做轻量级进程。
对于Linux 来说,线程只是一种进程间共享资源的手段(Linux 的进程本身就够轻量级了)
举个例子:假如我们有一个包含四个线程的进程,在Windows中通常都会有一个包含指向四个不同线程的指针的进程描述符,该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它所独占的资源。
相反,Linux 仅仅创建四个进程并分配四个普通的 task_struct 结构。建立这四个进程时指定他们共享某些资源,这是相当高雅的做法。(原谅我暂时领会不到这种高雅)
线程的创建
线程的创建和普通进程的创建类似,只不过在调用 clone 的时候需要传递一些参数标志来指明需要共享的资源:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)
父子俩共享地址空间(Virtual Memory)、文件系统资源、文件描述符和信号处理程序。
对比一下,一个普通的 fork 函数实现是:
clone(SIGCHLD, 0)
传递给 clone () 函数的参数标志决定了新创建的进程的行为方式和父子进程之间共享的资源种类。
内核线程
内核经常需要在后台执行一些操作。这种任务可以通过内核线程完成–独立运行在内核空间的标准线程。内核线程与普通线程之间的区别是内核线程没有独立的地址空间(实际上指向地址空间的 mm 指针被设置为 NULL)这是为什么呢?它们只在内核空间运行,从不切换到用户空间去。
首先内核页表有一份,在内核初始时产生了,保存在某个地方。然后在创建每个进程时,每个进程 都拷贝了 一份 内核的页表,加上 自己的用户空间的页表,构成了整个 4g空间的页表,当然实际上这个页表没那么大,因为很多地址是没映射的。那么一个进程执行前,肯定要 在某些寄存器设置 设置好页表,这样mmu才能找到 根据某逻辑地址找到相应的物理内存地址。
正因为 内核的页表 在每个进程下都一样,只要某个进程的页表被正确设置,那么 内核线程就可以不管当前是设置哪个进程的页表,只要 内核线程访问的 内核逻辑地址,就可以正确地找到物理地址。
从这个角度看,进程中所谓独立的地址空间,只是因为有一个 独立的用户空间页表。 而对于内核线程,内核页表都是一样的,因此没有所谓的独立地址空间。
进程终结
虽然让人伤感,但是进程总归是要终结的。当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知其父进程。(瞅瞅人家这话说的,有水平,翻译的也有水平)
一般来说,进程的析构是自身引起的,它发生在进程调用 exit() 系统调用时,既可能显式地调用这系统调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在 main() 函数的返回点后面放置调用 exit() 的代码)。当进程接收到它既不能处理也不能忽略的信号和异常时,它还可能被动的终结。不管进程是怎么终结的,该任务大部分都要靠 do_exit() 来完成,它要做下面这些繁琐的工作:
1)将 task_struct 中的标志成员设置为 PF_EXITING;
2)调用 del_timer_sync() 删除任一内核定时器。
3)释放进程占用的 mm_struct,如果没有别的进程使用它们(也就是说这个地址空间没有被共享),就彻底释放它们。
4)递减文件描述符、文件系统数据的引用计数,如果某个引用计数的值降为0,那么就代表没有进程在使用相应的资源,允以释放。
5)调用 schedule() 切换到新的进程,因为处于 PF_EXITING 的进程不会再被调度,所以这是进程所执行的最后一段代码。
进程占用的所有内存就是内核栈、thread_info 结构和 task_struct 结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存被释放,归还给系统使用。
在调用了 do_exit 之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的 task_struct 才被释放。
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。解决方法就是给子进程在当前线程组里找一个线程作为父亲,如果不行,就让 init 作为它们的父进程。 在 do_exit 中会调用 exit_notify(),该函数会调用 forget_original_parent() ,而后者会调用 find_new_reaper() 来执行寻父过程。