Maxw的小站

Maxw学习记录

26.2 孤儿进程与僵尸进程 (Orphans and Zombies)

父进程和子进程的生命周期通常并不相同——要么父进程比子进程存活时间长,要么相反。这就引出了两个问题:

1. 孤儿进程由谁接管? 当一个进程的父进程终止后,该进程就变成了“孤儿进程”。这个孤儿进程会被 init 进程(所有进程的祖先,其进程 ID 为 1)收养。换句话说,在子进程的父进程终止后,调用 getppid() 将返回值 1。这可以作为一种判断子进程的真正父进程是否仍存活的方法(前提是该子进程并非由 init 进程创建)。 使用 Linux 特有的 prctl() 系统调用的 PR_SET_PDEATHSIG 操作,可以设置一个进程在成为孤儿时接收到一个特定的信号。

2. 父进程还未执行 wait() 子进程就已终止,会发生什么? 关键在于,尽管子进程已经完成了它的工作,但仍应允许父进程在之后的某个时间点执行 wait() 来获取子进程的终止状态。内核通过将子进程转变为“僵尸进程”(Zombie)来处理这种情况。这意味着子进程持有的大部分资源会被释放回系统,供其他进程重用。进程唯一保留的部分是内核进程表中的一个条目,该条目记录了子进程的进程 ID、终止状态以及资源使用统计信息(参见 36.1 节)等信息。

关于僵尸进程,UNIX 系统模仿了电影中的设定——僵尸进程无法被信号杀死,即使是(银弹)SIGKILL 信号也不行。这确保了父进程最终总是能够执行 wait()

当父进程执行了 wait() 后,内核会清除该僵尸进程,因为关于该子进程的最后剩余信息不再需要。另一方面,如果父进程未执行 wait() 就终止了,那么 init 进程会收养该子进程,并自动执行 wait(),从而将僵尸进程从系统中移除。

如果一个父进程创建了子进程,但未能执行 wait(),那么内核进程表中将无限期地保留该僵尸子进程的条目。如果创建了大量这样的僵尸子进程,它们最终会填满内核进程表,从而阻止新进程的创建。由于僵尸进程无法用信号杀死,将它们从系统中移除的唯一方法是杀死它们的父进程(或等待其退出),届时 init 进程会收养这些僵尸进程并对它们执行 wait(),从而将它们从系统中清除。

这些语义对于需要创建大量子进程的长生命周期父进程(如网络服务器和 Shell)的设计具有重要意义。换句话说,在此类应用程序中,父进程应执行 wait() 调用,以确保已终止的子进程总是能从系统中被移除,而不是变成长期存在的僵尸进程。如 26.3.1 节所述,父进程可以同步执行这些 wait() 调用,也可以异步地(例如响应 SIGCHLD 信号的递送)执行。

以下程序演示了僵尸进程的创建以及僵尸进程无法被 SIGKILL 杀死的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <signal.h>
#include <libgen.h> /* For basename() declaration */
#include "tlpi_hdr.h"

#define CMD_SIZE 200

int main(int argc, char *argv[]){
char cmd[CMD_SIZE];
pid_t childPid;

setbuf(stdout, NULL); /* Disable buffering of stdout */
printf("Parent PID=%ld\n", (long) getpid());

switch (childPid = fork()) {
case -1:
errExit("fork");
case 0: /* Child: immediately exits to become zombie */
printf("Child (PID=%ld) exiting\n", (long) getpid());
_exit(EXIT_SUCCESS);
default: /* Parent */
sleep(3); /* Give child a chance to start and exit */
snprintf(cmd, CMD_SIZE, "ps | grep %s", basename(argv[0]));
cmd[CMD_SIZE - 1] = '\0'; /* Ensure string is null-terminated */
system(cmd); /* View zombie child */

/* Now send the "sure kill" signal to the zombie */
if (kill(childPid, SIGKILL) == -1)
errMsg("kill");
sleep(3); /* Give child a chance to react to signal */
printf("After sending SIGKILL to zombie (PID=%ld):\n", (long) childPid);
system(cmd); /* View zombie child again */

exit(EXIT_SUCCESS);
}
}
当我们运行此程序时,会看到以下输出:
1
2
3
4
5
6
7
8
$ ./make_zombie
Parent PID=1013
Child (PID=1014) exiting
1013 pts/4 00:00:00 make_zombie
1014 pts/4 00:00:00 make_zombie <defunct> (ps命令的输出)
After sending SIGKILL to zombie (PID=1014):
1013 pts/4 00:00:00 make_zombie
1014 pts/4 00:00:00 make_zombie <defunct> (ps命令的输出)
在上面的输出中,我们看到 ps(1) 命令显示字符串 <defunct> 来表示一个处于僵尸状态的进程。 示例程序使用 system() 函数来执行其字符串参数中给出的 Shell 命令。我们将在 27.6 节详细描述 system()

Linux 进程源代码指引

进程和线程是操作系统中的基本抽象概念。为支持进程和线程,操作系统使用了两个关键数据结构:task_structthread_info;以及大量用于管理系统中进程和线程的辅助函数。

  • arch/arm/include/asm/thread_info.h 文件声明了 thread_info 结构体。
  • include/linux/sched.h 文件声明了 task_struct 结构体以及许多进程管理函数。
  • arch/arm/include/asm/switch_to.h 文件为 switch_to 进程切换例程定义了一个特定于 ARM 架构的宏,而 arch/arm/kernel/entry-armv.S 文件实现了该宏所使用的汇编级进程切换例程 __switch_to

Linux 内核代码错误检查

在 Linux 用户空间编程中,函数通常返回整数,并约定返回值 0 表示函数调用成功,而不同的(通常为负的)非零值用于指示不同的错误。然而,在内核编程中,许多函数可能返回指针而非整数,这使问题变得复杂,因为指针可能使用非零值来编码作为函数成功调用结果而返回的有效内存地址。

Linux 通过利用以下事实来解决这一挑战: 1. 有效(虚拟)地址范围的上半部分未被使用。 2. 使用负值表示错误会使得其高位比特位非零,无论它们是作为指针还是整数返回。 3. 在 include/linux/err.h 文件中提供有用的宏和内联函数,这些函数可以以跨不同硬件架构的可移植方式处理指针、整数和布尔类型的不同组合:

  • IS_ERR_VALUE:检查一个(指针或整数)值的高位范围是否非空(即,包含错误值)。
  • ERR_PTR 函数:将一个(long 类型)整数值转换为(void * 类型)指针值。
  • PTR_ERR 函数:将一个(const void * 类型)指针值转换为(long 类型)整数值。
  • IS_ERR 函数:将一个(const void * 类型)指针值转换为(unsigned long 类型)整数值,使用 IS_ERR_VALUE 宏检查该值的高位范围是否非空(即,包含错误值),并相应地返回一个 bool 值。
  • IS_ERR_OR_NULL 函数:返回一个 bool 值,如果 (1) 传入的(const void * 类型)指针值为 0 (2) 将其转换为(unsigned long 类型)整数值后使用 IS_ERR_VALUE 宏检查表明该值的高位范围非空(即,包含错误值),则该函数返回 true
  • ERR_CAST 函数:将(const void * 类型)指针转换为(void * 类型)指针(去除 const 属性)。
  • PTR_ERR_OR_ZERO 函数:使用 IS_ERR 函数检查传入的(const void * 类型)指针值是否包含错误,并返回一个(int 类型)整数值:如果不包含错误则返回 0,如果包含错误则返回通过调用 PTR_ERR 函数获取的错误值。

ERR_PTRPTR_ERR

关于返回值的讨论,你现在明白了内核模块的 init 例程必须返回一个整数。但如果你希望返回一个指针呢? ERR_PTR() 内联函数为我们提供了解决方案,它允许我们通过将指针类型转换为 void * 来返回一个伪装成整数的指针。实际上还有更好的方法:你可以使用 IS_ERR() 内联函数检查错误(该函数实质上判断值是否在 [-1, -4095] 范围内),通过 ERR_PTR() 内联函数将负的错误值编码到指针中,并使用对应的例程 PTR_ERR() 从指针中检索出这个错误值。

一个简单的例子,参见下面给出的被调用方代码。这次,我们让(示例)函数 myfunc() 返回一个(指向名为 mystruct 的结构体的)指针,而不是整数:

1
2
3
4
5
6
7
8
9
struct mystruct * myfunc(void)
{
struct mystruct *mys = NULL;
mys = kzalloc(sizeof(struct mystruct), GFP_KERNEL);
if (!mys)
return ERR_PTR(-ENOMEM); /* 示例:返回编码为指针的错误码 */
/* ... 其他操作 ... */
return mys; /* 返回有效的指针 */
}

如何使用 IS_ERR 和 PTR_ERR?它们是什么意思?

根据内核定义,有三个宏: * IS_ERR - 用于检查。如果 ptr 是一个错误指针则返回非 0 值。否则,如果不是错误则返回 0。 * PTR_ERR - 用于打印。获取指针中当前(编码的错误)值。 * IS_ERR_VALUE - 在此有更详细的解释 (here1)。

我发现这些宏对于内核空间编程非常有用。用法如下 - 如果 ptr 是你要检查的指针,则按如下方式使用:

1
2
if (IS_ERR(ptr))
printk("Error here: %ld", PTR_ERR(ptr)); /* 打印出错误码 */
它们在内核中的代码定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
#define IS_ERR_VALUE(x) unlikely((x) >= (unsigned long)-MAX_ERRNO)
static inline void * __must_check ERR_PTR(long error){
return (void *) error;
}
static inline long __must_check PTR_ERR(const void *ptr){
return (long) ptr;
}
static inline long __must_check IS_ERR(const void *ptr){
return IS_ERR_VALUE((unsigned long)ptr);
}
static inline long __must_check IS_ERR_OR_NULL(const void *ptr){
return !ptr || IS_ERR_VALUE((unsigned long)ptr);
}

