进程创建
1.fork的本质:一次调用,两次返回
这是fork最让初学者困惑的地方。
函数原型:
#include <unistd.h> pid_t fork(void);- 现象: 你在代码里只写了一行
fork(),但程序运行后,这一行代码似乎“执行”了两次,并且返回了两个不同的值 。
为什么会有两次返回?
当你的程序执行到fork()函数内部时,控制权转移到了操作系统内核。内核做了一件惊天动地的事:
- 复制:内核以父进程为模板,克隆了一个一模一样的子进程。
- 子进程也有自己的 PCB (
task_struct)。 - 子进程也有和父进程一样的代码、数据、文件描述符等。
- 关键点:子进程的程序计数器 (PC)(记录代码执行到哪一行了)也和父进程一样,都指向
fork()函数刚刚执行完的位置 。
- 子进程也有自己的 PCB (
- 分裂:当内核处理完复制工作,准备从
fork()函数返回时,系统中已经有了两个正在运行的进程(父进程和子进程)。 - 返回:
- 内核让父进程从
fork返回,带回子进程的 PID。 - 内核让子进程从
fork返回,带回0。
- 内核让父进程从
2. 深入理解返回值:为什么是 0 和 PID?
设计这两个不同的返回值是有深刻用意的 :
- 父进程返回子进程 PID:
- 父进程可能生了很多孩子(调用多次
fork)。父进程必须拿到这个 ID,才能在将来通过waitpid(pid)准确地找到并回收这个特定的子进程,或者给它发信号。
- 父进程可能生了很多孩子(调用多次
- 子进程返回 0:
- 子进程不需要
fork告诉它父进程是谁,因为子进程可以随时调用getppid()获取父进程 ID。 - 更重要的是,返回 0 是为了让子进程知道:“我就是那个被创造出来的新生命”。
- 子进程不需要
- 出错返回 -1:
- 如果系统进程太多(内存不足或 PID 耗尽),创建失败,只会在父进程返回 -1 。
灵魂拷问:同一个变量id怎么可能既等于 0 又大于 0?
看这段经典代码:
pid_t id = fork(); if (id == 0) { // 子进程逻辑 } else if (id > 0) { // 父进程逻辑 }解释: 并非同一个变量同时有两个值。而是在两个独立的进程空间里,各有一个叫id的变量。
- 父进程里的
id变量被赋值为子进程 PID(比如 1234)。 - 子进程里的
id变量被赋值为 0。 它们只是名字相同,但在物理内存中是完全隔离的两个变量 。
3. 核心机制:写时拷贝 (Copy-On-Write, COW)
这是 Linuxfork高效的秘诀。如果不理解这个,你就无法理解为什么fork即使拷贝几 GB 内存的进程也极其迅速。
误区
早期 Unix 的fork是“傻拷贝”:父进程有 1GB 内存,fork就立马申请 1GB 物理内存,把数据全部拷贝一份给子进程。这既浪费时间,又浪费内存。
真相:Linux 的惰性策略
Linux 采用了写时拷贝技术 :
- Fork 刚完成时(只读共享):
- 父子进程的页表(虚拟地址到物理地址的映射表)是完全一样的。
- 它们指向同一块物理内存。
- 关键动作:内核把这些共享的物理内存页标记为“只读” (Read-Only)。
- 当任意一方试图写入时(触发拷贝):
- 比如子进程执行
g_val = 100;。 - CPU 发现正在往“只读”页面写入数据,触发缺页中断 (Page Fault)。
- 内核捕获这个中断,发现这是因为 COW 导致的。
- 执行拷贝:内核立刻申请一个新的物理内存页,把原来的数据拷贝过来,把新页面的权限改为“可读写”,然后让子进程的页表指向这个新页面。
- 父进程的页表依然指向旧页面(权限也恢复为可读写)。
- 比如子进程执行
结论:
- 如果父子进程都只读数据,不修改,物理内存永远共享,零拷贝。
- 只有被修改的那一页数据才会被复制。这就是为什么
fork极快,且节省内存 。
4. 虚拟地址的“欺骗”
// 伪代码 int g_val = 0; if (fork() == 0) { g_val = 100; printf("%d, %p\n", g_val, &g_val); // 输出: 100, 地址: 0x601040 } else { sleep(1); printf("%d, %p\n", g_val, &g_val); // 输出: 0, 地址: 0x601040 }- 现象:父子进程打印的
g_val地址竟然一模一样(0x601040),但值却不同。 - 解释:
0x601040是虚拟地址。父子进程拥有完全一样的虚拟地址空间布局。- 由于发生了写操作(
g_val = 100),触发了写时拷贝。 - 在物理内存层面,父进程的
0x601040映射到物理页 A。 - 子进程的
0x601040映射到物理页 B。 - 用户看到的只是虚拟地址这个“门牌号”,却不知道背后指向了不同的“房间” 。
进程的退出
1. 进程退出的三种场景
在 Linux 看来,进程退出只有三种情况 1:
- 代码跑完,结果正确:
return 0。 - 代码跑完,结果不正确:
return非 0(比如文件不存在、权限不足)。 - 代码没跑完,异常终止:程序崩溃了(野指针、除零错误),或者被信号(
kill -9)杀死了。
关键点:只有前两种情况(正常退出),进程的退出码 (Exit Code)才有意义。如果进程是异常终止的(被杀死的),它的退出码是无意义的,我们需要关心的是它是被哪个信号杀死的。
2. 退出码 (Exit Code)
- 概念:
main函数的返回值,或者exit(n)中的n。 - 规范:
0表示成功,非0表示失败(不同的数字代表不同的错误原因)。 - 查看方式:在 Shell 中运行完程序后,立刻输入
echo $?可以查看上一个进程的退出码 2。
3. 核心考点:exitvs_exit
这是面试常客。Linux 提供了两个退出函数,虽然结果都是进程结束,但过程不同。
_exit(int status):
- 身份:系统调用 (System Call),直接由内核提供 3。
- 行为:冷酷无情。立刻关闭进程,回收内存,不刷新缓冲区。如果你的
printf内容还在缓冲区里没打印出来,调用_exit后这些数据就丢了。
exit(int status):
- 身份:库函数 (Library Function),由 C 标准库提供 4。
- 行为:温柔体贴。它在调用
_exit之前,会做很多收尾工作:
- 执行用户注册的清理函数(
atexit)。 - 刷新缓冲区(这是最大的区别):把没打印出来的
printf数据强制刷到屏幕或文件中 5。 - 最后才调用
_exit。
- 执行用户注册的清理函数(
代码验证:
printf("hello"); // 注意没有 \n,数据会暂存在缓冲区 _exit(0); // 屏幕上什么都不会打印,因为缓冲区直接被丢弃了 // exit(0); // 如果换成这个,屏幕会打印 hello进程的等待
子进程死了,变成了僵尸 (Zombie),父进程必须负责回收它的资源(PCB)。
1. 为什么要等待?
- 防僵尸:解决内存泄漏问题 6。
- 获知结果:父进程需要知道子进程的任务完成得怎么样(是成功了,还是被杀死了?)7。
2. 等待的方法:wait与waitpid
A.wait(int* status)—— 简单粗暴
- 功能:等待任意一个子进程退出。
- 行为:如果子进程没退,父进程就阻塞(死等),直到有子进程退出为止 8。
B. waitpid(pid_t pid, int* status, int options) —— 精准控制 9
这是更常用的函数,因为它更灵活。
pid参数:
pid > 0:等待指定的那个子进程(比如 PID=1234)。pid = -1:等待任意子进程(等同于wait)。
options参数:
0:阻塞等待。和wait一样,子进程不完我不走。WNOHANG:非阻塞等待。
- 这是高并发程序的关键。父进程会问一下内核:“子进程结束了吗?”
- 如果没有结束,
waitpid立刻返回0,父进程可以先去干别的事,过会儿再来问(轮询)。 - 如果结束了,返回子进程 PID。
- 如果出错了,返回 -1。
3. 深度解剖:status位图
wait/waitpid的参数status是一个输出型参数。它不仅仅是一个整数,而是一个位图。我们需要像看“验尸报告”一样解读它。
我们只关注低 16 位 10:
位区域 | 含义 | 提取宏 |
高 8 位 (8-15) | 退出码(正常退出才有意义) |
|
低 7 位 (0-6) | 终止信号(异常终止才有意义) |
|
第 7 位 | Core Dump 标志 | - |
判断流程:
- 先看低 7 位(是否收到信号):
- 如果低 7 位是 0,说明是正常退出。此时再看高 8 位拿退出码。
- 如果低 7 位不是 0,说明是异常退出(被杀)。此时高 8 位的退出码是无效的,不用看。
代码示例:
int status; pid_t ret = waitpid(id, &status, 0); if (ret > 0) { if (WIFEXITED(status)) { // 宏:判断是否正常退出 (低7位是否为0) printf("正常退出,退出码: %d\n", WEXITSTATUS(status)); } else { printf("异常退出,被信号杀死: %d\n", status & 0x7F); } }进程的替换
我们在fork之后,子进程默认执行的是和父进程一样的代码(或者父进程代码的副本)。但通常我们创建子进程,是为了让它去执行一个全新的程序(比如你在 Shell 里输入ls,是希望运行/bin/ls这个程序,而不是再跑一遍 Shell 的代码)。
这时候,就需要exec函数族出场了。
1. 替换原理:
当进程调用exec系列函数时,内核会进行一场彻底的“大换血” :
- 清空:内核会把当前进程的用户空间完全清空(代码段、数据段、堆、栈统统不要了)。
- 加载:内核找到你指定的那个新程序(比如磁盘上的
ls可执行文件),把它的代码和数据加载到内存中。 - 重置:重置程序计数器 (PC),指向新程序的入口(通常是
_start->main)。 - 执行:进程开始执行新程序的代码。
核心特征 (面试考点):
- PID 不变:这就好比一个人“夺舍”了。躯壳(PCB、PID、PPID)还是原来那个,但灵魂(内存里的代码和数据)已经完全变成了另一个人 。
- 不创建新进程:
exec只是用新程序覆盖了旧程序,没有产生新的进程 ID。 - 一次调用,绝不返回:
exec函数一旦调用成功,当前进程原本后续的代码就直接灰飞烟灭了,根本没有机会执行“return”。只有在调用失败时(比如找不到文件),它才会返回 -1 。
2.exec函数族:
Linux 提供了 6 个以exec开头的库函数,它们功能一样,只是传参方式不同。记住后缀的含义就能分清了 :
- l (list):参数用列表一个个列出来,最后必须以
NULL结尾。 - v (vector):参数放进一个数组 (vector)里传进去。
- p (path):自动在环境变量
PATH里找程序,不用写全路径(比如写"ls"就会自动找/bin/ls)。 - e (env):不使用当前环境变量,而是自己组装一套环境变量传给新程序。
最常用的两个:
execl/execlp(列表传参): 适合参数已知且少的情况。
// 执行 ls -l -a // 这里的第一个 "ls" 是程序名,第二个 "ls" 是 argv[0](占位,但也得写),后面是参数 execlp("ls", "ls", "-l", "-a", NULL);execv/execvp(数组传参): 适合参数动态生成的情况(比如你自己写的 Shell,用户输入的参数个数不确定,解析后放在数组里)。
char *const argv[] = {"ls", "-l", "-a", NULL}; execvp("ls", argv);注意:只有execve是真正的系统调用 (System Call),其他 5 个都是 C 标准库封装的函数,它们底层最终都会调用execve。