《Linux内核设计与实现》内容整合与讲解-第三章

进程管理

​ 无论在哪一本操作系统的教材当中,进程永远占据着至关重要的位置,这本书也不例外。在这里,我最开始会展示出进程的定义即什么是进程,然后,探讨在Linux操作系统当中,系统如何管理每个进程:在内核中如何被例举,如何创建,最终又如何消亡。可以说,进程就是Linux系统的心脏所在。

​ 首先我们来谈谈什么是进程,在书中,作者所给出的定义是这样的:

  • 进程:处于执行期的程序(目标代码存储在某种存储介质上)。但进程不仅仅局限于一段可执行代码(text section),通常进程还需要包含其它的资源,像打开的文件,挂起的信号,内核内部数据,处理器的状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程当然还包括了存储全局变量的数据段等。

    在上面的进程定义中出现了一个叫作线程的概念,我将线程的定义放在下面:

  • 线程:执行线程简称线程,是在进程中活动的对象。每一个线程都有着一个独立的程序计数器、进程栈和一组进程寄存器。内核的调度对象是线程而不是进程,在Linux中线程可以看作是一种特殊的进程

​ 在现代的操作系统中,进程提供两种虚拟机制:虚拟存储器以及虚拟内存。在真实的内核中是很多进程在共享一个真正的处理器,但是虚拟处理器的存在使得进程觉得自己在独占一个处理器,类似的,虚拟内存使得进程觉得它可以占用所有的系统内存。但是值得注意的是,在线程之间可以共享虚拟内存,但是他们却拥有各自的虚拟处理器。

​ 在知晓了关于进程的一些基础知识后,我们首先来大致浏览一下进程的周期:

  1. 父进程调用fork()函数来创造一个全新的进程,在调用结束的时候父进程恢复运行,子进程开始执行,所以可以说系统调用从内核返回两次:一次回到父进程一次回到新产生的子进程。
  2. 接着执行所创建的子进程,通过exec()这组函数就可以创建新的地址空间,并把新的程序载入其中。
  3. 当进程要消亡的时候,程序通过调用exit()使得进程退出执行,将其所占用的系统资源释放掉,此时我们称进程处于僵死状态,直到它的父进程调用wait()或waitpid()为止。

​ 现在我来讲解一下,内核是怎么来存储所有进程的相关信息的,要便于对进程进行管理,单单依靠进程自身的内存信息是不够的,内核需要一个类似于简介的东西来管理内核中所有的进程。为了解决这个问题,Linux采用了一种双向循环列表的形式来存储,Linux称这个双向链表为任务队列(task list),这个链表每个元素是task_struct类型,他被称为进程描述符(process descriptor)的结构,该结构定义在<Linux/sched.h>头文件中,它包含了一个具体进程的所有信息。

​ 在一个32位的机器上,它大约有1.7KB。进程描述符中包含的数据能完整的描述一个正在执行的程序:打开的文件,进程的地址空间,挂起的信号,进程的状态,以及其它信息。

​ 为了在每个进程的栈中存储进程描述符里的信息,在2.weiduan 6以前的内核中,各个进程的tast_struct存放在内核栈尾端。这样的好处是,只需要通过栈指针就可以记录它们的位置,而不需要单独去记录,但是现在使用slab分配器来动态的生成tast_struct,所以只需要在栈增长的尾端存放新的结构struct_thread_info。这个数据结构存放在<asm/thread_info.h>中,它的指针元素tast是指向该任务实际task_struct的指针。

​ 内核通过一个唯一的进程标识值(PID)来表示每一个进程。它是一个数,实际上就是一个int类型。它的最大值默认设置为32768(short int短整型的最大值),尽管这个值也可以也可以增加到高达400万(这受<linux/threads.h>中所定义的PID最大值限制)内核把PID存放在它们各自的进程描述符中。

​ 进程描述符中除了PID还包含了进程的当前状态记作state域,系统中的进程都必然处于五种状态之一,我将五种状态写在下面:

  • TASK_RUNNING(运行)-——进程是可执行的,它或者正在执行或者在运行队列中等待执行,这是进程在用户空间执行的唯一的一种可能的执行状态,当然也可以应用到内核空间的正在执行的进程。

  • TASK_INTERRUPYIBLE(可中断)——进程正在睡眠(也就是说它正在被阻塞),等待某些条件的达成,一旦条件达成,内核就会把进程状态设为运行,处于此状态的进程也可接收到某种信号而提前投入运行。

  • TASK_UNINTERRUPYIBLE(不可中断)——与可中断唯一的区别在于:它就算接到了信号也不会被唤醒或者投入运行。

  • __TASK_TRACED——被其它程序跟踪的进程,例如通过ptrace对调试程序进行跟踪。

  • __TASK_STOPPED(终止)——进程停止运行,进程没有投入运行也不能投入运行。

​ 讲完了进程的基本状态,我们再来说说进程的上下文,可执行程序代码是进程的重要组成部分,这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行,当一个程序执行了系统调用或者说触发了某个异常,它就陷入了内核空间,此外,我们称内核“代表进程执行”并处于进程的上下文中,内核退出后,程序恢复在用户空间继续执行。