等待子进程 (Waiting on a Child Process)

在许多由父进程创建子进程的应用程序中,父进程能够监视子进程以了解它们于何时以及如何终止是非常有用的。wait() 系统调用及一系列相关的系统调用提供了这个功能。

wait() 系统调用 (The wait() System Call)

wait() 系统调用等待调用进程的任一子进程终止,并在 status 参数所指向的缓冲区中返回该子进程的终止状态。

1
2
#include <sys/wait.h>
pid_t wait(int *status);

返回值: 成功则返回终止子进程的进程ID (PID),出错则返回 -1。

wait() 系统调用执行以下操作:

  1. 如果调用进程的(先前未被等待的)子进程中尚无一个终止,则该调用会阻塞,直到某个子进程终止为止。如果在调用时已有子进程终止,wait() 则立即返回。
  2. 如果 status 参数不是 NULL,则关于子进程如何终止的信息会通过 status 指针所指向的整数返回。我们将在第 26.1.3 节描述 status 返回的信息。
  3. 内核会将此子进程的 CPU 时间(第 10.7 节)和资源使用统计信息(第 36.1 节)添加到其父进程所有子进程的运行总计时长中。
  4. 作为其函数结果,wait() 返回已终止子进程的进程 ID。

出错时,wait() 返回 -1。一个可能的错误是调用进程没有(先前未被等待的)子进程,这由 errnoECHILD 指示。这意味着我们可以使用以下循环来等待调用进程的所有子进程终止:

1
2
3
4
while ((childPid = wait(NULL)) != -1)
continue;
if (errno != ECHILD) /* 发生意外错误... */
errExit("wait");

以下代码演示了 wait() 的用法。该程序创建多个子进程,每个命令行整数参数对应一个子进程。每个子进程休眠其对应命令行参数所指定的秒数,然后退出。与此同时,在创建完所有子进程后,父进程反复调用 wait() 来监视其子进程的终止。此循环持续直到 wait() 返回 -1。(这不是唯一的方法:我们也可以选择当终止的子进程数量 numDead 匹配创建的子进程数量时退出循环。)

创建并等待多个子进程 ––––––––––––––––––––––––––––––––––––––––––––––––––––– procexec/multi_wait.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <sys/wait.h>
#include <time.h>
#include "curr_time.h" /* Declaration of currTime() */
#include "tlpi_hdr.h"

int main(int argc, char *argv[]){
int numDead; /* 目前已等待的子进程数量 */
pid_t childPid; /* 被等待的子进程的PID */
int j;

if (argc < 2 || strcmp(argv[1], "--help") == 0)
usageErr("%s sleep-time...\n", argv[0]);
setbuf(stdout, NULL); /* 禁用 stdout 的缓冲 */

for (j = 1; j < argc; j++) { /* 为每个参数创建一个子进程 */
switch (fork()) {
case -1:
errExit("fork");
case 0: /* 子进程:休眠一段时间后退出 */
printf("[%s] child %d started with PID %ld, sleeping %s "
"seconds\n", currTime("%T"), j, (long) getpid(), argv[j]);
sleep(getInt(argv[j], GN_NONNEG, "sleep-time"));
_exit(EXIT_SUCCESS);
default: /* 父进程:继续循环 */
break;
}
}

numDead = 0;
for (;;) { /* 父进程等待每个子进程退出 */
childPid = wait(NULL);
if (childPid == -1) {
if (errno == ECHILD) {
printf("No more children - bye!\n");
exit(EXIT_SUCCESS);
} else { /* 发生其他(意外)错误 */
errExit("wait");
}
}
numDead++;
printf("[%s] wait() returned child PID %ld (numDead=%d)\n",
currTime("%T"), (long) childPid, numDead);
}
}

下面的 shell 会话日志展示了我们使用该程序创建三个子进程时发生的情况:

1
2
3
4
5
6
7
8
$ ./multi_wait 7 1 4
[13:41:00] child 1 started with PID 21835, sleeping 7 seconds
[13:41:00] child 2 started with PID 21836, sleeping 1 seconds
[13:41:00] child 3 started with PID 21837, sleeping 4 seconds
[13:41:01] wait() returned child PID 21836 (numDead=1)
[13:41:04] wait() returned child PID 21837 (numDead=2)
[13:41:07] wait() returned child PID 21835 (numDead=3)
No more children - bye!
如果在某个特定时刻有多个子进程已终止,SUSv3 (Single UNIX Specification, version 3) 未明确规定一系列 wait() 调用回收这些子进程的顺序;也就是说,顺序依赖于实现。即使在不同的 Linux 内核版本之间,该行为也有所不同。

waitpid() 系统调用 (The waitpid() System Call)

wait() 系统调用有一些局限性,waitpid() 的设计正是为了应对这些局限性:

  • 如果一个父进程创建了多个子进程,使用 wait() 无法等待特定某个子进程的完成;我们只能等待下一个终止的子进程。
  • 如果尚无子进程终止,wait() 总是会阻塞。有时,更可取的是执行非阻塞的等待,这样如果尚无子进程终止,我们可以立即获得相应的指示。
  • 使用 wait(),我们只能获知那些已经终止的子进程的信息。无法在一个子进程被信号(如 SIGSTOPSIGTTIN停止时,或在一个被停止的子进程因收到 SIGCONT 信号而恢复执行时得到通知。
1
2
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);

返回值: 成功则返回状态发生变化的子进程的进程ID (PID);如果指定了 WNOHANG 且未有子进程状态变化则返回 0;出错则返回 -1。

waitpid() 的返回值和 status 参数与 wait() 相同。(关于通过 status 返回值的解释,请参见第 26.1.3 节)。pid 参数用于选择要等待的子进程,具体如下:

  • 如果 pid 大于 0,则等待进程 ID 等于 pid 的那个子进程。
  • 如果 pid 等于 0,则等待与调用者(父进程)属于同一进程组的任何子进程。我们将在第 34.2 节描述进程组。
  • 如果 pid 小于 -1,则等待其进程组标识符等于 pid 绝对值 (abs(pid)) 的任何子进程。
  • 如果 pid 等于 -1,则等待任何子进程。调用 wait(&status) 等价于调用 waitpid(-1, &status, 0)

options 参数是一个位掩码,可以包含(通过 OR 操作)以下零个或多个标志(所有这些标志都在 SUSv3 中指定):

  • WUNTRACED 除了返回关于已终止子进程的信息外,还会在子进程因收到信号而停止时返回其信息。
  • WCONTINUED (自 Linux 2.6.10 起) 还会在因收到 SIGCONT 信号而恢复执行的、之前被停止的子进程的状态信息。
  • WNOHANG 如果由 pid 指定的子进程尚未改变状态,则立即返回而非阻塞(即执行一次“轮询”)。在这种情况下,waitpid() 的返回值为 0。如果调用进程没有符合 pid 指定条件的子进程,则 waitpid() 失败并返回错误 ECHILD

我们将在清单 26-3 中演示 waitpid() 的用法。


附加说明 (关于 WUNTRACED 名称的由来):

在其关于 waitpid() 的原理说明中,SUSv3 指出名称 WUNTRACED 是该标志源自 BSD 的一个历史产物,在 BSD 中,一个进程可以通过两种方式之一被停止:一种是由于被 ptrace() 系统调用跟踪的结果,另一种是被信号停止(即未被跟踪)。当一个子进程被 ptrace() 跟踪时,任何信号(除了 SIGKILL)的送达都会导致该子进程被停止,并随之向父进程发送一个 SIGCHLD 信号。即使子进程忽略该信号,此行为也会发生。然而,如果子进程阻塞了该信号,则它不会被停止(除非该信号是 SIGSTOP,它无法被阻塞)。

使用c++作为笔试语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include <algorithm>
#include <climits>
#include <cmath>
using namespace std;

int main() {
// ====== 基础输入输出 ======

// 读取多个整数
int a, b, c;
cout << "输入三个整数(用空格分隔): ";
cin >> a >> b >> c;
cout << "三个整数分别是: " << a << ", " << b << ", " << c << endl;

// 清除输入缓冲区
cin.ignore(INT_MAX, '\n');

// ====== 读取整行输入 ======
cout << "\n整行输入示例:" << endl;
string line;
cout << "输入一行文本: ";
getline(cin, line);
cout << "你输入的文本是: " << line << endl;

// ====== 处理大数字 ======
cout << "\n大数字处理示例:" << endl;

// 32位大整数 (使用long)
long big32;
cout << "输入一个32位大整数: ";
cin >> big32;
cout << "32位大整数: " << big32 << endl;

// 64位大整数 (使用long long)
long long big64;
cout << "输入一个64位大整数: ";
cin >> big64;
cout << "64位大整数: " << big64 << endl;

cin.ignore(INT_MAX, '\n');

// ====== 字符串流处理 ======
cout << "\n字符串流处理示例:" << endl;
cout << "输入多个用空格分隔的整数: ";
getline(cin, line); // 使用逗号作为分隔符:getline(ss, line, ',')
stringstream ss(line);
vector<int> numbers;
int num;
while (ss >> num) {
numbers.push_back(num);
}
cout << "提取的数字: ";
for (int n : numbers) {
cout << n << " ";
}
cout << endl;

// ====== 多组输入数据 ======
cout << "\n多组输入数据示例:" << endl;
cout << "输入多行数据,每行两个整数(输入0 0结束):" << endl;
int x, y;
while (cin >> x >> y) {
if (x == 0 && y == 0) break;
cout << "和: " << (x + y) << endl;
}

// 清除状态并忽略剩余内容
cin.clear();
cin.ignore(INT_MAX, '\n');

// ====== 文件结束处理 ======
cout << "\n文件结束处理示例(输入Ctrl+Z或Ctrl+D结束):" << endl;
cout << "输入多个整数:" << endl;
vector<int> eofNumbers;
int input;
while (cin >> input) {
eofNumbers.push_back(input);
}

cin.clear();
cin.ignore(INT_MAX, '\n');

cout << "输入的数字: ";
for (int n : eofNumbers) {
cout << n << " ";
}
cout << endl;

// ====== 格式化输出 ======
cout << "\n格式化输出示例:" << endl;
double pi = 3.141592653589793;
cout << "默认输出: " << pi << endl;
cout.precision(4);
cout << "保留4位: " << pi << endl;
cout.precision(10);
cout << "保留10位: " << pi << endl;

// 恢复默认精度
cout.precision(6);

// ====== 实战示例 ======
cout << "\n实战示例: 计算一系列数字的平均值" << endl;
cout << "输入多个数字(用空格分隔): ";
getline(cin, line);
stringstream ss2(line);
double sum = 0;
int count = 0;
while (ss2 >> num) {
sum += num;
count++;
}
if (count > 0) {
cout << "平均值: " << (sum / count) << endl;
} else {
cout << "没有输入数字" << endl;
}

return 0;
}

