本人第一次翻看《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 触发异常则内核空间执行,此时的内核处于进程上下文中。
进程/线程状态
每个进程/线程都处于下列五种状态的一种:
- TASK_RUNNING:或者正在执行,或者在运行队列中等待执行;
- TASK_INTERRUPTIBLE:进程睡眠,等待条件,可接受信号并被其提前唤醒;
- TASK_UNINTERRUPTIBLE:进程睡眠,不响应信号;
- __TASK_TRACED:被其它进程跟踪;
- __TASK_STOPPED:停止执行,没有投入运行也不能投入运行;
进程家族树
- 所有进程都是PID为1的init进程的后代;
- 每个进程都有一个父进程,每个进程也拥有0个到多个子进程,子进程构成链表;
- 可以从系统的任何进程出发,查找到任意指定的其它进程;
进程创建
产生进程的机制:新的地址空间创建进程
,读入可执行文件
,最后开始执行。
进程和可执行文件的关系是什么?我的理解是,进程是Linux的任务,执行文件是实体。
fork
fork的作用是创建进程,子进程和父进程的区别在于PID、PPID以及某些资源和统计量;
exec的作用是读取可执行文件载入地址空间执行;
COW,即写时复制,父子进程对资源进行只读共享,只有在写入时才对页复制;
fork的开销:复制父子进程的页表以及给子进程创建唯一的进程描述符;
|
|
copy_process:
- 调用dup_task_struct(),为新进程创建一个内核栈、thread_info以及task_struct,父子进程的这些值以及描述符都是相同的;
- 资源限制检查;
- 更改某些资源和统计量,父子进程开始彼此区分;
- 设置进程态:TASK_UNINTERRUPTIBLE;
- 一些标志位的设定;
- 为新进程分配有效PID;
- 根据传递给clone()的标志位,要么复制要么共享资源,公共的就共享,独有的就复制到该处;
- 做一些进程创建的收尾工作,返回指向新进程的指针;
子进程优先开始的目的是让其先执行exec(),避免写时复制的开销,是一种优化(这个复制开销可能指的是父进程写入)。
vfork()
vfork()和fork()有相同的功效,但和fork主要有两点区别:
- 不复制父进程页表;
- 父进程会阻塞直到子进程执行exec()或者调用_exit()。
通常来说除非给性能带来重大提升,否则一般避免使用此调用。
线程实现
Linux中,线程被视为一个与其它进程共享某些资源(地址空间)的进程;
创建方式和fork()差不多,但是会通过标志位来决定共享资源的种类;
内核进程:
内核进行的后台操作通过内核线程完成,内核线程
独立运行在内核空间,它和普通进程间的区别是没有独立
的地址空间,不会将上下文切换至用户空间;
后续章节还会讨论这个概念。
进程终结
进程终结将释放资源并通知父进程。
终结的方式通常有:
- 显式调用exit();
- 隐式调用exit();
- 遇到不能处理也不能忽略的信号或异常;
exit()主要靠do_exit()代码:
- 设置task_struct标志位为PF_EXITING;
- 删除内核定时器;
- 若开启了记账功能,调用相关方法输出记账;
- 调用exit_mm()释放mm_struct;
- 调用sem__exit(),如果进程进队是等候IPC信号,则出队;
- 调用exit_files()和exit_fs()来递减相关资源的引用计数,计数为0就释放资源;
- 设置退出代码,供父进程检索;
- exit_notify()发信号给父进程,然后寻找养父,养父为线程组其它线程活着init进程,然后把子进程的退出状态设置为EXIT_ZOMBIE;
- 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系统编程手册(上)》