Linux内核设计与实现——进程篇之进程管理

  • 目录
    • 概述
    • 进程与线程
    • 进程管理
      • 进程描述符及任务结构
      • 进程状态
      • 进程上下文
      • 线程创建
      • 写时拷贝
      • fork()
      • vfork()
      • 创建线程
      • 内核线程
      • 进程终结
      • 删除进程描述符
    • 结语

目录

概述

  谈及Windows,我们都不会陌生,这就是我们平常使用最多的个人PC,但是谈及服务器,基本上都是部署在Linux上,经常接触编程开发,计算机的人对Linux都不会陌生。那么在谈及Linux进程篇相关的内容之前,我们需要首先再次了解一下Linux的相关概述。

Linux,全称GNU/Linux,是一种免费使用和自由传播的类UNIX操作系统,其内核由林纳斯·本纳第克特·托瓦兹于1991年10月5日首次发布,它主要受到Minix和Unix思想的启发,是一个基于POSIX的多用户、多任务、支持多线程和多CPU的操作系统。它能运行主要的Unix工具软件、应用程序和网络协议。它支持32位和64位硬件。Linux继承了Unix以网络为核心的设计思想,是一个性能稳定的多用户网络操作系统。Linux有上百种不同的发行版,如基于社区开发的debian、archlinux,和基于商业开发的Red Hat Enterprise Linux、SUSE、Oracle Linux等。

  • 完全免费 Linux是一款免费的操作系统,用户可以通过网络或其他途径免费获得,并可以任意修改其源代码。这是其他的操作系统所做不到的。正是由于这一点,来自全世界的无数程序员参与了Linux的修改、编写工作,程序员可以根据自己的兴趣和灵感对其进行改变,这让Linux吸收了无数程序员的精华,不断壮大。
  • 完全兼容POSIX1.0标准 这使得可以在Linux下通过相应的模拟器运行常见的DOS、Windows的程序。这为用户从Windows转到Linux奠定了基础。许多用户在考虑使用Linux时,就想到以前在Windows下常见的程序是否能正常运行,这一点就消除了他们的疑虑。
  • 多用户、多任务 Linux支持多用户,各个用户对于自己的文件设备有自己特殊的权利,保证了各用户之间互不影响。多任务则是现代电脑最主要的一个特点,Linux可以使多个程序同时并独立地运行。
  • 良好的界面 Linux同时具有字符界面和图形界面。在字符界面用户可以通过键盘输入相应的指令来进行操作。它同时也提供了类似Windows图形界面的X-Window系统,用户可以使用鼠标对其进行操作。在X-Window环境中就和在Windows中相似,可以说是一个Linux版的Windows。
  • 支持多种平台 Linux可以运行在多种硬件平台上,如具有x86、680×0、SPARC、Alpha等处理器的平台。此外Linux还是一种嵌入式操作系统,可以运行在掌上电脑、机顶盒或游戏机上。2001年1月份发布的Linux2.4版内核已经能够完全支持Intel64位芯片架构。同时Linux也支持多处理器技术。多个处理器同时工作,使系统性能大大提高。

  知道了解了Linux的相关概述,那么就又该谈谈Linux的内核又是怎么一回事,顾名思义,内核内核,就是Linux设计和实现中最为核心的东西,是Linux系统的灵魂,就像我们的大脑一样重要,帮助我们处理各种各样的事情,而进程,是每个操作系统抽象概念中最基本的一种,在我们平常接触最多的Windows系统中也有相关进程、线程的概念,普遍来说,进程就是处于执行期的程序,那么Linux内核如何管理好每个进程,他们在内核中,如何创建,最终又如何消亡呢?请紧跟笔者的步伐,一起深入了解相关概念及内核设计源码,去一揽究竟。

进程与线程

  谈及进程,我们就会想到线程,那么线程和进程到底是怎样的关系呢?Linux内核又是怎样区分进程和线程的呢?

  • 进程:处于执行期的程序(可执行程序代码+其他资源);
  • 线程:执行线程,简称线程,是在进程中活动的对象。每个线程都有一个独立的程序计数器、进程栈和一组进程寄存器。

  内核调度的对象是线程,而不是进程,稍后我们会发现,在Linux系统中,线程的实现非常特别,即它对线程和进程并不特别区分,对Linux而言,线程只不过是一种特殊的进程罢了。

进程管理

进程描述符及任务结构

  内核把进程的列表存放在叫做任务队列的双向循环链表中,链表的每一项的类型为task_struct、称为进程描述符的结构

 上述是简化的进程描述符形式,Linux内核源码中定义的进程描述符综合考虑了平台,环境等很多因素,异常复杂,在Linux内核源码<include/linux
/sched.h>

struct task_struct源码如下:

说明:由上述struct task_struct的源码就可以发现,由于内核中进程相关源码定义和使用参杂了太多系统、平台和环境的因素,导致源码定义就异常复杂,故笔者后续的探索仅使用简化版的相关定义和方法,详细方法和源码留给读者自己学习探索。

  task_struct相对较大,在32位机器上,它大约有1.7KB,进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息(如简化版图所示)

  而Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的。在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端。这样做是为了让那些像x86那样寄存器较少的硬件体系结构只要通过栈指针就 能计算出它的位置,而避免使用额外的寄存器专门记录。由于现在用slab分配器动态生成task_ struct,所以只需在栈底(对于向下増长的栈来说)或栈顶(对于向上增长的栈来说)创建一个 新的结构 struct thread_info
 在X86上,struct thread_info在文件中简化版定义如下:

 struct thread_info {struct task_struct*task;struct exec_domain *exec_domain;_u32   flags;_u32   status;_u32   cpu;int       preempt_count;mm_segment_t addr_limit;struct restart_blockrestart_block;void       *sysenter_return;intuaccess_err;};


每个任务的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/kemel/pid_max来提高上限。

进程状态

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

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

    内核经常需要调整某个进程的状态。这时最好使用set_task_state(task, stete)`函数:
set_task_state (task, state)/* 将任务 task 的状态设置为 state */

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

task->state = state;

  set_current_state(state)set_task_state(current, state)含义是等同的。参看 中 对这些相关函数实现的说明。

进程上下文

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

补充:上下文相关:
进程上下文是一种内核所处的操作模式,此时内核代表进程执行——例如,执行程序调用或运行内核线程;实际上我们可以将每个处理器在任何指定时间点上的活动必然概括为下列三者之一:

•运行于用户空间,执行用户进程。
•运行于内核空间,处于进程上下文,代表某个特定的进程执行。
•运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定的中断。
 以上所列几乎包括所有情况,即使边边角角的情况也不例外,例如,当CPU空闲时,内核就运行一个空进程,处于进程上下文,但运行于内核空间。
而进程上下文和中断上下文最为显著的区别就是:

进程上下文允许睡眠,而中断上下文不允许睡眠。

线程创建

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

写时拷贝

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

fork()

  Linux通过clone()系统调用实现fork()这个调用通过一系列的参数标志来指明父、子进程 需要共享的资源。fork()、vfork()和_clone()库函数都根据各自需要的参数标志去调用clone(),然后由cloneO去调用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_struct的flags成员。表明进程是否拥有超级 用户权限的PF_SUPERPRIV标志被清0。表明进程还没有调用exec()函数的PF_FORKNOEXEC 标志被设置。
  6. 调用alloc_pid()为新进程分配一个有效的PID.
  7. 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
  8. 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。

  再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其 投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数,这样可 以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

vfork()

 &emsp除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程 的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进 程不能向地址空间写入。在过去的3BSD时期,这个优化是很有意义的,那时并未使用写时拷贝 页来实现fork。。现在由于在执行forkO时引入了写时拷贝页并且明确了子进程先执行,vfork() 的好处就仅限于不拷贝父进程的页表项了。如果Linux将来fork()有了写时拷贝页表项,那么 vfork()就彻底没用了气 另外由于vfork。语意非常微妙(试想,如果exec()调用失败会发生什 么),所以理想情况下,系统最好不要调用vfork(),内核也不用实现它。完全可以把vfork()实现 成一个普普通通的fork()—实际上,Linux 2.2以前都是这么做的。
 vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行的。

  1. 在调用 copy_process()时,task_struct 的 vfork_done 成员被设置为 NULL。
  2. 在执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特定地址。
  3. 子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过 vfork_done指针向它发送信号。
  4. 在调用mm_releaseO时,该函数用于进程退出内存地址空间,并且检査vfork_dcme是否
    为空,如果不为空,则会向父进程发送信号。
  5. 回到do_fork(),父进程醒来并返回。

  如果一切执行顺利,子进程在新的地址空间里运行而父进程也恢复了在原地址空间的运行。 这样开销确实降低了,不过它的实现并不是优良的。

创建线程

  Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线 程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共 享某些资源,如地址空间)。
 线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来 指明需要共享的资源:

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

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

clone(SIGCHLD, 0);

而vfork()的实现是:

clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

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


内核线程

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

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

  新的任务是由kthread内核进程通过clone()系统调用而创建的。新的进程将运行threadfe 函数,给其传递的参数为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)来完成,它要做下面这些烦琐的工作:

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

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

删除进程描述符

  在调用了 do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描 述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结 时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,或 者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。
 wait()这一族函数都是通过唯一(但是很复杂)的一个系统调用wait4()来实现的。它的标 准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此 外,调用该函数时提供的指针会包含子函数退出时的退出代码。 .
 当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:

  1. 它调用 _exit_signal(),该函数调用 _unhash_process(),后者又调用 detach_pid。从 pidhash 上删除该进程,同时也要从任务列表中删除该进程。
  2. _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
  3. 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要 通知僵死的领头进程的父进程。
  4. release_task()调用put_task_struct()释放进程内核栈和threadjnfb结构所占的页,并释放tast_struct所占的slab髙速缓存。

  至此,进程描述符和所有进程独享的资源就全部释放掉了。

结语

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

  这只是Linux内核、进程篇中的第一个小的部分,后续还有进程是如何调度的,系统调用……等等

  敬请期待后续Linux内核系列篇…

微语:我们可以把时间浪费在自己喜欢做的事情上,但不可以困在自己讨厌的生活方式里