终止进程:_exit()exit()

进程可以通过两种通用方式终止。其中一种是异常终止,由接收到一个默认动作为终止进程(可能伴随核心转储)的信号引起。另一种方式是,进程可以使用 _exit() 系统调用进行正常终止

1
2
#include <unistd.h>
void _exit(int status);

传递给 _exit()status 参数定义了进程的终止状态,该状态在此进程的父进程调用 wait() 时可用。虽然定义为 int 类型,但实际上只有 status 的低 8 位会提供给父进程。按照惯例,终止状态 0 表示进程成功完成,而非零状态值表示进程未成功终止。对于如何解释非零状态值没有固定规则;不同的应用程序遵循自己的惯例,这些惯例应在它们的文档中描述。SUSv3 规定了两个常量 EXIT_SUCCESS (0) 和 EXIT_FAILURE (1),本书中的大多数程序都使用它们。进程总是被 _exit() 成功终止(即 _exit() 从不返回)。

尽管任何在 0 到 255 范围内的值都可以通过 _exit()status 参数传递给父进程,但指定大于 128 的值可能会在 shell 脚本中引起混淆。原因是,当一个命令被信号终止时,shell 通过将变量 $? 的值设置为 128 加上信号编号 来表明这一事实,而这个值与进程以相同的状态值调用 _exit() 所产生的值无法区分。

程序通常不直接调用 _exit(),而是调用 exit() 库函数,该函数在调用 _exit() 之前会执行各种操作。

1
2
#include <stdlib.h>
void exit(int status);

exit() 执行以下操作: * 调用退出处理程序(使用 atexit()on_exit() 注册的函数),调用顺序与注册顺序相反。 * 刷新 stdio 流缓冲区。 * 使用 status 中提供的值调用 _exit() 系统调用。

与 UNIX 特有的 _exit() 不同,exit() 被定义为标准 C 库的一部分;也就是说,它在每个 C 实现中都可用。

进程终止的另一种方式是从 main() 返回,无论是通过显式 return 语句,还是通过执行到 main() 函数末尾而隐式返回。执行显式的 return n 通常等同于调用 exit(n),因为调用 main() 的运行时函数会在调用 exit() 时使用 main() 的返回值。

在一种情况下,调用 exit() 和从 main() 返回并不等效。如果在退出处理期间执行的任何步骤访问了 main() 的局部变量,那么从 main() 返回会导致未定义行为。例如,如果在调用 setvbuf()setbuf()(第13.2节)时指定了 main() 的局部变量,就可能发生这种情况。

执行不指定值的 return,或者执行到 main() 函数末尾,也会导致 main() 的调用者调用 exit(),但结果会根据所支持的 C 标准版本和所使用的编译选项而有所不同: * 在 C89 中,这些情况下的行为是未定义的;程序可能以任意状态值终止。这是在 Linux 上使用 gcc 时的默认行为,程序的退出状态取自栈上或特定 CPU 寄存器中的某个随机值。应避免以这种方式终止程序。 * C99 标准要求执行到主程序末尾应等同于调用 exit(0)。如果我们在 Linux 上使用 gcc –std=c99 编译程序,就会得到这种行为。

进程终止的细节

在进程的正常和异常终止期间,会发生以下操作: * 打开的文件描述符目录流(第18.8节)、消息目录描述符(参见 catopen(3)catgets(3) 手册页)和转换描述符(参见 iconv_open(3) 手册页)被关闭。 * 作为关闭文件描述符的后果,此进程持有的任何文件锁(第55章)都会被释放。 * 任何附加的 System V 共享内存段都会被分离(detach),并且相应每个段的 shm_nattch 计数器减一(参见第48.8节)。 * 对于进程已设置了 semadj 值的每个 System V 信号量,该 semadj 值会被添加到信号量值中(参见第47.8节)。 * 如果此进程是某个控制终端的控制进程,则 SIGHUP 信号会被发送到该控制终端前台进程组中的每个进程,并且该终端与会话分离。我们将在第34.6节进一步讨论这一点。 * 调用进程中打开的任何 POSIX 命名信号量都会被关闭,就像调用了 sem_close() 一样。 * 调用进程中打开的任何 POSIX 消息队列都会被关闭,就像调用了 mq_close() 一样。 * 如果由于此进程退出导致一个进程组变为孤儿进程组,并且该组中存在任何停止的 (stopped) 进程,则该组中的所有进程都会收到一个 SIGHUP 信号,随后是一个 SIGCONT 信号。我们将在第34.7.4节进一步讨论这一点。 * 此进程使用 mlock()mlockall()(第50.2节)建立的任何内存锁会被移除。 * 此进程使用 mmap() 建立的任何内存映射会被取消映射(unmapped)。

退出处理程序 (Exit Handlers)

有时,应用程序需要在进程终止时自动执行一些操作。考虑这样一个例子:一个应用程序库,如果在进程的生命周期中被使用,需要在进程退出时自动执行一些清理操作。由于该库无法控制进程何时以及如何退出,也不能强制主程序在退出前调用库特定的清理函数,因此无法保证清理一定会发生。在这种情况下,一种方法是使用退出处理程序(exit handler)(较老的 System V 手册使用术语“程序终止例程”)。

退出处理程序是由程序员提供的函数,在进程生命周期的某个时间点注册,然后在进程通过 exit() 正常终止时被自动调用。如果程序直接调用 _exit() 或者进程被信号异常终止,则不会调用退出处理程序。

在某种程度上,进程被信号终止时不调用退出处理程序这一事实限制了它们的实用性。我们能做的最好方式是为可能发送给进程的信号建立处理程序,并让这些处理程序设置一个标志,促使主程序调用 exit()。(因为 exit() 不在表21-1(第426页)列出的异步信号安全函数中,所以我们通常不能从信号处理程序中调用它。)即使这样,也无法处理 SIGKILL 的情况,因为它的默认动作无法更改。这是我们应避免使用 SIGKILL 终止进程(如第20.2节所述)而应使用 SIGTERM(这是 kill 命令发送的默认信号)的又一个理由。

注册退出处理程序 GNU C 库提供了两种注册退出处理程序的方法。第一种方法,由 SUSv3 规定,是使用 atexit() 函数。

1
2
#include <stdlib.h>
int atexit(void (*func)(void));

成功返回 0,错误返回非零值

atexit() 函数将 func 添加到一个函数列表中,这些函数在进程终止时被调用。函数 func 应定义为不接收参数且不返回值,因此具有以下一般形式:

1
2
3
void func(void) {
/* 执行一些操作 */
}
注意,atexit() 在出错时返回一个非零值(不一定是 -1)。

可以注册多个退出处理程序(甚至多次注册同一个退出处理程序)。当程序调用 exit() 时,这些函数按注册顺序的逆序被调用。这个顺序是合乎逻辑的,因为通常较早注册的函数执行更基本的清理类型,这些清理可能需要在后注册的函数之后执行。

本质上,可以在退出处理程序内部执行任何所需的操作,包括注册额外的退出处理程序(这些新处理程序会被放在待调用退出处理程序列表的头部)。但是,如果其中一个退出处理程序未能返回——要么是因为它调用了 _exit(),要么是因为进程被信号终止(例如,退出处理程序调用了 raise())——那么剩余的退出处理程序将不会被调用。此外,exit() 通常会执行的剩余操作(即刷新 stdio 缓冲区)也不会执行。

SUSv3 规定,如果退出处理程序自身调用 exit(),结果是未定义的。在 Linux 上,剩余的退出处理程序会正常调用。然而,在一些系统上,这会导致所有退出处理程序再次被调用,这可能引发无限递归(直到栈溢出杀死进程)。可移植的应用程序应避免在退出处理程序内部调用 exit()

SUSv3 要求实现允许一个进程至少能够注册 32 个退出处理程序。使用调用 sysconf(_SC_ATEXIT_MAX),程序可以确定实现定义的可以注册的退出处理程序数量的上限。(但是,无法查明已经注册了多少退出处理程序。)通过将注册的退出处理程序链入一个动态分配的链表,glibc 允许注册几乎无限数量的退出处理程序。在 Linux 上,sysconf(_SC_ATEXIT_MAX) 返回 2,147,482,647(即最大的有符号 32 位整数)。换句话说,在达到可注册函数数量的限制之前,其他东西(例如内存不足)就会先出问题。

通过 fork() 创建的子进程继承其父进程的退出处理程序注册的一个副本。当进程执行 exec() 时,所有退出处理程序注册都会被移除。(这必然是如此的,因为 exec() 会替换掉退出处理程序的代码以及现有程序的其余代码。)

我们无法注销一个已经用 atexit()(或下面描述的 on_exit())注册的退出处理程序。但是,我们可以让退出处理程序在执行其操作之前检查某个全局标志是否设置,并通过清除该标志来禁用该退出处理程序。

