《Linux内核设计与实现》阅读笔记:(第三章)进程管理

本人第一次翻看《Linux内核设计与实现》的时候,非常享受其中关于Linux进程调度的内容。不同于《深入理解计算机系统》泛而全,此书对于特定领域知识的介绍更加详细,且作者就是Linux抢占式内核、进程调度器等项目的参与者,书中的描述具有权威性。本人将开启一个文章系列作为对此书的阅读笔记。

进程概念

书中给出了一些概念:

  • 进程:处于执行期的程序以及相关资源的总称、正在执行的程序代码的实时结果 / 内核视进程为任务;
  • 可执行代码:在Unix称为代码段,text section;
  • 进程描述符:包含一个进程的所有信息;

Linux系统的线程实现:对线程和进程不特别区分;

进程提供两种虚拟机制:虚拟处理器和虚拟内存,分别将在第四章和第十二章详细描述;

一些系统级函数:fork、exec、exit、wait4()等;

Linux内核通常也把进程叫做任务task,书中“任务”“进程”这两个术语交替使用。

进程描述符

内核把进程放在任务队列中,这个队列是一个双向循环链表,链表每一项都是task_struct类型,称为进程描述符。这个描述符包含一个进程的所有信息。

分配进程描述符:Linux通过slab分配器分配task_struct,在这个过程中会创建新的结构struct thread_info,这个结构有助于计算偏移量。

PID用来标识进程,每个进程的PID都存放在各自的进程描述符中,默认最大值32768,但可以修改。

进程上下文:可执行文件 -> 进程地址空间 -> 用户空间执行 -> 系统调用 or 触发异常则内核空间执行,此时的内核处于进程上下文中。

进程/线程状态

每个进程/线程都处于下列五种状态的一种:

  1. TASK_RUNNING:或者正在执行,或者在运行队列中等待执行;
  2. TASK_INTERRUPTIBLE:进程睡眠,等待条件,可接受信号并被其提前唤醒;
  3. TASK_UNINTERRUPTIBLE:进程睡眠,不响应信号;
  4. __TASK_TRACED:被其它进程跟踪;
  5. __TASK_STOPPED:停止执行,没有投入运行也不能投入运行;

进程家族树

  • 所有进程都是PID为1的init进程的后代;
  • 每个进程都有一个父进程,每个进程也拥有0个到多个子进程,子进程构成链表;
  • 可以从系统的任何进程出发,查找到任意指定的其它进程;

进程创建

产生进程的机制:新的地址空间创建进程,读入可执行文件,最后开始执行。

进程和可执行文件的关系是什么?我的理解是,进程是Linux的任务,执行文件是实体。

fork

fork的作用是创建进程,子进程和父进程的区别在于PID、PPID以及某些资源和统计量;

exec的作用是读取可执行文件载入地址空间执行;

COW,即写时复制,父子进程对资源进行只读共享,只有在写入时才对页复制;

fork的开销:复制父子进程的页表以及给子进程创建唯一的进程描述符;

1
2
3
4
5
6
7
8
fork()
\
_\|
vfork() ---> clone() ---> do_fork() ---> copy_process()
_
/|
/
__clone()

copy_process:

  1. 调用dup_task_struct(),为新进程创建一个内核栈、thread_info以及task_struct,父子进程的这些值以及描述符都是相同的;
  2. 资源限制检查;
  3. 更改某些资源和统计量,父子进程开始彼此区分;
  4. 设置进程态:TASK_UNINTERRUPTIBLE;
  5. 一些标志位的设定;
  6. 为新进程分配有效PID;
  7. 根据传递给clone()的标志位,要么复制要么共享资源,公共的就共享,独有的就复制到该处;
  8. 做一些进程创建的收尾工作,返回指向新进程的指针;

子进程优先开始的目的是让其先执行exec(),避免写时复制的开销,是一种优化(这个复制开销可能指的是父进程写入)。

vfork()

vfork()和fork()有相同的功效,但和fork主要有两点区别:

  • 不复制父进程页表;
  • 父进程会阻塞直到子进程执行exec()或者调用_exit()。

