操作系统基础 | 5.1 进程管理简介

进程

一个进程是一个正在执行中的程序(存储在某种介质上的目标代码)。然而,进程不仅仅只是执行的程序代码(在 Unix 中常称为文本段)。它们还包括一系列资源,例如打开的文件和待处理的信号、内核内部数据、处理器状态、包含一个或多个内存映射的内存地址空间、一个或多个执行线程,以及包含全局变量的数据段。实际上,进程是运行程序代码的动态产物。内核需要高效且透明地管理所有这些细节。

执行线程(通常简称为线程)是进程内部的活动对象。每个线程包含一个唯一的程序计数器、进程栈和一组处理器寄存器。内核调度的是单个线程,而不是进程。在传统的 Unix 系统中,每个进程由一个线程组成。然而,在现代系统中,由多个线程组成的多线程程序非常普遍。正如您后面将看到的,Linux 对线程的实现很独特:它并不区分线程和进程。对 Linux 而言,线程只是一种特殊的进程。

在现代操作系统上,进程提供了两种虚拟化:虚拟化处理器和虚拟内存。虚拟化处理器给进程一种假象,让它以为自己在独享系统,尽管处理器可能正与数百个其他进程共享。虚拟内存让进程可以分配和管理内存,就好像它独占了系统中的所有内存一样。

有趣的是,请注意线程共享虚拟内存抽象,而每个线程则拥有自己的虚拟化处理器。

程序本身并不是一个进程;进程是活动中的程序及其相关资源。实际上,可以存在两个或更多个执行同一程序的进程。事实上,也可以存在两个或更多共享各种资源(如打开的文件或地址空间)的进程。

毫不奇怪,进程在其创建时开始它的生命周期。在 Linux 中,这是通过 fork() 系统调用来完成的,该系统调用通过复制一个现有进程来创建一个新进程。调用 fork() 的进程是父进程,而新进程是子进程。父进程恢复执行,而子进程则在相同的地方开始执行:即从 fork() 调用返回的地方。fork() 系统调用从内核返回两次:一次在父进程中,一次在新生的子进程中。

通常,在 fork() 之后立即需要执行一个新的、不同的程序。exec() 系列函数调用会创建一个新的地址空间并将一个新的程序加载到其中。在当代的 Linux 内核中,fork() 实际上是通过 clone() 系统调用实现的,这将在后面讨论。

最后,程序通过 exit() 系统调用退出。此函数终止进程并释放其所有资源。父进程可以通过 wait4()¹ 系统调用查询已终止子进程的状态,该系统调用使一个进程能够等待特定进程的终止。当一个进程退出时,它会被置于一种特殊的僵尸 (zombie) 状态,该状态表示已终止的进程,直到父进程调用 wait()waitpid()

¹ 内核实现了 wait4() 系统调用。Linux 系统通过 C 库通常提供 wait(), waitpid(), wait3(), 和 wait4() 函数。所有这些函数都返回关于已终止进程的状态信息,尽管语义略有不同。

注意 进程的另一个名称是任务 (task)。Linux 内核在内部将进程称为任务。在本书中,我会交替使用这两个术语,尽管当我说“任务”时,通常是从内核的角度来指代一个进程。


进程描述符和任务结构 (Task Structure)

内核在一个称为任务列表 (task list)² 的环形双向链表中存储进程列表。任务列表中的每个元素都是一个类型为 struct task_struct 的进程描述符,该结构定义在 <linux/sched.h> 中。进程描述符包含了一个特定进程的所有信息。

task_struct 是一个相对较大的数据结构,在 32 位机器上约为 1.7 千字节。然而,考虑到该结构包含了内核所拥有和需要的关于一个进程的所有信息,这个大小已经相当小了。进程描述符包含了描述正在执行的程序的数据——打开的文件、进程的地址空间、待处理的信号、进程的状态等等。

² 一些操作系统设计文献称此列表为任务数组 (task array)。由于 Linux 的实现是链表而非静态数组,因此在 Linux 中它被称为任务列表 (task list)。


分配进程描述符

task_struct 结构通过 slab 分配器 (slab allocator) 进行分配,以提供对象重用和缓存着色。在 2.6 系列内核之前,struct task_struct 存储在每个进程的内核栈的末端。这使得像 x86 这样寄存器数量较少的体系结构,可以通过栈指针计算进程描述符的位置,而无需使用额外的寄存器来存储该位置。由于现在进程描述符是通过 slab 分配器动态创建的,因此引入了一个新的结构 struct thread_info,该结构再次存在于栈的底部(对于向下增长的栈)或栈的顶部(对于向上增长的栈)³。参见图 3.2。

³ 创建 struct thread_info 并不仅仅是因为寄存器受限的体系结构。新结构也使得在汇编代码中计算其值的偏移量变得相当容易。