atexit() 注册的退出处理程序有几个局限性。第一个是当被调用时,退出处理程序不知道传递给 exit() 的状态(status)是什么。偶尔,了解这个状态可能有用;例如,我们可能希望根据进程是成功退出还是不成功退出执行不同的操作。第二个局限性是,我们无法在调用退出处理程序时为其指定参数。这种功能可能有助于定义一个根据其参数执行不同操作的退出处理程序,或者用不同的参数多次注册同一个函数。

为了解决这些局限性,glibc 提供了一种(非标准的)注册退出处理程序的替代方法:on_exit()

1
2
3
#define _BSD_SOURCE           /* 或者: #define _SVID_SOURCE */
#include <stdlib.h>
int on_exit(void (*func)(int, void *), void *arg);

成功返回 0,错误返回非零值

on_exit()func 参数是一个指向如下类型函数的指针:

1
2
3
void func(int status, void *arg) {
/* 执行清理操作 */
}
当被调用时,func() 被传入两个参数:提供给 exit()status 参数,以及注册该函数时提供给 on_exit()arg 参数的副本。虽然定义为指针类型,但 arg 可由程序员自由解释。它可以被用作指向某个结构的指针;同样地,通过明智地使用类型转换,它可以被视为整数或其他标量类型。

atexit() 一样,on_exit() 出错时返回非零值(不一定是 -1)。与 atexit() 一样,可以使用 on_exit() 注册多个退出处理程序。使用 atexit()on_exit() 注册的函数被放在同一个列表中。如果在同一个程序中同时使用这两种方法,则退出处理程序按使用这两种方法注册顺序的逆序调用。

虽然比 atexit() 更灵活,但 on_exit() 在旨在可移植的程序中应避免使用,因为它不受任何标准涵盖,并且在其他 UNIX 实现上很少可用。

示例程序 以下代码演示了使用 atexit()on_exit() 注册退出处理程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define _BSD_SOURCE     /* 从 <stdlib.h> 获取 on_exit() 声明 */
#include <stdlib.h>
#include "tlpi_hdr.h"

static void atexitFunc1(void){
printf("atexit function 1 called\n");
}
static void atexitFunc2(void){
printf("atexit function 2 called\n");
}
static void onexitFunc(int exitStatus, void *arg){
printf("on_exit function called: status=%d, arg=%ld\n",
exitStatus, (long) arg);
}

int main(int argc, char *argv[]){
if (on_exit(onexitFunc, (void *) 10) != 0)
fatal("on_exit 1");
if (atexit(atexitFunc1) != 0)
fatal("atexit 1");
if (atexit(atexitFunc2) != 0)
fatal("atexit 2");
if (on_exit(onexitFunc, (void *) 20) != 0)
fatal("on_exit 2");
exit(2);
}
当我们运行这个程序时,会看到以下输出:
1
2
3
4
5
$ ./exit_handlers
on_exit function called: status=2, arg=20
atexit function 2 called
atexit function 1 called
on_exit function called: status=2, arg=10

(输出顺序解释) 处理程序按注册顺序的逆序调用: * 最后注册的是 on_exit (arg=20),所以最先调用。 * 然后是 atexitFunc2。 * 然后是 atexitFunc1。 * 最后是第一个注册的 on_exit (arg=10)。

fork()、stdio 缓冲区与 _exit() 之间的交互

1
2
3
4
5
6
7
8
9
10
11
#include "tlpi_hdr.h"
int main(int argc, char *argv[]){
printf("Hello world\n");
write(STDOUT_FILENO, "Ciao\n", 5); // 直接写入当前打开的文件描述符

if (fork() == -1)
errExit("fork");

/* 父子进程都会执行到这里 */
exit(EXIT_SUCCESS);
}

以上程序的输出展示了一个起初令人费解的现象。当我们直接在终端运行此程序时,会看到预期的结果:

1
2
3
$ ./fork_stdio_buf
Hello world
Ciao
然而,当我们将标准输出重定向到一个文件时,却看到以下情况:
1
2
3
4
5
$ ./fork_stdio_buf > a
$ cat a
Ciao
Hello world
Hello world
在上面的输出中,我们看到两件奇怪的事情:由 printf() 写入的行出现了两次,并且 write() 的输出先于 printf() 的输出出现。

要理解为什么用 printf() 写入的消息会出现两次,需要回忆一下:stdio 缓冲区是在进程的用户空间内存中维护的(参见第13.2节)。因此,这些缓冲区在 fork() 时会被子进程复制

当标准输出指向终端时,默认是行缓冲的,因此由 printf() 写入的以换行符终止的字符串会立即显示。然而,当标准输出重定向到文件时,默认是块缓冲的。因此,在我们的例子中,在 fork() 发生时,由 printf() 写入的字符串仍然位于父进程的 stdio 缓冲区中,并且这个字符串被子进程复制。当父进程和子进程随后调用 exit() 时,它们都会刷新各自的 stdio 缓冲区副本,从而导致重复的输出

我们可以通过以下方法之一来防止出现这种重复输出: * 作为解决 stdio 缓冲问题的特定方案,我们可以在调用 fork() 之前使用 fflush() 来刷新 stdio 缓冲区。或者,我们可以使用 setvbuf()setbuf()禁用 stdio 流的缓冲。 * 子进程可以调用 _exit() 而不是 exit(),这样它就不会刷新 stdio 缓冲区。这项技术阐明了一个更通用的原则:在创建子进程的应用程序中,通常只有一个进程(最常见的是父进程)应该通过 exit() 终止,而其他进程应该通过 _exit() 终止。这确保了只有一个进程调用退出处理程序并刷新 stdio 缓冲区,这通常是可取的。

也存在其他允许父进程和子进程都调用 exit() 的方法(有时是必要的)。例如,可以设计退出处理程序,使得即使从多个进程调用也能正确运行;或者让应用程序在调用 fork() 之后才安装退出处理程序。此外,有时我们可能确实希望所有进程在 fork() 后都刷新其 stdio 缓冲区。在这种情况下,我们可以选择使用 exit() 终止进程,或者根据情况在每个进程中使用显式的 fflush() 调用。

示例程序中 write() 的输出没有出现两次,是因为 write() 将数据直接传输到内核缓冲区,而该缓冲区在 fork() 期间不会被复制

现在,程序输出重定向到文件时的第二个奇怪之处的原因应该很清楚了。write() 的输出出现在 printf()输出之前,是因为 write() 的输出会立即传输到内核缓冲区缓存,而 printf() 的输出只有在调用 exit() 刷新 stdio 缓冲区时才会被传输。(通常,如第13.7节所述,在同一文件上混合使用 stdio 函数和系统调用来执行 I/O 时需要小心。)

信号的概念

信号(Signal)是通知进程已发生某种事件的一种机制。信号有时被描述为软件中断(software interrupts)。信号与硬件中断类似,因为它们会中断程序的正常执行流程;在大多数情况下,无法精确预测信号何时到达。一个进程(如果它具有适当的权限)可以向另一个进程发送信号。这种用途下,信号可以作为一种同步技术,甚至作为一种原始的进程间通信(IPC) 形式。进程也可以向自己发送信号。

然而,传递给进程的许多信号的通常来源是内核(kernel)。导致内核为进程生成信号的事件类型包括:

  • 发生硬件异常:这意味着硬件检测到故障条件并通知内核,内核随后向相关进程发送相应的信号。硬件异常的例子包括:执行格式错误的机器语言指令、除以0、或引用了无法访问的内存区域。
  • 用户键入了能生成信号的终端特殊字符。这些字符包括中断字符(通常是 Control-C)和挂起字符(通常是 Control-Z)。
  • 发生软件事件。例如:文件描述符上有输入可用、终端窗口大小改变、定时器超时、进程的CPU时间限制已超过、或该进程的一个子进程终止。

每个信号都被定义为一个唯一的(小)整数,从1开始顺序编号。这些整数在 <signal.h> 头文件中用 SIGxxxx 形式的符号名定义。由于每个信号使用的实际数字因实现而异,因此在程序中总是使用这些符号名称。例如,当用户键入中断字符时,SIGINT(信号编号2)被传递给进程。

信号分为两大类。第一组构成了传统或标准信号(standard signals),内核使用它们来通知进程事件。在Linux上,标准信号编号从1到31。本章我们描述标准信号。另一组信号由实时信号(realtime signals)组成,其与标准信号的区别将在第22.8节描述。

信号被认为是由某个事件产生(generated)。一旦产生,信号随后会被递送(delivered)给一个进程,该进程随后会采取某些动作(action)来响应信号。在信号产生和递送之间的时间段,信号被称为处于等待状态(pending)。通常,一个等待中的信号会在进程下一次被调度运行时立即递送,如果进程已经在运行则立即递送(例如,进程向自己发送信号)。

然而,有时我们需要确保一段代码不会因信号的递送而中断。为此,我们可以将一个信号添加到进程的信号掩码(signal mask)中——这是一组当前被阻塞(blocked)递送的信号。如果一个信号在阻塞状态下产生,它将保持等待状态,直到后来被解除阻塞(unblocked)(从信号掩码中移除)。各种系统调用允许进程向其信号掩码中添加和移除信号。

根据信号的不同,信号被递送时,进程会执行以下默认动作(default actions)之一:

  • 忽略信号(Ignored):即信号被内核丢弃,对进程没有影响。(进程甚至不知道它发生了。)
  • 进程被终止(Terminated)(杀死)。这有时被称为异常进程终止,与进程使用 exit() 终止的正常进程终止相对。
  • 生成核心转储文件(Core dump file)且进程被终止。核心转储文件包含进程虚拟内存的一个映像,可以将其加载到调试器中,以检查进程终止时的状态。
  • 进程被停止(Stopped)——进程的执行被暂停。
  • 进程被恢复(Resumed)执行——在之前被停止后恢复执行。

