[Linux]进程控制
进程控制
- 进程创建
- fork函数
- 写时拷贝
- 进程终止
- _exit函数
- 进程等待
- wait函数
- waitpid函数
- 进程程序替换
进程创建
fork函数
在 Linux系统中,fork() 函数被用来创建一个新的进程(子进程),该进程是调用进程(父进程)的副本。fork() 的特殊之处在于,它在调用后会返回两次:一次在父进程中,一次在子进程中,且两者的返回值不同。这种设计使程序能够通过简单的条件判断区分父子进程,并执行不同的逻辑。
1、fork() 的返回值机制
父进程:fork() 返回子进程的 PID(Process ID)。
子进程:fork() 返回 0。
错误:若创建子进程失败,返回 -1(仅在父进程中返回)。
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork(); // 调用 fork()
if (pid == -1) {
perror("fork 失败");
return 1;
} else if (pid == 0) {
// 子进程逻辑
printf("子进程 PID: %d\n", getpid());
} else {
// 父进程逻辑
printf("父进程 PID: %d,子进程 PID: %d\n", getpid(), pid);
}
return 0;
}
2、 为什么会有两个返回值?
fork() 的“两个返回值”本质是:父子进程从同一位置继续执行,但操作系统通过 进程上下文切换 和 内核的调度机制,为两者设置了不同的返回值。具体流程如下:
(1) 父进程调用 fork()
操作系统复制父进程的代码、数据、堆栈等资源,创建子进程。
子进程的 进程控制块(PCB) 被初始化,其内存空间通过 写时复制(COW) 与父进程共享。
(2) 内核设置返回值
父进程:内核将 fork() 的返回值设为子进程的 PID。
子进程:内核将 fork() 的返回值设为 0。
(3) 父子进程继续执行
父进程和子进程从 fork() 之后的代码开始并行执行。
通过检查返回值,程序可以确定当前进程的角色(父或子)。
3、代码执行流程
(1)调用 fork():
父进程创建子进程,两者代码段、数据段等资源被复制。
(2)内核设置返回值:
父进程得到子进程的 PID、子进程得到 0。
(3)条件分支:
子进程进入 pid == 0 分支。
父进程进入 pid > 0 分支。
(4)并行执行:
父子进程可能交替执行,具体顺序由调度器决定。
4、关键机制:写时复制(COW)
(1)物理内存共享:
fork() 创建子进程时,父子进程共享相同的物理内存页,但这些页被标记为 只读。
(2)按需复制:
当任一进程尝试修改共享内存时,触发 页错误,操作系统会复制该页到新物理内存,并更新页表映射。
(3)优势:
避免不必要的内存复制,提升 fork() 效率。
Q1: 为什么子进程返回 0,而父进程返回子进程 PID?
设计逻辑: 父进程需要知道子进程的 PID 以管理它(如等待子进程退出)。子进程通过返回值 0 明确自己的身份,避免混淆。
Q2: 如何确保父子进程不执行相同代码?
通过条件分支: if (pid == 0) 和 else 块明确区分了父子进程的逻辑。
Q3: 如果父子进程同时修改变量会冲突吗?
COW 机制保障: 修改共享内存会触发页复制,父子进程最终操作的是独立的物理页。
总结:
fork() 的“两个返回值”本质是 父子进程从同一代码位置继续执行,但内核为两者设置了不同的返回值。
通过返回值区分角色,父子进程可以执行不同的逻辑,实现并行处理。
写时复制(COW)技术优化了内存使用,避免冗余复制。
1、 fork() 前的状态
用户空间:
只有父进程在运行,执行用户代码(如调用 fork() 前的逻辑)。
内核空间:
父进程尚未进入内核态,fork() 未被触发。
2、fork() 执行时的状态
用户空间:
父进程的代码执行到 fork() 函数调用时,暂停用户态执行。
内核空间:
父进程通过系统调用进入内核态,内核完成以下操作:
创建子进程:
复制父进程的进程描述符(task_struct)、虚拟地址空间(通过写时复制)等资源。
分配唯一 PID:
为子进程分配独立的进程标识符。
初始化执行上下文:
子进程的寄存器状态(包括程序计数器 PC)设置为父进程调用 fork() 后的返回点。
挂载到调度队列:将子进程加入系统进程列表,等待调度。
3、fork() 后的状态
用户空间:
父进程:从 fork() 返回,继续执行后续代码,返回值是子进程的 PID。
子进程:从 fork() 返回,返回值是 0,执行与父进程相同的代码(从 fork() 后的指令开始)。
内核空间:
fork() 完成,内核退出系统调用,将控制权交还给用户空间。
4、父子进程的独立性
独立内存空间:
父子进程的虚拟地址相同,但通过 COW 机制,物理内存实际隔离。
独立执行流:
父子进程拥有各自的寄存器、堆栈和程序计数器,互不影响。
独立调度:
调度器可能将父子进程分配到不同的 CPU 核心上并行执行。
5、fork常规用法
1、一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
2、一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
6、fork调用失败的原因:
1、系统中有太多的进程
2、实际用户的进程数超过了限制
写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
为何要有写时拷贝?
进程具有独立性,父子皆如此
为何不在创建的时候就分开?
子进程不一定会使用父进程的所有数据,而是按需分配(写入的时候才去分配新空间)、延时分配(可以更高效的使用内存空间)
1、 虚拟地址空间的一致性
(1) 虚拟地址相同
fork() 后,子进程的虚拟地址空间是父进程的完整副本,包括代码段、数据段、堆、栈等区域的虚拟地址范围和布局。
示例:
父进程中变量 int x = 10 的虚拟地址为 0x1000,子进程中 x 的虚拟地址同样为 0x1000。
(2) 虚拟地址独立
每个进程的虚拟地址空间是逻辑隔离的,操作系统通过页表将虚拟地址映射到物理地址。
即使父子进程的虚拟地址相同,它们实际访问的物理内存可能不同(通过页表控制)。
2、物理内存的共享与分离
(1) 初始状态:共享物理内存
写时复制(COW)机制:
在 fork() 后,父子进程的页表指向相同的物理内存页,但这些页被标记为 只读。
内存高效共享:
未修改的内存区域(如代码段)始终共享物理内存,避免冗余复制。
(2) 触发写操作:物理内存分离
写入触发页错误:
当任一进程尝试修改共享页时(例如修改变量 x),会触发页错误。
内核分配新物理页:
内核为写入者分配新的物理页,复制原页内容到新页,并更新该进程的页表映射。
父子进程物理内存独立:
修改者使用新物理页,另一进程仍指向原物理页。此时,虚拟地址相同,但物理地址不同。
误解:虚拟地址不同
正确理解:
虚拟地址空间是进程独立的逻辑视图,但 fork() 后子进程的虚拟地址布局与父进程完全一致。
虚拟地址相同,物理地址可能不同。
误解:页表直接映射不同物理内存
正确理解:
页表初始映射相同物理内存,只有写入时才会分离(通过 COW)。
总结:
虚拟地址相同:父子进程的虚拟地址空间在 fork() 后完全一致。
物理内存共享与分离:
初始共享物理内存,写入时通过 COW 机制分离,保障内存安全和高效利用。
页表独立性:
父子进程的页表独立管理,动态映射物理内存,实现灵活的内存隔离。
进程终止
main函数的返回值给了谁?——OS
为什么main函数要有返回值?——运行程序的本质就是加载后形成进程。目的:为了完成某种工作,由人发起;
main函数的返回值:进程退出码——可以让我们知道代码运行的结果对不对
进程退出场景:
代码运行完毕,结果正确——进程退出码为0
代码运行完毕,结果不正确——进程退出码为非0
代码异常终止(越界、野指针、除0)——进程崩溃
./myproc
echo $? #显示打印最近一次进程退出时候的退出码
此时最近一次是上一句的echo $?
由此可见 运行正确的时候,会返回0;否则是非0
退出码为0:成功
退出码非0:失败有多种原因
打印各种错误码:
进程常见退出方法
正常终止(可以通过echo $? 查看进程退出码):
- 从main返回——main函数中的return;只有main函数中的return代表进程退出、其他函数的返回值代表函数调用结束。
- 调用exit——进程终止,即exit(退出码),exit在代码中任何地方调用exit都能让进程终止
- _exit
_exit函数
下图中,hello world没有\n因此先输出到输出缓冲区中,调用exit(0)的时候,进程退出,会刷新出相关的数据
与_exit函数对比:
此时可以发现,缓冲区中的数据没有被刷新出来
exit:会释放进程曾经占用的资源,如缓冲区
_exit:直接终止进程,不会进行任何收尾工作
exit最后也会调用exit, 但在调用exit之前,还做了其他工作:
1. 执行用户通过 atexit或on_exit定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写入
3. 调用_exit
进程异常退出,退出码还有意义吗?——没有任何意义
进程终止了,操作系统做了什么?
OS释放进程PCB、mm_struct、页表、代码和数据所占的空间;即释放曾经申请的数据结构和内存,将各种队列等数据结构中移除
进程等待
子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,则 kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,
或者是否正常退出。父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
进程等待——通常由父进程完成
为什么要有进程等待?——回收子进程资源,获取子进程退出信息
wait函数
wait函数使用方法:
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
在子进程运行期间,父进程wait()的时候,父进程在做什么?——什么都不做(阻塞等待),就在等子进程退出
父子谁先运行不确定,但是wait之后,大部分情况都是子进程先退出,父进程读取子进程退出信息后,父进程才会退出
waitpid函数
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
进程等待成功,是否意味着子进程运行成功?——不是
进程等待成功只意味着子进程退出了,但是运行成功还是运行失败还是需要判定的
(status >> 8)& 0xFF
即可获得退出状态的8位的退出码,则父进程获得了子子进程的退出码,可以知道子进程运行成功还是失败
注:status >> 8操作不会修改status的值
进程异常的时候,本质是进程运行的时候出现了某种错误,导致进程收到信号。
因为低7位是终止信号:
status & 0x7F
阻塞:一直等,什么都不做
非阻塞:也是等,不过并不会因为条件不满足而卡住
进程程序替换
替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
替换函数
其实有六种以exec开头的函数,统称exec函数:
#include <unistd.h>`
int execl(const char *path, const char *arg, ...); // 不带p需要说明可执行程序是谁,并且要说明路径
int execlp(const char *file, const char *arg, ...); // 带p需要说明要执行谁就可以 -> 带p默认在环境变量中找可执行程序
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
在 Linux 系统中,execl 函数的作用是替换当前进程的代码,而不是创建一个新进程。
进程程序替换,一经替换,绝不会返回,后续代码都不会继续执行
execl 的工作机制
功能:execl 属于 exec 函数家族,其核心作用是 替换当前进程的代码和数据,加载并执行新的程序。
特性:
如果 execl 执行成功,当前进程的代码会被新程序完全覆盖,后续代码不再执行。
如果 execl 执行失败(如路径错误),函数返回 -1,后续代码会继续执行。
int main() {
printf("I am a process!\n"); // 步骤1:打印
sleep(5); // 步骤2:休眠5秒
execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL); // 步骤3:尝试执行ls命令
printf("hello\n"); // 步骤4:此代码仅在 execl 失败时执行
return 0;
}
(1) 当 execl 执行成功时:
ls 命令会替换当前进程的代码,原进程的代码(包括 printf(“hello\n”))被完全丢弃。ls 执行完成后,进程直接退出,不会回到原程序,因此 hello 不会打印。
execl 的功能:
将当前进程的代码段、数据段、堆栈等完全替换为新的程序(如 /usr/bin/ls)。
进程的 PID、优先级、资源限制等属性保持不变。
若 execl 执行成功,原进程的代码将不再存在,取而代之的是新程序的代码。
(2) 当 execl 执行失败(传参失败、路径传递错误等)时:
execl 返回 -1,程序继续执行后续代码,hello 会被打印。程序替换失败的时候,程序后续不会受到影响。
(3)execl系列的函数,不需要判断返回值。只要返回了,则就是失败了。
注意:子进程进行execl程序替换的时候,不会影响到父进程——因为子进程和父进程有独立的地址空间,并且进程运行的时候是有独立性的,父子进程不会影响到彼此。
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH,在环境变量中找可执行程序
e(env) : 表示自己维护环境变量
此处的想怎么执行:就是在Linux的命令行中怎么写 就直接在这里写上
Makefile可以生成Makefile内的所有目标文件?——错,只能够生成从上到下扫描到的第一个可执行程序
一次可以形成2个可执行程序
同理:一次生成3个可执行程序