thread_info 结构在 x86 上的 <asm/thread_info.h> 中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
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_block restart_block;
void *sysenter_return;
int uaccess_err;
};

(图 3.2 示意图说明) 进程内核栈
栈起始处(向低地址方向增长)
- 最高内存地址
- 栈指针
- struct thread_struct (进程的 struct task_struct)
- 最低内存地址, current_thread_info()
thread_info 包含一个指向进程描述符的指针

栈的增长方向:x86 体系的栈是向下(向低地址方向)增长的。 - 栈底 (Stack Base):为了充分利用这块内存,栈被初始化为从这块分配区的最高地址 (0x10000) 开始。这个初始的栈指针位置就是栈底。它是栈的“起点”,但却是分配内存块的“末尾”。 - 栈顶 (Stack Top):随着函数调用、压入参数等操作,%esp 寄存器的值会不断减小(向 0xE000 方向移动)。%esp 当前指向的位置称为栈顶。它是栈的“当前操作端”,向低地址方向延伸。

每个任务的 thread_info 结构都分配在其栈的末端。该结构的 task 元素是一个指向任务实际 task_struct 的指针。


存储进程描述符

系统通过一个唯一的进程标识值 (process identification value)PID 来识别进程。PID 是一个由不透明类型⁴ pid_t 表示的数值,通常是一个 int。然而,为了与早期的 Unix 和 Linux 版本向后兼容,默认最大值仅为 32,768(一个 short int 的值),尽管可以选择将该值增加到高达 400 万(这由 <linux/threads.h> 控制)。内核在每个进程描述符的 pid 字段中存储此值。

⁴ 不透明类型 (opaque type):指其内部细节被隐藏,只通过特定接口访问的数据类型。

这个最大值很重要,因为它本质上是系统上可能同时存在的最大进程数。虽然 32,768 对于桌面系统可能足够,但大型服务器可能需要多得多的进程。此外,该值越低,数值回绕(wrap around)就越快,这会破坏“数值大的进程比数值小的进程更晚运行”这一有用概念。如果系统愿意打破与旧应用程序的兼容性,管理员可以通过 /proc/sys/kernel/pid_max 来增加最大值。

在内核内部,通常直接通过指向其 task_struct 结构的指针来引用任务。事实上,大多数处理进程的内核代码都直接使用 struct task_struct。因此,能够快速查找当前正在执行的任务的进程描述符是非常有用的,这是通过 current 宏来完成的。这个宏必须由每个体系结构独立实现。一些体系结构将当前运行进程的 task_struct 结构的指针保存在一个寄存器中,以实现高效访问。其他体系结构,如 x86(几乎没有多余的寄存器可用),则利用 struct thread_info 存储在内核栈上这一条件,来计算 thread_info 的位置,进而找到 task_struct

在 x86 上,current 是通过滤除栈指针的最低 13 位来获得 thread_info 结构计算的。这是由 current_thread_info() 函数完成的。汇编代码如下:

1
2
movl $-8192, %eax
andl %esp, %eax
这里假设栈大小是 8KB。当启用 4KB 栈时,使用 4096 代替 8192。

最后,current 解引用 thread_infotask 成员以返回 task_struct

1
current_thread_info()->task;

将此方法与 PowerPC(IBM 的现代基于 RISC 的微处理器)采用的方法对比,后者将当前的 task_struct 存储在一个寄存器中。因此,在 PPC 上 current 仅仅返回存储在寄存器 r2 中的值。PPC 可以采用这种方法是因为,与 x86 不同,它有大量的寄存器。由于访问进程描述符是一项常见且重要的工作,PPC 内核开发人员认为使用一个寄存器是值得的。

进程状态 (Process State)

进程描述符中的 state 字段描述了进程的当前状况(参见图 3.3)。系统中的每个进程都确切地处于五种不同状态中的一种。该值由以下五个标志之一表示:

  • TASK_RUNNING (运行):进程是可运行的;它要么正在执行,要么在运行队列(runqueue)中等待运行(运行队列将在第 4 章讨论)。这是在用户空间执行的进程唯一可能的状态;它也应用于正在内核空间中主动运行的进程。
  • TASK_INTERRUPTIBLE (可中断睡眠):进程正在睡眠(即被阻塞),等待某个条件的发生。当此条件满足时,内核会将进程的状态设置为 TASK_RUNNING。如果进程接收到信号,它也会被提前唤醒并变为可运行状态。
  • TASK_UNINTERRUPTIBLE (不可中断睡眠):此状态与 TASK_INTERRUPTIBLE 类似,区别在于即使接收到信号,它也不会被唤醒并变为可运行状态。此状态用于进程必须不受中断地等待,或者预期事件会很快发生的场合。由于处在此状态的任务不响应信号,因此 TASK_UNINTERRUPTIBLE 的使用频率低于 TASK_INTERRUPTIBLE⁵。
  • **__TASK_TRACED (被跟踪)**:进程正被另一个进程(例如调试器通过 ptrace)所跟踪。
  • **__TASK_STOPPED (停止)**:进程执行已停止;任务既不在运行,也没有资格运行。如果任务收到 SIGSTOPSIGTSTPSIGTTINSIGTTOU 信号,或者在被调试时收到任何信号,就会发生这种状态。