程序可以改变信号递送时发生的动作,而不是接受特定信号的默认动作。这被称为设置信号的处置方式(disposition)。程序可以为信号设置以下处置方式之一:

  • 发生默认动作。这对于撤销之前将信号处置方式更改为非默认值的操作很有用。
  • 忽略信号。这对于那些默认动作是终止进程的信号很有用。
  • 执行一个信号处理程序(signal handler)。信号处理程序是由程序员编写的函数,它执行适当的任务以响应信号的递送。例如,shell 有一个用于 SIGINT 信号(由中断字符 Control-C 产生)的处理程序,该处理程序使其停止当前正在做的事情并将控制权返回给主输入循环,从而再次向用户显示 shell 提示符(用户按下 Control-C -shell中断当前处理-用户可以再次在shell中输入指令了)。通知内核应调用某个处理函数通常被称为安装(installing)或建立(establishing)一个信号处理程序。当信号处理程序因信号递送而被调用时,我们说信号已被处理(handled)或,同义词,被捕获(caught)。 注意:不可能将信号的处置方式设置为终止或转储核心(除非其中一个是该信号的默认处置方式)。最接近这一点的是为该信号安装一个处理程序,然后该处理程序调用 exit()abort()abort() 函数(第21.2.2节)为进程生成一个 SIGABRT 信号,这会导致其转储核心并终止。

Linux特有的 /proc/PID/status 文件包含各种位掩码字段,可以检查这些字段以确定进程对信号的处理情况。位掩码以十六进制数显示,最低有效位代表信号1,左边下一位代表信号2,依此类推。这些字段是: * SigPnd(线程内等待信号,per-thread pending signals) * ShdPnd(进程范围内等待信号,process-wide pending signals;自Linux 2.6起) * SigBlk(阻塞信号,blocked signals) * SigIgn(忽略信号,ignored signals) * SigCgt(捕获信号,caught signals)。 (当我们第33.2节描述多线程进程中的信号处理时,SigPndShdPnd 字段之间的区别将变得清晰。)同样的信息也可以使用 ps(1) 命令的各种选项来获取。