通常来说除非给性能带来重大提升,否则一般避免使用此调用。

线程实现

Linux中,线程被视为一个与其它进程共享某些资源(地址空间)的进程;

创建方式和fork()差不多,但是会通过标志位来决定共享资源的种类;

内核进程:

内核进行的后台操作通过内核线程完成,内核线程独立运行在内核空间,它和普通进程间的区别是没有独立的地址空间,不会将上下文切换至用户空间;

后续章节还会讨论这个概念。

进程终结

进程终结将释放资源并通知父进程。

终结的方式通常有:

  • 显式调用exit();
  • 隐式调用exit();
  • 遇到不能处理也不能忽略的信号或异常;

exit()主要靠do_exit()代码:

  1. 设置task_struct标志位为PF_EXITING;
  2. 删除内核定时器;
  3. 若开启了记账功能,调用相关方法输出记账;
  4. 调用exit_mm()释放mm_struct;
  5. 调用sem__exit(),如果进程进队是等候IPC信号,则出队;
  6. 调用exit_files()和exit_fs()来递减相关资源的引用计数,计数为0就释放资源;
  7. 设置退出代码,供父进程检索;
  8. exit_notify()发信号给父进程,然后寻找养父,养父为线程组其它线程活着init进程,然后把子进程的退出状态设置为EXIT_ZOMBIE;
  9. do_exit调用schedule(),因为处于僵尸状态所以这是进程所执行的最后一段代码;

处于僵尸状态的进程还占用内核栈、thread_info以及task_struct,目的是给父类提供信息,父类检索后或者通知内核后,释放最后的资源。

最后的资源主要由进程描述符以及其相关结构组成,父进程调用wait()然后被挂起,直到它的一个子进程退出,然后会返回该子进程的PID,不仅如此,一个指针被提供给该函数,返回时会持有子进程的退出码。最终的释放还会调用release_task()。

当然上面还可能会遇到一种问题,那就是父进程在子进程之前就退出了,这就涉及到上面列举的第8步,寻找养父,不然就是孤儿进程永远处在僵尸状态不能释放。找到养父后就同上,调用wait()检查子进程,清除其中的僵尸进程。

补充

进程的布局

逻辑划分如下(也称段):

  • 文本:程序指令;
  • 数据:程序使用的静态变量;
  • 堆:程序可从该区域动态分配额外内存;
  • 栈:随函数调用;

以上可以类比Java线程的组成结构。

fork之后的竞态条件

前文提到了“子进程优先开始的目的是让其先执行exec()”,为什么子进程优先执行exec()会避免无谓的复制和开销?

这要从子进程的执行说起。当fork出子进程后,常常会去执行和父进程共享代码段的另一组不同函数,在Redis源码中就出现过;另一种常见的情况是使用系统调用exec()去加载全新的程序,exec()会销毁现有的文本、数据、堆以及栈,建立新的堆栈页。

父进程在fork()之后继续修改数据页和栈页,那么如果父进程先执行,内核就要为子进程复制那些“将要修改”的页。而先调度子进程,它会立即执行exec(),当下一次调度到父进程的时候,就无需复制内存页了。

不过在Linux2.6.32又改成父进程先执行,因为父进程在CPU处于活跃状态,并且内存管理信息也被置于内存管理单元的转译后背缓冲器(TLB),所以线运行父进程可以提高性能。

其实这两种策略性能差异很小,对大部分应用程序几无影响。

小结

本文是对《Linux内核设计与实现》一书第三章所做的笔记,主要探究了Linux进程的生命周期,并对一些内容进行了补充。

为什么vfork会存在子进程有新地址空间的说法?地址空间不共享吗?毕竟写时复制时“内核此时并不复制整个进程地址空间”?关于地址空间的理解会在后续文章展开。

问题

书中写道”If Linux one day gains copy-on-write page table entries, there will no longer be any benefit”,这句话感觉有歧义也不好理解,fork不是已经提供了这一功能么?

参考

《Linux内核设计与实现(第三版)》

《Linux Kernel Development, 3rd Edition》

《Linux_UNIX系统编程手册(上)》