⁵ 这就是为什么在 ps(1) 命令中会出现那些状态为 D (即 TASK_UNINTERRUPTIBLE) 的、令人头疼的无法杀死的进程。因为该任务不响应信号,你无法向它发送 SIGKILL 信号。此外,即使你能终止该任务,这样做通常也不明智,因为该任务可能正在进行一项重要操作(并可能持有信号量)。

操作当前进程状态 (Manipulating the Current Process State)

内核代码经常需要更改进程的状态。首选机制是使用:

1
set_task_state(task, state);        /* 将任务 'task' 的状态设置为 'state' */
此函数将给定的任务设置为给定的状态。在适用的情况下,它还提供一个内存屏障 (memory barrier) 以强制在其他处理器上的执行顺序(这仅在 SMP 系统上需要)。否则,它等价于:
1
task->state = state;
方法 set_current_state(state) 等同于 set_task_state(current, state)。请参阅 <linux/sched.h> 以了解这些及相关函数的实现。


进程上下文 (Process Context)

进程最重要的部分之一是正在执行的程序代码。这些代码从可执行文件中读入,并在程序的地址空间内执行。正常的程序执行发生在用户空间 (user-space)。当程序执行系统调用或触发异常时,它就进入了内核空间 (kernel-space)。此时,内核被称为“代表进程执行”并处于进程上下文 (process context) 中。在进程上下文中,current 宏是有效的⁶。退出内核后,进程会在用户空间恢复执行,除非在此期间有更高优先级的进程变为可运行状态(这种情况下将调用调度器来选择更高优先级的进程)。

⁶ 除了进程上下文,还有中断上下文 (interrupt context)。在中断上下文中,系统并非代表某个进程运行,而是在执行一个中断处理程序。没有进程与中断处理程序相关联。

系统调用和异常处理程序是进入内核的明确定义的接口。进程只能通过这些接口之一开始在内核空间中执行——所有对内核的访问都是通过这些接口进行的。


进程家族树 (The Process Family Tree)

在 Unix 系统(Linux 也不例外)的进程之间存在着一个清晰的层次结构。所有进程都是 init 进程(其 PID 为 1)的后代。内核在启动过程的最后步骤中启动 init。然后,init 进程读取系统初始化脚本并执行更多程序,最终完成启动过程。

系统中的每个进程都有且只有一个父进程 (parent)。同样,每个进程有零个或多个子进程 (children)。同一父进程的所有直接子进程称为兄弟进程 (siblings)。进程之间的关系存储在进程描述符中。每个 task_struct 都有一个指向父进程 task_struct 的指针(名为 parent),以及一个子进程的链表(名为 children)。

因此,给定当前进程,可以通过以下代码获取其父进程的描述符:

1
struct task_struct *my_parent = current->parent;
类似地,可以这样遍历一个进程的所有子进程:
1
2
3
4
5
6
7
struct task_struct *task;
struct list_head *list;

list_for_each(list, &current->children) {
task = list_entry(list, struct task_struct, sibling);
/* task 现在指向 current 的某个子进程 */
}

init 任务的进程描述符是静态分配的,名为 init_task。所有进程间关系的一个很好例证是下面这段代码总能执行成功:

1
2
3
4
struct task_struct *task;
for (task = current; task != &init_task; task = task->parent)
;
/* 循环结束后,task 指向了 init */
实际上,你可以从系统中的任何一个进程出发,循着进程层次结构到达任何其他进程。

然而,通常我们更希望简单地遍历系统中的所有进程。这很容易,因为任务列表是一个循环双向链表 (circular doubly linked list)。给定任何一个有效任务,要获取链表中的下一个任务,可以使用:

1
list_entry(task->tasks.next, struct task_struct, tasks)
获取上一个任务的方式相同:
1
list_entry(task->tasks.prev, struct task_struct, tasks)
这两个操作分别由宏 next_task(task)prev_task(task) 提供。最后,提供了宏 for_each_process(task) 用于遍历整个任务列表。每次迭代时,task 指向列表中的下一个任务:
1
2
3
4
5
struct task_struct *task;
for_each_process(task) {
/* 这段代码无意义地打印每个任务的名称和 PID */
printk("%s[%d]\n", task->comm, task->pid);
}
注意 在拥有大量进程的系统中,遍历每个任务的开销很大;代码在这样做之前应该有充分的理由(并且没有其他替代方案)。