fork(), exit(), wait()execve() 概述

  • fork() fork() 系统调用允许一个进程(称为父进程)创建一个新的进程(称为子进程)。这是通过使新的子进程成为父进程的(近乎)完全副本来实现的:子进程获取父进程栈、数据、堆和文本段(第6.3节)的副本。“Fork”一词源于我们可以将父进程视为分裂 (forking) 以产生自身的两个副本这一构想。

  • exit(status) exit() 库函数终止一个进程,使该进程使用的所有资源(内存、打开的文件描述符等)可供内核后续重新分配。status 参数是一个整数,用于确定进程的终止状态。通过 wait() 系统调用,父进程可以检索此状态。 exit() 库函数是基于 _exit() 系统调用构建的。在第25章,我们将解释这两个接口之间的区别。在此我们只需注意,在 fork() 之后,通常只有父进程和子进程中的一个通过调用 exit() 终止;另一个进程应使用 _exit() 终止。

  • wait(&status) wait(&status) 系统调用有两个目的。首先,如果该进程的某个子进程尚未调用 exit() 终止,那么 wait()暂停该进程的执行,直到它的一个子进程终止为止。其次,子进程的终止状态通过 wait()status 参数返回

  • execve(pathname, argv, envp) execve(pathname, argv, envp) 系统调用将一个新的程序(pathname,带有参数列表 argv 和环境列表 envp加载到一个进程的内存中。现有的程序文本被丢弃,并为新程序全新创建栈、数据和堆段。此操作通常被称为 execing 一个新程序。后面我们会看到,有几个库函数是基于 execve() 构建的,每个函数都在编程接口上提供了有用的变体。当我们不关心这些接口变体时,我们遵循通用惯例,将这些调用统称为 exec(),但请注意,并没有叫这个名字的系统调用或库函数。

与其他系统的对比: 一些其他操作系统将 fork()exec() 的功能组合到单个操作中——即所谓的 spawn——该操作创建一个新进程然后执行指定的程序。相比之下,UNIX 的方法通常更简单、更优雅。将这两个步骤分开使得 API 更简单(fork() 系统调用不需要参数),并且允许程序在两个步骤之间执行的操作具有极大的灵活性。此外,只进行 fork() 而不接着执行 exec() 通常也很有用。

SUSv3 规定了可选的 posix_spawn() 函数,它结合了 fork()exec() 的效果。此函数以及 SUSv3 规定的几个相关 API 已在 glibc 中为 Linux 实现。SUSv3 规定 posix_spawn() 是为了允许为那些不提供交换设施或内存管理单元(这在许多嵌入式系统中很典型)的硬件架构编写可移植应用程序。在此类架构上,传统的 fork() 难以或无法实现。

协同工作概述: fork(), exit(), wait(), 和 execve() 通常是如何一起使用的。(shell 持续执行一个循环,该循环读取命令、对其进行各种处理,然后 fork 一个子进程来 exec 该命令。)

创建新进程:fork()

fork() 系统调用创建一个新的进程,即子进程,它是调用进程,即父进程的一个几乎完全相同的副本。

理解 fork() 的关键在于认识到,在它完成工作后,存在两个进程,并且在每个进程中,执行都从 fork() 返回的地方继续。两个进程执行相同的程序代码,但它们拥有独立的栈、数据和堆段副本。子进程的栈、数据和堆段最初是父进程内存相应部分的精确副本。在 fork() 之后,每个进程都可以修改其栈、数据和堆段中的变量,而不会影响另一个进程

在程序代码中,我们可以通过 fork() 的返回值来区分这两个进程:

  • 对于父进程fork() 返回新创建子进程的进程ID (PID)。这很有用,因为父进程可能会创建多个子进程,并因此需要(通过 wait() 或其相关函数)跟踪它们。
  • 对于子进程fork() 返回 0
  • 如果无法创建新进程,fork() 返回 -1。失败的可能原因包括:已达到允许该(真实)用户ID创建的进程数的资源限制(RLIMIT_NPROC,在第36.3节描述),或者已达到系统范围内可创建进程数的上限。

必要时,子进程可以使用 getpid() 获取自身的进程ID,使用 getppid() 获取其父进程的进程ID。

调用 fork() 时有时会使用以下惯用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
pid_t childPid; /* 在父进程中用于记录成功 fork() 后的子进程 PID */

switch (childPid = fork()) {
case -1: /* fork() 失败 */
/* 处理错误 */

case 0: /* 成功 fork() 后的子进程进入此处 */
/* 执行子进程特定的操作 */
break; // 或 exit()

default: /* 成功 fork() 后的父进程进入此处 */
/* 执行父进程特定的操作 */
}

重要的是要认识到,在 fork() 之后,无法确定接下来是哪个进程被调度使用CPU。在编写不佳的程序中,这种不确定性可能导致称为竞争条件 (race conditions) 的错误,我们将在第24.4节进一步描述。

代码清单24-1演示了 fork() 的用法。该程序创建一个子进程,修改它在 fork() 期间继承的全局变量和自动变量的副本。在程序中(由父进程执行的代码中)使用 sleep(),是为了让子进程能在父进程之前被调度到CPU上,从而使子进程可以在父进程继续执行之前完成其工作并终止。使用 sleep() 这种方式并不是保证此结果的万无一失的方法;我们将在第24.5节探讨一种更好的方法。 代码清单 24-1: 使用 fork()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "tlpi_hdr.h"

static int idata = 111; /* 分配在数据段 (data segment) */
int
main(int argc, char *argv[])
{
int istack = 222; /* 分配在栈段 (stack segment) */
pid_t childPid;

switch (childPid = fork()) {
case -1:
errExit("fork");
case 0: /* 子进程分支 */
idata *= 3; /* 修改继承的变量副本 */
istack *= 3; /* 修改继承的变量副本 */
break;
default: /* 父进程分支 */
sleep(3); /* 给子进程一个执行的机会 */
break;
}

/* 父进程和子进程都会执行到这里 */
printf("PID=%ld %s idata=%d istack=%d\n", (long) getpid(),
(childPid == 0) ? "(child) " : "(parent)", idata, istack);
exit(EXIT_SUCCESS);
}
当我们运行清单24-1中的程序时,会看到以下输出:
1
2
3
$ ./t_fork
PID=28557 (child) idata=333 istack=666
PID=28556 (parent) idata=111 istack=222
上面的输出证明,子进程在 fork() 时获得了栈段和数据段的自有副本,并且它能够修改这些段中的变量而不影响父进程

进程创建 (Process Creation)

Unix 中的进程创建方式是独特的。大多数操作系统实现一种 spawn 机制来在新地址空间中创建新进程、读入可执行文件并开始执行。Unix 则采用了一种不寻常的方法,将这些步骤分离成两个不同的函数:fork()exec()⁷。第一个函数 fork() 创建一个作为当前任务副本的子进程。它与父进程的区别仅在于其 PID(是唯一的)、其 PPID(父进程的 PID,被设置为原始进程的 PID)以及某些资源和统计信息(如待处理的信号,这些不被继承)。第二个函数 exec() 将一个新的可执行文件加载到地址空间中并开始执行。fork() 后接 exec() 这种组合,类似于大多数操作系统提供的单一函数。

⁷ 这里的 exec() 指的是 exec() 函数家族中的任何成员。内核实现了 execve() 系统调用,基于此实现了 execlp(), execle(), execv(), 和 execvp()

写时复制 (Copy-on-Write)

传统上,在 fork() 时,父进程拥有的所有资源都会被复制,并将副本交给子进程。这种方法很朴素且低效,因为它复制了许多本可以共享的数据。更糟糕的是,如果新进程要立即执行一个新的映像(image),所有这些复制都将被浪费。在 Linux 中,fork() 是通过使用写时复制 (Copy-on-Write 或 COW) 页来实现的。写时复制是一种延迟或完全防止数据复制的技术。父进程和子进程可以共享一份单一的副本,而不是复制整个进程地址空间。

然而,数据会被标记,如果对其进行写入,就会创建一个副本,每个进程都会收到一个独一无二的副本。因此,资源的复制仅发生在它们被写入时;在此之前,它们以只读方式共享。这种技术将地址空间中每一页的复制延迟到实际被写入的时候。如果这些页永远不被写入——例如,如果在 fork() 之后立即调用 exec()——它们就永远不需要被复制。

fork() 产生的唯一开销是复制父进程的页表 (page tables) 以及为子进程创建一个唯一的进程描述符。在常见的场景中(进程在 fork 后立即执行一个新的可执行映像),这种优化避免了大量数据(整个地址空间,很容易达到几十兆字节)的浪费性复制。这是一个重要的优化,因为 Unix 哲学鼓励快速的进程执行。

Forking

Linux 通过 clone() 系统调用来实现 fork()。这个调用接受一系列标志(flags),用以指定父进程和子进程应共享哪些资源(如果有的话)。(关于这些标志的更多信息,请参阅本章后面的“Linux 的线程实现”一节。)fork(), vfork(), 和 __clone() 库调用都使用必要的标志来调用 clone() 系统调用。而 clone() 系统调用又会调用 do_fork()

forking 的大部分工作由 do_fork() 处理,该函数定义在 kernel/fork.c 中。此函数调用 copy_process(),然后启动进程运行。有趣的工作是由 copy_process() 完成的:

  1. 它调用 dup_task_struct(),该函数为新进程创建一个新的内核栈、thread_info 结构和 task_struct。新的值与当前任务的值完全相同。此时,子进程和父进程的描述符是完全相同的。
  2. 然后检查确保新的子进程不会超过当前用户所能拥有的进程数量资源限制。
  3. 子进程需要与父进程区分开来。进程描述符的各种成员被清除或设置为初始值。不被继承的进程描述符成员主要是统计信息。task_struct 中的大部分值保持不变。
  4. 子进程的状态被设置为 TASK_UNINTERRUPTIBLE 以确保它还不会运行。
  5. copy_process() 调用 copy_flags() 来更新 task_structflags 成员。PF_SUPERPRIV 标志(表示任务是否使用了超级用户权限)被清除。PF_FORKNOEXEC 标志(表示进程尚未调用 exec())被设置。
  6. 它调用 alloc_pid() 为新任务分配一个可用的 PID。
  7. 根据传递给 clone() 的标志,copy_process() 要么复制要么共享打开的文件、文件系统信息、信号处理程序、进程地址空间和命名空间。这些资源通常在给定进程的线程之间共享;否则,它们是唯一的,因此在这里被复制。
  8. 最后,copy_process() 进行清理工作,并向调用者返回一个指向新子进程的指针。

回到 do_fork(),如果 copy_process() 成功返回,新的子进程会被唤醒并运行。内核有意地让子进程先运行⁸。在子进程通常只是立即调用 exec() 的常见情况下,这消除了如果父进程先运行并开始写入地址空间可能发生的任何写时复制开销。

⁸ 虽然目标是让子进程先运行,但这目前(指原书写作时)并不能正确运行。

vfork()

vfork() 系统调用与 fork() 效果相同,但不会复制父进程的页表项 (page table entries)。相反,子进程作为父进程地址空间中的唯一线程执行,并且父进程被阻塞,直到子进程调用 exec() 或退出。不允许子进程写入地址空间。在引入这个调用的旧版 3BSD 时代,这是一个受欢迎的优化,因为当时还没有使用写时复制页来实现 fork()。如今,有了写时复制和子进程优先运行的语义,vfork() 的唯一好处就是不复制父进程的页表项

如果 Linux 有一天实现了写时复制页表项,那么 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_release() 函数中(该函数在任务退出内存地址空间时使用),会检查 vfork_done 是否为 NULL。如果不是,则向父进程发送信号。
  5. 回到 do_fork(),父进程被唤醒并返回。

如果这一切都按计划进行,那么子进程现在在新的地址空间中执行,而父进程则在其原始地址空间中恢复执行。开销是降低了,但实现并不优雅。

⁹ 页表写时复制可作为补丁,已大概率加入主流内核

Linux 的线程实现 (The Linux Implementation of Threads)

线程是一种流行的现代编程抽象。它们在同一程序的共享内存地址空间内提供多个执行线程。它们还可以共享打开的文件和其他资源。线程实现了并发编程,并且在多处理器系统上实现了真正的并行。

Linux 拥有一个独特的线程实现。对 Linux 内核而言,没有“线程”的概念。Linux 将所有线程实现为标准进程。Linux 内核并不提供任何特殊的调度语义或数据结构来表示线程。相反,一个线程仅仅是一个与其他进程共享某些资源的进程。每个线程都有一个唯一的 task_struct,并且在内核看来就是一个普通的进程——线程只是恰巧与其他进程共享了资源(例如地址空间)。

这种线程实现方法与 Microsoft Windows 或 Sun Solaris 等操作系统形成了巨大对比,这些操作系统在内核中提供了对线程的明确支持(有时称线程为轻量级进程 (Lightweight Processes))。“轻量级进程”这个名字概括了 Linux 与其他系统在哲学上的差异。对这些其他操作系统而言,线程是一种抽象,旨在提供比笨重进程更轻量、更快速的执行单元。而对 Linux 而言,线程仅仅是进程之间共享资源的一种方式(而进程本身已经相当轻量)¹⁰。

例如,假设一个包含四个线程的进程。在具有显式线程支持的系统中,可能会存在一个进程描述符,该描述符又指向四个不同的线程。进程描述符描述共享资源,如地址空间或打开的文件。线程则描述它们独自拥有的资源。相反,在 Linux 中,简单地存在四个进程,因而有四个普通的 task_struct 结构。这四个进程被设置为共享某些资源。结果非常优雅。

¹⁰ 例如,可以对比 benchmark 一下 Linux 的进程创建时间与其他操作系统的进程(甚至线程!)创建时间。结果对 Linux 有利。

创建线程 (Creating Threads)

线程的创建方式与普通任务相同,区别在于需要向 clone() 系统调用传递一些标志(flags),这些标志指定了要共享的特定资源:

1
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
上述代码产生的行为与普通的 fork() 相同,但地址空间、文件系统资源、文件描述符和信号处理程序是共享的。换句话说,新任务和其父进程就是通常所说的线程。相比之下,一个普通的 fork() 可以实现为:
1
clone(SIGCHLD, 0);
vfork() 可以实现为:
1
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
提供给 clone() 的标志有助于指定新进程的行为,并详细说明父进程和子进程将共享哪些资源。表 3.1 列出了在 <linux/sched.h> 中定义的 clone 标志及其作用。

(表 3.1 Clone 标志及含义) | 标志 (Flag) | 含义 (Meaning) | | :----------------------- | :----------------------------------------------------------------------------- | | CLONE_FILES | 父子进程共享打开的文件。 | | CLONE_FS | 父子进程共享文件系统信息(如根目录、当前工作目录)。 | | CLONE_IDLETASK | 将 PID 设置为零(仅由空闲任务使用)。 | | CLONE_NEWNS | 为子进程创建新的命名空间 (namespace)。 | | CLONE_PARENT | 子进程与父进程拥有相同的父进程(即调用者的父进程)。 | | CLONE_PTRACE | 继续跟踪子进程。 | | CLONE_SETTID | 将 TID(线程 ID)写回用户空间。 | | CLONE_SETTLS | 为子进程创建新的 TLS(线程本地存储)。 | | CLONE_SIGHAND | 父子进程共享信号处理程序和阻塞的信号掩码。 | | CLONE_SYSVSEM | 父子进程共享 System V SEM_UNDO 语义。 | | CLONE_THREAD | 父子进程位于同一个线程组中。这是将新进程标识为线程而非普通进程的关键标志。 | | CLONE_VFORK | 使用了 vfork(),父进程将睡眠直到子进程唤醒它。 | | CLONE_UNTRACED | 不允许跟踪进程强制对子进程使用 CLONE_PTRACE。 | | CLONE_STOP | 以 TASK_STOPPED 状态启动进程。 | | CLONE_CHILD_CLEARTID | 在子进程中清除 TID。 | | CLONE_CHILD_SETTID | 在子进程中设置 TID。 | | CLONE_PARENT_SETTID | 在父进程中设置 TID。 | | CLONE_VM | 父子进程共享地址空间。这是创建线程的关键标志之一。 |

内核线程 (Kernel Threads)

内核在后台执行某些操作通常很有用。内核通过内核线程 (kernel threads) 来实现这一点——内核线程是仅存在于内核空间的标准进程。内核线程与普通进程的显著区别在于内核线程没有地址空间(它们的 mm 指针指向它们的地址空间,为 NULL)。它们只在内核空间中运行,不会上下文切换到用户空间。然而,内核线程与普通进程一样,是可调度和可抢占的。

Linux 将一些任务委托给内核线程,最著名的是 flush 任务和 ksoftirqd 任务。您可以在 Linux 系统上运行 ps -ef 命令来查看内核线程。数量很多!

-ef 是 every full(info) 的简写。在所有进程中,CMD 带 [],TTY 为 ?,PPID 为 2 或 0, UID是0或者root,是内核线程的特征

内核线程在系统启动时由其他内核线程创建。实际上,内核线程只能由另一个内核线程创建。内核通过从 kthreadd 内核进程 fork 出所有新的内核线程来自动处理此事。在 <linux/kthread.h> 中声明的,用于从现有内核线程生成新内核线程的接口是:

1
2
3
struct task_struct *kthread_create(int (*threadfn)(void *data),
void *data,
const char namefmt[], ...);
新任务由 kthread 内核进程通过 clone() 系统调用创建。新进程将运行 threadfn 函数,该函数接收 data 参数。进程将被命名为 namefmt,该参数接受在可变参数列表中的 printf 风格格式化参数。进程创建时处于不可运行状态 (unrunnable state);只有在通过 wake_up_process() 显式唤醒后,它才会开始运行。可以使用单个函数 kthread_run() 来创建并使进程可运行:
1
2
3
struct task_struct *kthread_run(int (*threadfn)(void *data),
void *data,
const char namefmt[], ...);
这个例程(作为宏实现)简单地调用了 kthread_create()wake_up_process()
1
2
3
4
5
6
7
8
9
#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_create() 返回的 task_struct 结构体的地址):
1
int kthread_stop(struct task_struct *k);

进程终止 (Process Termination)

这很遗憾,但进程最终都会消亡。当一个进程终止时,内核会释放该进程所拥有的资源,并通知其父进程关于子进程消亡的消息。

通常,进程销毁是自我诱导 (self-induced) 的。当进程调用 exit() 系统调用时就会发生,这可以是在它准备终止时显式调用,也可以是在从任何程序的 main 子程序返回时隐式调用(即 C 编译器会在 main() 返回后放置一个对 exit() 的调用)。进程也可能非自愿地 (involuntarily) 终止。这发生在进程收到一个它无法处理或忽略的信号或异常时。

无论进程如何终止,大部分工作都由 do_exit() 处理(定义在 kernel/exit.c 中),它完成一系列收尾工作:

  1. 它在 task_structflags 成员中设置 PF_EXITING 标志。
  2. 它调用 del_timer_sync() 来移除任何内核定时器。确保返回时没有定时器在排队,也没有定时器处理程序在运行。
  3. 如果启用了 BSD 进程记账 (process accounting),do_exit() 会调用 acct_update_integrals() 来写出记账信息。
  4. 它调用 exit_mm() 来释放该进程持有的 mm_struct。如果没有其他进程在使用这个地址空间(即地址空间未被共享),内核就会销毁它。
  5. 它调用 exit_sem()。如果进程正在排队等待一个 IPC 信号量,它在这里被移出队列。
  6. 然后它调用 exit_files()exit_fs() 来分别递减与文件描述符和文件系统数据相关的对象的引用计数。如果某个引用计数降为零,说明该对象不再被任何进程使用,随即被销毁。
  7. 它将任务的退出码(存储在 task_structexit_code 成员中)设置为 exit() 提供的代码或任何强制终止它的内核机制所提供的代码。退出码存储在这里,供父进程选择性检索。
  8. 它调用 exit_notify() 来向任务的父进程发送信号,将该任务的任何子进程重新设定父进程 (reparent) 给其线程组中的另一个线程或 init 进程,并将任务的退出状态(存储在 task_struct 结构的 exit_state 中)设置为 EXIT_ZOMBIE
  9. do_exit() 调用 schedule() 来切换到新进程(参见第 4 章)。因为该进程现在已不可调度,这是该任务将执行的最后代码。do_exit() 永不返回。

至此,与任务关联的所有对象(假设该任务是唯一使用者)都已释放。该任务不可运行(并且不再有地址空间可供运行),并处于 EXIT_ZOMBIE(僵尸)退出状态。它占用的唯一内存是它的内核栈、thread_info 结构和 task_struct 结构。该任务存在的唯一目的是向其父进程提供信息。在父进程检索到信息,或通知内核它不感兴趣之后,进程持有的剩余内存将被释放并返回给系统使用。

移除进程描述符 (Removing the Process Descriptor)

do_exit() 完成后,已终止进程的进程描述符仍然存在,但该进程已成为僵尸 (zombie) 且无法运行。如前所述,这使得系统能够在子进程终止后获取其信息。因此,清理进程之后和移除其进程描述符是分开的两个步骤。在父进程获取了已终止子进程的信息,或向内核表示不关心之后,子进程的 task_struct 才会被释放。

wait() 函数族是通过一个单一(且复杂)的系统调用 wait4() 实现的。标准行为是挂起调用任务的执行,直到它的一个子进程退出,此时函数返回退出子进程的 PID。此外,还提供一个指针给该函数,该指针在返回时持有已终止子进程的退出码。

当最终要释放进程描述符时,会调用 release_task()。它执行以下操作: 1. 它调用 __exit_signal(),后者又调用 __unhash_process(),继而调用 detach_pid() 来将进程从 pidhash 中移除,并从任务列表中移除该进程。 2. __exit_signal() 释放这个已死亡进程使用的任何剩余资源,并完成统计和簿记工作。 3. 如果该任务是线程组的最后一个成员,并且领导者 (leader) 是僵尸进程,那么 release_task() 会通知僵尸领导者的父进程。 4. release_task() 调用 put_task_struct() 来释放包含进程内核栈和 thread_info 结构的内存页,并释放包含 task_struct 的 slab 缓存。

至此,进程描述符以及仅属于该进程的所有资源都已被释放。

无父任务的困境 (The Dilemma of the Parentless Task)

如果一个父进程在其子进程之前退出,必须存在某种机制来将任何子任务重新设定父进程 (reparent) 给一个新进程,否则,没有父进程的已终止进程将永远保持僵尸状态,浪费系统内存。解决方案是在退出时将一个任务的子进程重新设定父进程给当前线程组中的另一个进程,如果失败,则设定给 init 进程。do_exit() 调用 exit_notify(),后者调用 forget_original_parent(),该函数又调用 find_new_reaper() 来执行重新设定父进程的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static struct task_struct *find_new_reaper(struct task_struct *father)
{
struct pid_namespace *pid_ns = task_active_pid_ns(father);
struct task_struct *thread;

thread = father;
while_each_thread(father, thread) {
if (thread->flags & PF_EXITING)
continue;
if (unlikely(pid_ns->child_reaper == father))
pid_ns->child_reaper = thread;
return thread;
}
...
return pid_ns->child_reaper; // 通常是 init 进程
}

(代码已简化,核心逻辑是查找同一线程组内未退出的线程,或最终返回 init 进程)

这段代码尝试在进程的线程组中查找并返回另一个任务。如果线程组中没有其他任务,它就查找并返回 init 进程。找到合适的新父进程后,需要找到每个子进程并将其重新设定父进程给这个新的“收割者”(reaper):

1
2
3
4
5
6
7
8
9
reaper = find_new_reaper(father);
list_for_each_entry_safe(p, n, &father->children, sibling) {
p->real_parent = reaper;
if (p->parent == father) {
BUG_ON(p->ptrace);
p->parent = p->real_parent;
}
reparent_thread(p, father);
}

然后调用 ptrace_exit_finish() 做同样的事情,但是针对一个被 ptrace 跟踪 (ptraced) 的子进程列表。

同时拥有一个子进程列表 (child list) 和一个被跟踪子进程列表 (ptraced list) 的基本原理很有趣;这是 2.6 内核的一个新特性。当一个任务被 ptrace 跟踪时,它被临时重新设定父进程给调试进程。然而,当该任务的原始父进程退出时,它必须与其兄弟姐妹一起被重新设定父进程。在之前的内核中,这会导致需要循环遍历系统中的每个进程来查找子进程。解决方案就是简单地维护一个进程被 ptrace 跟踪的子进程的独立列表——将查找子进程的范围从系统中的每个进程缩小到仅仅两个相对较小的列表。

随着进程成功被重新设定父进程,就不再存在 stray zombie processes( stray zombie processes)的风险。init 进程会例行地对其子进程调用 wait(),清理分配给它的任何僵尸进程。

进程

一个进程是一个正在执行中的程序(存储在某种介质上的目标代码)。然而,进程不仅仅只是执行的程序代码(在 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);
}
注意 在拥有大量进程的系统中,遍历每个任务的开销很大;代码在这样做之前应该有充分的理由(并且没有其他替代方案)。

