进程是操作系统对一个正在运行的程序的一种抽象。线程是进程中的一个执行单元。
为什么不用类似于 fork 的方式创建线程
fork 创建进程的特点
- 资源复制
1 2 3 4 5 6
| pid_t pid = fork();
|
- 开销大
- 需要复制整个进程地址空间
- 创建新的页表
- 复制文件描述符表
线程的设计目的
- 轻量级任务切换
1 2 3 4 5 6 7 8 9
| pthread_t thread; pthread_create(&thread, NULL, thread_func, NULL);
|
- 资源共享优势
如果用 fork 方式创建线程会带来的问题
- 性能问题
1 2 3
| 创建100个线程: - 当前方式:只需要分配100个栈空间 - fork方式:需要复制100次整个进程空间
|
资源浪费
通信困难
- 失去了共享内存的便利
- 需要使用 IPC 机制
- 效率降低
因此,线程的设计初衷就是要比进程更轻量级,如果用 fork 方式创建线程,就违背了这个设计初衷,失去了线程的主要优势。
Linux 中,一个多线程进程使用 fork 生成了一个新进程, 新进程中会存在几个线程? 为什么要这么设计
让我解释 Linux 中 fork 多线程进程的行为:
基本行为
- 只复制调用 fork 的线程
1 2 3 4 5 6 7
| pthread_t thread1, thread2; pthread_create(&thread1, NULL, func1, NULL); pthread_create(&thread2, NULL, func2, NULL);
pid_t pid = fork();
|
为什么这样设计
- 安全性考虑
1 2 3 4 5
| write(fd, data, size);
|
- 死锁风险
- 实现复杂性
- 复制所有线程需要:
- 重建线程间的同步关系
- 复制所有线程的上下文
- 处理线程池等复杂结构
最佳实践
- fork 后立即 exec
1 2 3 4 5
| pid_t pid = fork(); if (pid == 0) { execv(path, args); }
|
- 在主线程中 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 2 3 4 5
| struct rlimit rlim; rlim.rlim_cur = 0; rlim.rlim_max = 0; setrlimit(RLIMIT_NPROC, &rlim);
|
- 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 2 3 4 5 6
| void handle_sigchld(int sig) { while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) { } }
|
- 信号处理器设置
1 2 3
| sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
|
- 保护 errno
1 2 3
| int saved_errno = errno;
errno = saved_errno;
|
为什么这样设计
使用 while 循环处理 waitpid
- 多个子进程可能同时退出
- 一次信号可能对应多个子进程终止
- 确保处理所有已终止的子进程
使用 WNOHANG 标志
保存和恢复 errno
- 信号处理可能改变 errno
- 避免干扰主程序的错误处理
使用建议
- 错误处理
1 2 3 4
| if (sigaction(SIGCHLD, &sa, NULL) == -1) { perror("sigaction"); exit(1); }
|
- 在程序初始化时设置信号处理
- 尽早设置,避免遗漏子进程
- 确保所有 fork 之前都已设置好处理器
这种方式可以有效避免僵尸进程,同时不会影响父进程的正常工作。