hello,好久不见,我是云边有个稻草人,后续我会继续更新本篇文章所属专栏:《Linux系统》
目录
一、进程创建—回顾
二、进程终止
2.1 进程退出场景
2.2 退出码
2.3 进程的退出方法(三种)
三、进程等待
3.1 为什么要进程等待?
3.2 进程等待是什么?
3.3 获取⼦进程status
3.4 阻塞与⾮阻塞等待
四、进程程序替换
4.1 替换原理
4.2 替换函数
一、进程创建—回顾
初始父进程:代码段页表权限只读,数据段可读可写。fork 创建子进程后,父子进程共享物理页,系统将双方数据段页表权限统一改为只读,代码段依旧保持只读。任意进程尝试写数据段内存,触发缺页异常,内核执行写时拷贝,分配新物理页、拷贝数据,修改对应页表权限为可写,后续各自独立访问。
页面被标记只读,进程执行写指令时,CPU 硬件判定权限非法
触发缺页异常,陷入内核态处理
内核识别是 fork 后的写时拷贝场景,新建物理页、复制数据
更新当前进程页表权限为可写,程序正常继续执行
为什么要有写时拷贝?
减少创建时间
fork 时只复制页表,不拷贝实际数据,子进程创建秒级完成。
节约物理内存
父子共享同一份物理页面,避免重复占用空间。
按需延迟拷贝
仅数据被修改时才复制页面,减少无效拷贝开销。
二、进程终止
2.1进程退出场景
- 代码运⾏完毕,结果正确
- 代码运⾏完毕,结果不正确(例如fopen一个文件,但是打开失败了,走的是失败的逻辑路线)
- 代码异常终⽌(野指针)
子进程也是进程,子进程由父进程创建,子进程的执行结果无非就是上面的三种之一。
函数里的局部变量,到底怎么返回给外面?为什么要用寄存器?
- 局部变量存在栈(stack)里,函数结束栈会销毁,变量就没了。
- CPU 规定:用固定的寄存器存放返回值,寄存器不在栈里,不会被销毁。
- 函数返回时:
- 把局部变量的值 → 复制到寄存器
- 函数退出、栈销毁
- 调用方从寄存器里取出值使用
这就是局部变量能安全返回的根本原因。
2.2退出码
打印最近一个程序(进程)退出时的退出码,也就是进程退出码 echo $?子进程的退出码要写到子进程的task_struct内部,如果父进程要获取子进程的退出码,父进程往子进程的PCB里读就能读到子进程的退出结果。
怎么知道错误码对应的错误信息呢?
可以使⽤strerror函数来获取退出码对应的描述
可以直接return errno
所以一个进程(程序)的运行结果由进程的退出码决定。
当一个程序/进程异常终止的时候,退出码无意义。
进程一旦出现异常,一般是进程收到了信号(TODO)
2.3 进程的退出方法(三种)
(1)main:return;函数结束,表示进程结束,其他函数,只表示自己函数调用完成,返回;执⾏return n等同于执⾏exit(n),因为调⽤main的运⾏时函数会将main的返回值当做 exit的参数。
(2)exit(status),status表示进程的退出码;
#include <unistd.h> void exit(int status);看下面的现象:
所以,任何地方调用exit,表示进程结束,并返回给父进程bash子进程的退出码。
exit最后也会调⽤_exit, 但在调⽤_exit之前,还做了其他⼯作:
- 执⾏⽤⼾通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写⼊
- 调⽤_exit
(3)_exit(),status表示进程的退出码;谁调我我把谁干死(退出)
#include <unistd.h> void _exit(int status); 参数:status 定义了进程的终⽌状态,⽗进程通过wait来获取该值那exit() vs _exit()?下面进行解答:
看下面的现象:
将exit()换成_exit():
so:
\n在终端的行缓冲模式下,是强制刷新缓冲区的关键。exit()会在退出前主动刷新所有 I/O 缓冲区;而_exit()完全不会。
所以,exit()和_exit()的区别是什么?
1、
- exit():C 库函数
- _exit():Linux 系统调用
2、
库函数是对系统调用的封装,exit()的底层封装了_exit()
三、进程等待
3.1为什么要进程等待?
1、⼦进程退出,⽗进程如果不管不顾,就可能造成‘僵⼫进程’的问题,进⽽造成内存泄漏。
2、另外,进程⼀旦变成僵⼫状态,那就⼑枪不⼊,“杀⼈不眨眼”的kill -9 也⽆能为⼒,因为谁也没有办法杀死⼀个已经死去的进程。
3、最后,⽗进程派给⼦进程的任务完成的如何,我们需要知道。如,⼦进程运⾏完成,结果对还是不对,或者是否正常退出。
4、所以⽗进程通过进程等待的⽅式,回收⼦进程资源,获取⼦进程退出信息。
真正产生僵尸进程的唯一条件
👇 必须同时满足 3 点 👇
- 子进程先退出
- 父进程还活着
- 父进程没有调用 wait () /waitpid () 去回收
如果父进程先死,子进程还活着,此时的子进程就是孤儿进程:
- 子进程会被init 进程(PID=1)收养
- 子进程以后结束时,init 会自动帮它收尸
- 不会变成僵尸
3.2 进程等待是什么?
(1)wait:等待任意一个退出的子进程
#include<sys/types.h> #include<sys/wait.h> pid_t wait(int* status); 返回值: 成功,返回被等待进程pid;失败,返回-1。 参数: 输出型参数,获取⼦进程退出状态,不关⼼则可以设置成为NULL上面的代码中,如果等待子进程,子进程没有退出,父进程会阻塞在wait调用处(例如scanf)。
(2)waitpid
pid_ t waitpid(pid_t pid, int *status, int options); 返回值:当正常返回的时候waitpid返回收集到的⼦进程的进程ID;失败,则返回-1 pid:选择等待指定子进程 status: 输出型参数,让父进程获取子进程的退出信息下面我们来创建一个等待失败的场景,不存在指定的子进程:
3.3获取⼦进程status
基本认识:
有个问题:
为什么?
那如果进程异常终止呢?
进程异常终止,分两种情况:一种是代码异常终止代码崩了,一种是被外界使用信号终止
讨论一下三个问题:
(1)父进程是如何拿到子进程的退出码,退出信号的呢?属于子进程的信息,进程之间具有隔离性,怎么办到的?
父子进程正常运行用户空间互相隔离,不能直接访问对方内存。
子进程退出 / 被信号杀死
操作系统内核处理退出
- 释放子进程的代码、数据、页表、虚拟地址空间
- 把退出码、终止信号存入子进程的 PCB
- 子进程变成僵尸进程,只保留 PCB
父进程调用系统调用:waitpid ()
- 这是父进程主动向操作系统要子进程信息
- 父进程进入内核态
操作系统内核工作
- 根据 PID 找到子进程 PCB
- 从 PCB 中读取退出码、退出信号
- 把这些信息拷贝给父进程的 status 变量
内核删除子进程 PCB僵尸进程彻底消失
父进程回到用户空间解析 status,拿到子进程的退出结果
(2)WEXITSTATUS(status),WIFEXITED(status)
3.4阻塞与⾮阻塞等待
下面演示下非阻塞轮询
如何让父进程做自己的事情呢?
#include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> #include <vector> typedef void (*handler_t)(); // 函数指针类型 std::vector<handler_t> handlers; // 函数指针数组 void fun_one() { printf("这是一个临时任务1\n"); } void fun_two() { printf("这是一个临时任务2\n"); } void Load() { handlers.push_back(fun_one); handlers.push_back(fun_two); } void handler() { if (handlers.empty()) Load(); for (auto iter : handlers) iter(); } int main() { pid_t pid; pid = fork(); if (pid < 0) { printf("%s fork error\n", __FUNCTION__); return 1; } else if (pid == 0) { // child printf("child is run, pid is : %d\n", getpid()); sleep(5); exit(1); } else { int status = 0; pid_t ret = 0; do { ret = waitpid(-1, &status, WNOHANG); // 非阻塞式等待 if (ret == 0) { printf("child is running\n"); } handler(); } while (ret == 0); if (WIFEXITED(status) && ret == pid) { printf("wait child 5s success, child return code is :%d.\n", WEXITSTATUS(status)); } else { printf("wait child failed, return.\n"); return 1; } } return 0; }四、进程程序替换
4.1替换原理
在程序替换的过程中,并没有创建新的进程,只是把当前进程的代码和数据用新的程序的代码和数据覆盖式的进行替换。
1、一旦程序替换成功,就去执行新代码了,原始代码的后半部分,已经不存在了!
2、exec*函数,只有失败返回值,没有成功返回值;
所以:exec*系列的函数,不用做返回值判断,只要返回,就是失败!
execl() 之前: 进程 PID 1000 ├── file1 的代码 ├── file1 的全局数据 ├── file1 的堆 └── file1 的栈 execl() 成功之后: 进程 PID 1000 ├── ls 的代码 ├── ls 的全局数据 ├── ls 的堆 └── ls 的新栈4.2替换函数
【execl】
上面的程序替换影响到了父进程,那如何才能不影响呢?创建一个子进程,让子进程去进程程序替换
能替换我们自己写的程序吗?一切能转化为进程的程序全都能替换
现在尝试使用C语言去调用C++程序:
成功调用。同样的,也可以去调shell脚本,python
这里的“加载器”指的是:当子进程调用execl()后,系统负责把/usr/bin/ls装入子进程地址空间并启动它的机制。加载器是系统内部真正负责加载新程序的机制或组件。
父进程 | | fork() v 父进程 + 子进程 | | execl("/usr/bin/ls", ...) v 子进程中的原程序被替换为 ls【execlp】
【execv】
【execvp】
【execvpe】
putenv:给当前进程新增或修改环境变量。它只影响当前进程,以及之后由它创建并继承环境的子进程,不会反向修改父进程。
putenv("MYVAL=123");【execve】
int execve(const char *path, char *const argv[], char *const envp[]);这是真正的系统调用,execl / execlp / execv / execvp / execvpe都是基于 execve 的库封装。
至此,本篇结束——
我是云边有个稻草人
期待与你的下一次相遇!
凡是让你难受的东西,只要它是正向的,就一定能帮助你成长
告诉自己不要害怕