可加载的内核模块

练习 1

准备好实验报告


练习 2: 编译内核模块

  1. 步骤:
    1
    2
    3
    4
    5
    6
    7
    # 在 Linux Lab 集群上
    mkdir -p /project/scratch01/compile/your-username/modules
    cd /project/scratch01/compile/your-username/modules
    # 保存 simple_module.c 和 Makefile (内容: obj-m := simple_module.o)
    module add arm-rpi
    export LINUX_SOURCE=/path/to/your/linux_source/linux # 设置内核源码路径
    make -C $LINUX_SOURCE ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- M=$PWD modules
  2. 答案: 提交 make 命令的完整输出。

练习 3: 加载模块与系统日志

  1. 步骤 (在树莓派上):
    1
    2
    3
    4
    5
    mkdir ~/modules
    # 使用 sftp 将 simple_module.ko 传输到此目录
    sudo dmesg --clear
    sudo insmod ~/modules/simple_module.ko
    dmesg # 查看日志
  2. 答案: 提交 dmesg 中显示的模块加载信息。

练习 4: 验证模块列表与卸载

  1. 步骤:
    1
    2
    3
    4
    lsmod # 确认 simple_module 在列表中
    sudo rmmod simple_module
    lsmod # 确认已移除
    dmesg # 查看卸载信息
  2. 答案: 提交 lsmod 的输出(证明已卸载)和 dmesg 中显示的模块卸载信息。

练习 5: 访问内核变量 jiffies

  1. 步骤:
    • 复制 simple_module.cjiffies_module.c
    • 修改其 init 和 exit 函数,使用 printk 打印 jiffies 变量(类型 extern unsigned long volatile jiffies;)。
    • 更新 Makefile: obj-m += jiffies_module.o
    • 重新编译并传输 jiffies_module.ko 到树莓派。
    • 加载并卸载模块,观察 dmesg
  2. 答案:
    • 提交 dmesg 中显示加载和卸载时 jiffies 值的日志消息。
    • 计算并说明两条消息之间发生的滴答数(tick count)。

