进程是操作系统对一个正在运行的程序的一种抽象。线程是进程中的一个执行单元。

为什么不用类似于 fork 的方式创建线程

fork 创建进程的特点

  1. 资源复制
1
2
3
4
5
6
pid_t pid = fork();
// fork会复制:
// - 内存空间
// - 文件描述符
// - 程序计数器
// 等所有资源
  1. 开销大
    • 需要复制整个进程地址空间
    • 创建新的页表
    • 复制文件描述符表

线程的设计目的

  1. 轻量级任务切换
1
2
3
4
5
6
7
8
9
pthread_t thread;
pthread_create(&thread, NULL, thread_func, NULL);
// 线程共享:
// - 代码段
// - 堆空间
// - 文件描述符
// 仅独立:
// - 栈空间
// - 寄存器
  1. 资源共享优势
    • 线程间通信成本低
    • 上下文切换快
    • 内存占用小

如果用 fork 方式创建线程会带来的问题

  1. 性能问题
1
2
3
创建100个线程:
- 当前方式:只需要分配100个栈空间
- fork方式:需要复制100次整个进程空间
  1. 资源浪费

    • 大量重复的内存页
    • 不必要的资源隔离
    • 系统开销增加
  2. 通信困难

    • 失去了共享内存的便利
    • 需要使用 IPC 机制
    • 效率降低

因此,线程的设计初衷就是要比进程更轻量级,如果用 fork 方式创建线程,就违背了这个设计初衷,失去了线程的主要优势。

Linux 中,一个多线程进程使用 fork 生成了一个新进程, 新进程中会存在几个线程? 为什么要这么设计

让我解释 Linux 中 fork 多线程进程的行为:

基本行为

  1. 只复制调用 fork 的线程
1
2
3
4
5
6
7
// 主进程有3个线程
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, func1, NULL);
pthread_create(&thread2, NULL, func2, NULL);

pid_t pid = fork(); // 假设在thread1中调用
// 子进程只会包含thread1的副本

为什么这样设计

  1. 安全性考虑
    • 如果复制所有线程,可能导致:
1
2
3
4
5
// 线程2正在进行文件写入
write(fd, data, size);
// fork发生
// 子进程的线程2会从中间继续执行
// 导致文件数据重复或损坏
  1. 死锁风险
1
2
3
4
mutex_lock(&mutex);  // 线程2持有锁
// fork发生
// 子进程中没有线程2,锁永远无法释放
// 导致死锁
  1. 实现复杂性
    • 复制所有线程需要:
      • 重建线程间的同步关系
      • 复制所有线程的上下文
      • 处理线程池等复杂结构

最佳实践

  1. fork 后立即 exec
1
2
3
4
5
pid_t pid = fork();
if (pid == 0) {
// 子进程:立即执行新程序
execv(path, args);
}
  1. 在主线程中 fork
    • 避免在子线程中调用 fork
    • 减少不确定性
    • 更容易管理资源

这种设计既保证了安全性,又简化了实现,是一个合理的折中方案。

编写至少两种使用 fork 的程序, 使 fork 执行失败, 并解释 fork 失败的原因

让我展示两种让 fork 失败的程序:

1. 耗尽系统进程数限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

int main() {
int count = 0;
while(1) {
pid_t pid = fork();
if (pid < 0) {
printf("Fork失败: %s\n", strerror(errno));
printf("成功创建进程数: %d\n", count);
exit(1);
}
if (pid == 0) {
// 子进程睡眠不退出,占用进程表
sleep(100);
} else {
count++;
}
}
return 0;
}

失败原因:

  • 系统有最大进程数限制(通过ulimit -u可查看)
  • 当达到限制时,fork 会失败并返回 EAGAIN 错误

2. 耗尽系统内存

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

#define ALLOC_SIZE (100*1024*1024) // 100MB