​ UNIX的进程之间存在一个明显的继承关系,在Linux系统中也是如此,所有的进程都可以看作是PID为1的init进程的后代,内核在启动的最后阶段启动init进程,这个进程就会读取系统的初始化脚本(initscript)并执行其它的相关程序,最终完成整个系统的启动。

​ 系统中的每一个进程必有一个父进程,相应的,每个进程也可以拥有零个或者多个子进程。拥有同一个父进程的子进程叫作兄弟。进程间的关系存放在进程描述符中。每个进程描述符都包含着一个指向父进程的指针parent,还有一个称为children的子进程链表。

​ 聊了这么多之后我们再把目光放到进程创建的具体过程上面来,Linux创建子进程可以分成调用fork与调用exec两个步骤。首先使用fork()来创造一个子进程,此时的子进程与父进程的唯一的差别就是PID、PPID和某些资源的统计量(例如挂起的信号等就不需要被继承);在之后我们就可以使用exec()调用读取可执行文件并载入其地址空间开始运行。

​ 传统的fork系统调用直接把所有的资源复制给子进程,这种实现过于简单并且效率低下,因为他拷贝的数据子进程并不一定用得上,因此Linux的fork方法采用写时拷贝页实现。写时拷贝是一种推迟甚至避免拷贝的一种方法,内核此时并不会复制整个内存的地址空间,而是会让父进程与子进程共享一个拷贝。只有等到写入的时候才会进行拷贝,若只读他们就会共享同一块内存地址。在页根本不会被写入的时候也就没有必要复制了。

​ Linux通过clone()系统调用实现fork(),这个调用通过一系列的参数标志来指明父进程与子进程需要共享的资源,而clone的实现也离不开do_fork()系统调用,这个内核函数定义在kernel/fork.c中,可以说它实现了创建中的绝大部份工作。这个函数又接着调用copy_process()使得进程开始运行,这个函数调用的工作很有意思,我将它的详细过程写在下面:

  1. 调用do_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同,此时子进程与父进程的进程描述符是相同的;
  2. 检查所存在的进程数有没有达到系统限制个数;
  3. 进程描述符中的很多的成员都被清为0或者设置为初始值,但是绝大多数的数据并没有被修改;
  4. 子进程状态被设置成TASK_UNINTERRUPTIBLE,确保它不会投入运行;
  5. copy_process()调用copy_flags()以更新task_struct的flags成员,表明进程是否拥有超级用户权限的PF_SUPERRIV标志被清零,表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置;
  6. 调用alloc_pid()为进程分配一个有效的PID;
  7. 再根据传递给clone的参数标志拷贝或者共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。

​ 说完了进程的创建接着我们可以来聊一聊线程了;线程机制是现代编程技术中常用的一种抽象概念,它提供了在程序中共享内存地址空间的一组线程,这些线程可以共享打开的文件和其他资源,线程支持并发执行机制并在多核处理器上支持并行处理机制。在Linux中线程与进程并没有什么太大的区别,可以将线程也当作一种进程,对Linux线程机制只是进程之间共享资源的一种手段,这是非常优雅的一种做法。

​ 虽然很伤感,但是进程还是要终结的,当一个进程终结时它需要释放掉它所占用的所有的资源并且它需要把这一消息告诉自己的父进程;一般来说进程的析构是由它自己调用exit()函数来实现的,不管它是怎么终结的do_exit()都要完成大部分工作:

  1. 将进程描述符中的标志成员设为PF_EXITING;
  2. 删除任意内核定时器,确保没有定时器在排队也没有定时器处理程序正在运行;
  3. 如果BSD的进程记账功能是开启的,那么就输出记账信息;
  4. 释放进程所占用的mm_struct,如果没有其他进程占用,就彻底释放;
  5. 如果进程排队等待IPC信号,那么它离开队列;
  6. 分别递减文件描述符,文件系统数据的引用计数,如果其中的某个引用计数的数值降为零,那么就释放掉该资源;
  7. 把存放在进程描述符中的exit_code成员的人物退出代码设置为exit()提供的退出代码,或者去完成任何又内核机制规定的退出动作,退出代码供父进程查询;
  8. 给父进程发送信号或者给子进程寻找养父,将exit_state设置为EXIT_ZOMBIE;
  9. 调用schedule()切换到新进程并且永不返回。

​ 至此,进程已经僵死不可运行了,但是它的进程描述符还没有被清除;这时候我们就需要利用父进程的wait4()函数来挂起父进程,并彻底结束掉子进程,此时release_task会被调用:

  1. 从pidhash与任务列表上删除此进程;
  2. 释放所占用的剩余资源,并进行最终的统计;
  3. 如果这个进程是线程组的最后一个进程,且领头进程已死通知领头的父进程;
  4. 释放进程的内核栈与thread_info所占的内存页,清楚tast_struct所占的slab高速缓存;
posted @ 2021-06-17 21:33  成仙的胖子  阅读(112)  评论(0)    收藏  举报