需提交的内容

请提交一个包含以下内容的压缩包或文档: 1. 答案文件: 包含上述所有练习的答案。 2. 源代码文件: 你新创建的 jiffies_module.c 文件。


可选拓展练习

练习 6: 模块初始化返回值实验

  • 任务: 修改 init 函数,使其分别返回正数和负数(错误码,参见 /include/uapi/asm-generic/errno-base.h)。
  • 提示: 描述加载模块时发生的情况及其在系统日志中的表现。

练习 7: 探查导出的内核符号

  • 任务: 查看 /proc/kallsyms 文件(例如 cat /proc/kallsyms),了解内核符号表。查找带有 __kstrtab___ksymtab_ 前缀的符号(这些是可供模块使用的导出符号)。
  • 提示: 内核符号是 Linux 内核代码中定义的函数、变量、数据结构等的名称标签。它们代表了在内核地址空间中的一个特定内存地址。 可以将内核符号理解为内核的“公共接口”或“入口点”。主要有两种类型:
  • 导出的符号:这些是内核明确声明为可以被外部模块使用的符号。例如,printk(内核的 printf)、kmalloc(内核的内存分配函数)等。模块通过使用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 宏来导出它们的符号,以便其他模块可以使用。
  • 非导出的符号:这些是内核内部的静态函数或变量,只在它们被定义的文件或内核的特定部分中使用。它们对于内核模块是不可见的,主要用于内核自身的组织
  • EXPORT_SYMBOL(printk) 在编译后创建了两个内部符号:__ksymtab_printk 和 __kstrtab_printk。
    • __ksymtab_printk 是一个结构体,包含了 printk 的地址和指向 __kstrtab_printk 的指针。
    • __kstrtab_printk 是一个字符串,存储着 printk 的名字。
  • 内核使用这个结构来高效地通过名字查找函数的地址

练习 8: 实现用户态读取 CPU 周期计数器 (CCNT) 的模块

  • 任务 (如果之前在系统调用工作室未完成):
    • 将提供的驱动文件放入你的内核源码树的 arch/arm/include/asm 目录。
    • 下载 enable_ccnt.c 内核模块代码到你的模块目录。
    • 编译、传输并加载此模块 (insmod enable_ccnt.ko)。
    • 使用 dmesg | tail 验证加载成功。
  • 任务 (主要部分):
    • 在树莓派上创建一个用户态程序。
    • 该程序应 #include 你获取的驱动头文件。
    • 调用 unsigned long long pmccntr_get(void) 函数两次。
  • 答案:
    • 说明运行一次 pmccntr_get() 函数大约需要多少 CPU 周期(计算两次调用的差值)。
    • 如果你在系统调用工作室完成过类似的练习,请对比直接调用此函数与通过系统调用获取周期计数所需的周期数。

文章内容来自 LWN.net

文章摘要 (Summary)

这篇发表于 2013 年 2 月的文章讨论了 Linux 内核中 IDR 子系统 API 的一次重大简化改革,由开发者 Tejun Heo 主导。IDR 机制用于高效分配和管理整数 ID(例如设备名、POSIX 定时器 ID 等),其旧 API 因其复杂性和潜在的竞争条件而闻名。

核心问题:旧 API 的缺陷 1. 两步分配:需要先调用 idr_pre_get() 预分配内存(可休眠),再调用 idr_get_new() 获取 ID(可原子上下文)。 2. 必须重试循环idr_get_new() 可能因预分配内存被其他 CPU 耗尽而失败(返回 -EAGAIN),要求调用者编写冗长且易错的循环重试代码。 3. 全局资源竞争idr_pre_get() 预分配的内存是全局的,多个 CPU 竞争时,后执行的 idr_get_new() 可能因资源不足而失败,迫使代码退出原子上下文进行重试,这条路径往往缺乏测试。

解决方案:新 API 的改进 Tejun Heo 引入了三个新函数来简化流程: 1. idr_preload(gfp_t gfp_mask): 为当前 CPU 预分配内存,并禁用抢占以防止预分配的内存被偷。 2. idr_alloc(...): 单次调用即可完成 ID 分配和关联。它接受 ID 范围参数,并仅在真正需要时(未预分配或预分配不足)才使用 gfp_mask 分配内存。它只会在内存分配彻底失败时报错,消除了对 -EAGAIN 的重试循环需求。 3. idr_preload_end(): 在 idr_alloc 后调用,重新启用抢占

关键优势: * 更简单:消除了遍布内核的百余处重复、易错的样板代码。 * 更可靠:通过每 CPU 预分配和禁用抢占,基本消除了在原子上下文中因资源竞争而失败的需要。 * 更灵活idr_alloc 可以指定 ID 范围,并且如果能在进程上下文调用,甚至可以完全省略 idr_preload/idr_preload_end

社区反应: 尽管大部分开发者接受了这个改动(给出了 Acked-by),但 Eric Biederman 表达了强烈反对,认为新 API 的 idr_preload 像是一种难以理解的“魔法”。然而,文章作者(Jonathan Corbet)预测,新 API 带来的巨大简化优势将使其最终被内核社区接受

新旧 API 对比总结

特性 | 旧 API (2013 年前) | 新 API (Tejun Heo 提议) |
:--- | :--- | :--- |
核心函数 | idr_pre_get(), idr_get_new() | idr_preload(), idr_alloc(), idr_preload_end() |
调用模式 | 两步过程,必须配合重试循环 | 单次调用 (idr_alloc),无需循环 |
预分配内存 | 全局共享,易被其他 CPU 消耗 | 每 CPU 独享,配合禁用抢占,不会被偷 |
错误处理 | 可能返回 -EAGAIN,要求调用者重试 | 仅在所有内存分配都失败时才报错 |
原子上下文 | 支持,但重试时必须退出原子上下文 | 更好支持,通过 preload/preload_end 保障 |
代码复杂度 | 高,需要大量重复的样板代码 | 低,调用逻辑非常简洁 |

结论

这篇文章记录了一个经典的内核优化案例:通过巧妙的设计(利用每 CPU 数据和禁用抢占)将一个复杂、易错、充满竞争条件的旧接口,重构为一个简洁、可靠、高效的新接口。尽管存在一些争议,但简化并提升广泛使用的底层 API 的价值是极其巨大的,这很可能是新方案最终被采纳的原因。这正是 Linux 内核持续演进的一个缩影。

188.买卖股票的最佳时机IV

买卖股票三的扩展版,从最多两次交易扩充到k次交易。注意买入和卖出某个数量的股票都要单列一列dp,并采用不同的状态转移方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
vector<vector<int>> dp(prices.size(), vector<int>(2*k+1, 0));
for(int i = 1; i<=2*k; i++){
if(i % 2 == 1)dp[0][i] -= prices[0];
}
if(prices.size() == 1)return 0;
for(int i = 1; i < prices.size(); i++){
for(int j = 1; j<=2*k; j+=2){
dp[i][j] = max(dp[i-1][j-1] - prices[i], dp[i-1][j]);
}
for(int j = 2; j<=2*k; j+=2){
// printf("index of prices: %d, sold out stock id: %d\n", i, j);
dp[i][j] = max(dp[i-1][j-1] + prices[i], dp[i-1][j]);
}
}
return dp[prices.size()-1][2*k];
}
};

309.最佳买卖股票时机含冷冻期

我这道题还是写复杂了。其实不规定买入次数,就不需要每次买卖都开一个状态了,每天都计算持有/非持有状态即可,还可以省空间复杂度 我采用的思路延续了每次买入都有两个不同状态的方案,当计算卖出的时候往前比较两天,就是需要注意第二天持有股票状态的递推公式的特殊情况(选择第二天买入还是第一天买入)。其实采用仅四个状态也可以: 状态一:持有股票状态(今天买入股票,或者是之前就买入了股票然后没有操作,一直持有) 不持有股票状态,这里就有两种卖出股票状态 - 状态二:保持卖出股票的状态(两天前就卖出了股票,度过一天冷冻期。或者是前一天就是卖出股票状态,一直没操作) - 状态三:今天卖出股票 状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size() == 1)return 0;
int status = prices.size()*2;
vector<vector<int>> dp(prices.size(), vector<int>(status+1, 0));
for(int i = 1; i <= status; i += 2){
dp[0][i] -= prices[0];
}
for(int i = 1; i < prices.size(); i++){
for(int j = 1; j <= status; j += 2){
// printf("prices: %d, hold stock cnt: %d\n", i, j);
if(i >= 3){
dp[i][j] = max(dp[i-2][j-1] - prices[i], dp[i-1][j]);
}else{
dp[i][j] = max(-prices[i], dp[i-1][j]);
}
}
for(int j = 2; j <= status; j += 2){
dp[i][j] = max(dp[i-1][j-1] + prices[i], dp[i-1][j]);
}
}
// for(auto l: dp){
// cout<<"prices line ";
// for(auto i: l){
// cout<<i<<' ';
// }
// cout<<endl;
// }
return dp[prices.size()-1][status];
}
};

714.买卖股票的最佳时机含手续费

这题延续 122.买卖股票的最佳时机II 的思路即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
if(prices.size() == 1)return 0;
int status = prices.size()*2;
vector<vector<int>> dp(prices.size(), vector<int>(2, 0));
dp[0][1] -= prices[0];
for(int i = 1; i< prices.size(); i++){
dp[i][0] = max(dp[i-1][1]+prices[i]-fee, dp[i-1][0]);
dp[i][1] = max(dp[i-1][0]-prices[i], dp[i-1][1]);
}
// for(int i = 0; i<dp.size(); i++){
// for(int j = 0; j<dp[0].size(); j++){
// cout<<dp[i][j]<<' ';
// }
// cout<<endl;
// }
return dp[prices.size()-1][0];
}
};

0%