int main() {
// 先占用大量内存
char* memory[1000];
int i = 0;

while(1) {
memory[i] = malloc(ALLOC_SIZE);
if (memory[i] == NULL) {
break;
}
// 写入内存确保实际分配
memset(memory[i], 1, ALLOC_SIZE);
i++;

pid_t pid = fork();
if (pid < 0) {
printf("Fork失败: %s\n", strerror(errno));
printf("已分配内存: %d MB\n", i * 100);
exit(1);
}
if (pid == 0) {
sleep(100); // 子进程保持运行
}
}
return 0;
}

失败原因:

  • fork 需要复制父进程的地址空间
  • 当系统内存不足时,无法为新进程分配内存
  • 返回 ENOMEM 错误

其他可能导致 fork 失败的情况

  1. 资源限制
1
2
3
4
5
// 设置资源限制
struct rlimit rlim;
rlim.rlim_cur = 0;
rlim.rlim_max = 0;
setrlimit(RLIMIT_NPROC, &rlim); // 设置进程数限制为0
  1. cgroup 限制
  • 在容器环境中设置严格的资源限制
  • 限制进程数或内存使用

使用这些程序时要注意:

  • 可能会影响系统稳定性
  • 建议在测试环境运行
  • 运行后需要及时清理资源

当子进程终止时,由于父进程可能正忙于自己的工作,没能及时调用 wait,因此子进程将变成僵尸进程。不过,由于操作系统记录了父进程与子进程之间的关系,在子进程退出时,它会发送 SIGCHLD 信号给父进程。请基于该机制实现程序,使得父进程能及时处理子进程的退出,尽可能避免子进程成为僵尸进程

让我展示如何通过处理 SIGCHLD 信号来避免僵尸进程:

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>

// 信号处理函数
void handle_sigchld(int sig) {
int saved_errno = errno; // 保存errno
pid_t pid;

// 循环调用waitpid,处理所有已终止的子进程
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("子进程 %d 已被回收\n", pid);
}

errno = saved_errno; // 恢复errno
}

int main() {
// 设置SIGCHLD信号处理函数
struct sigaction sa;
sa.sa_handler = handle_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // 重启被中断的系统调用

if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}

// 创建多个子进程
for (int i = 0; i < 5; i++) {
pid_t pid = fork();

if (pid < 0) {
perror("fork");
exit(1);
}

if (pid == 0) { // 子进程
printf("子进程 %d 开始运行\n", getpid());
sleep(i + 1); // 每个子进程睡眠不同时间
printf("子进程 %d 即将退出\n", getpid());
exit(0);
}
}

// 父进程继续做其他工作
printf("父进程继续运行...\n");
while (1) {
sleep(1);
}

return 0;
}

关键点解释

  1. 信号处理函数设计
1
2
3
4
5
6
void handle_sigchld(int sig) {
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
// WNOHANG: 非阻塞等待
// -1: 等待任何子进程
}
}
  1. 信号处理器设置
1
2
3
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
// SA_RESTART: 重启被信号处理打断的系统调用
// SA_NOCLDSTOP: 只在子进程终止时发送信号,子进程暂停时不发送
  1. 保护 errno
1
2
3
int saved_errno = errno;
// ... 信号处理 ...
errno = saved_errno;

为什么这样设计

  1. 使用 while 循环处理 waitpid

    • 多个子进程可能同时退出
    • 一次信号可能对应多个子进程终止
    • 确保处理所有已终止的子进程
  2. 使用 WNOHANG 标志

    • 非阻塞调用
    • 避免信号处理函数阻塞
    • 提高程序响应性
  3. 保存和恢复 errno

    • 信号处理可能改变 errno
    • 避免干扰主程序的错误处理

使用建议

  1. 错误处理
1
2
3
4
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
  1. 在程序初始化时设置信号处理
    • 尽早设置,避免遗漏子进程
    • 确保所有 fork 之前都已设置好处理器

这种方式可以有效避免僵尸进程,同时不会影响父进程的正常工作。