🧑💻进程控制 🧑💻
文章目录
- 🧑💻进程控制 🧑💻
- 一、进程创建
- 1. fork函数
- 2. fork常规用法
- 3. fork创建子进程操作系统都做了什么?
- 4. 写时拷贝
- 5. 父子进程代码的共享
- 6. fork调用失败的原因
- 二、进程中止
- 1. 进程中止时,操作系统做了什么?
- 2. 进程中止的常见方式
- 3. 查看进程退出码的方式:
- 4. 用代码如何中止一个进程
- 三、进程等待
- 1. 为什么要进行进程等待?
- 2. 进程等待的方法
- 3. 子进程的status
- 四、进程替换
- 1. 基本概念
- 2. 如何进行进程替换
- 3. 为什么要进行进程替换
一、进程创建
1. fork函数
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。 新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void); //pid_t是一个无符号整数
返回值:子进程中返回0,父进程返回子进程id,出错返回-1 ⭐
举例:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我是父进程!\n");
pid_t id = fork();
if (id < 0)
{
printf("创建子进程失败!\n");
return 1;
}
else if (id == 0)
{
while (1)
{
printf("我是子进程:pid:%d,ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
else
{
while (1)
{
printf("我是父进程:pid:%d,ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
当在Linux中写入上述代码👆的文件时,会在命令框循环打印下面的文字👇
- 除了使用系统调用函数
getpid()
,也使用系统命令查看进程,可以看出使用fork()
使该进程生成子进程。
- 因为父子进程返回的id值不同,导致父子进程执行了不同的代码。
- 两个循环也会同时执行。
2. fork常规用法
- 父子进程同时执行同一份代码的不同代码段。
- 一个进程要执行一个不同的程序。
- fork之后,谁先执行完全由调度器决定。
🌟进程 =内核数据结构+进程代码和数据
内核数据结构由OS来维护,包括PCB结构体、进程地址空间结构体、页表等等,进程代码和数据一般从磁盘中来,也就是C/C++程序加载后的结果
3. fork创建子进程操作系统都做了什么?
- fork 创建子进程就是系统中多了一个进程,先分配新的内存块和内核数据结构给子进程,再将父进程部分数据结构内容拷贝至子进程
- 将该进程的PCB结构体放到运行队列里,
fork( )
返回后等待操作系统或调度器来调度。 - 当CPU开始调度这个进程了,此时就可以通过该进程的虚拟地址空间和页表找到该进程的相关代码。
- 从上往下按照顺序在这个进程内部来执行这个进程内的代码并完成某种功能。
解释说明:
- 创建子进程,给子进程分配对应的内核结构,必须子进程独有(独立性)。
- 理论上 ,子进程也要有自己的代码和数据。
- 可是一般而言,子进程没有自己的代码和数据,只能使用父进程的代码和数据。
- 代码都是不可被写的,只能读取,所以父子共享并没有问题
- 数据是可能被修改的,所以必须分离!操作系统使用写时拷贝技术将父子进程的数据分离。
4. 写时拷贝
写时拷贝原理:
- fork之后,子进程的虚拟地址空间和页表以父进程为模板,把父进程的字段拷贝过来,指向的内容是完全一样的。
- 当用户尝试对子进程中的数据进行修改的时候(页表只读权限去除),把内存当中曾经被父子进程共享的内存区域拷贝一份给子进程,然后修改页表中的页表项,再让子进程的页表指向新申请的地址。这样在数据层面上,父子进程就实现了分离。
为什么要使用写时拷贝:
- 为了节省空间,创建子进程,不需要将不会被访问的或者只会读取的数据都拷贝一份,并且OS无法在代码执行前预知哪些空间会被访问,所以OS选择了操作系统选择了写时拷贝技术。
写时拷贝的优点:
- 操作系统使用写时拷贝技术将父子进程分离,用的时候在给你分配,写时拷贝是一种延时拷贝申请技术,可以提高整机内存的使用率。
- 因为有写时拷贝技术的存在,所以父子进程得以彻底分离,完成了进程独立性的 技术保证。
5. 父子进程代码的共享
- 代码汇编之后会有很多行代码,而且每行代码加载到内存之后都有对应的地址。
- 进程可能被中断,没有执行完,下次回来还要从刚才的位置继续运行就要求cpu必须记录下当前进程执行的位置,所以cpu有对应的寄存器(EIP / pc指针:程序计数器)数据用来记录当前进程的执行位置。
- 寄存器在cpu内只有一份,但是寄存器中的数据(进程的上下文数据)是可以有很多份的。
💡所以:当fork时,寄存器中的数据也要给子进程,子进程认为自己的EIP起始值就是fork之后的代码! 但是实际上fork之后子进程可以看到全部的代码(包括fork执行前的代码)!
6. fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、进程中止
1. 进程中止时,操作系统做了什么?
进程 =内核数据结构+进程代码和数据
- 所以进程中止时,操作系统会释放进程申请的相关的内核数据结构和对应的数据和代码,本质就是释放系统资源(主要的是内存资源,还有cpu及其他资源)
2. 进程中止的常见方式
进程有三种常见的中止方式:
- 代码跑完,结果正确
- 代码跑完,结果不正确
- 代码没有跑完,程序崩溃(本质上就是操作系统通过发送信号的方式杀掉了该进程)
第一种和第二种进程中止方式主要的区别是结果是否正确,那么代码跑完结果是否正确应该怎么判定呢❓
💡答:代码跑完结果是否正确是由进程的退出码标识的
- main函数的返回值叫做进程的退出码,返回给上一级进程,表示进程返回时结果是否正确,从而评判该进程执行的结果。常见的main函数返回值都是0,但是它也可以是其他值。
- main函数返回值0:表示运行结果正确。
- main函数返回值非0:表示运行结果不正确。
- 进程崩溃时,退出码无意义,一般而言退出码对应的return语句没有被执行。
⭐ 不同的非零值就可以标识不同的错误原因,从而当我们的程序运行结束之后,退出码可以定位错误的原因。
3. 查看进程退出码的方式:
对话框输入命令echo $?
获取最近一个进程执行完毕的退出码
当不知道退出码的含义时可以使用c语言提供的strerror函数将退出码转换成字符串描述退出码含义
# include<string.h>
streror(退出码)
4. 用代码如何中止一个进程
正常终止:
- 从main函数
return 退出码
中止进程
👀其他函数内部return叫函数返回,只有main函数内的return语句是进程退出
- 调用exit函数中止进程
#include <stdlib.h>
void exit(int status);//参数:status 定义了进程的终止状态
exit 和 return区别:
(1)exit在代码的任何地方调用都是直接中止进程
(2)return是语句,exit是函数。
- 调用_exit函数中止进程
#include <unistd.h>
void _exit(int status);//参数:status 定义了进程的终止状态
⭐_exit
是系统调用接口,exit
函数是c语言提供的库函数
exit 也会调用exit, 但在调用exit之前还会:
- 执行用户通过定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入。(💡如果printf不加 \n 数据是保存在缓冲区中并由C标准库维护的)
- 调用_exit
异常退出:
- linux下在命令行窗口输入
ctrl + c
三、进程等待
1. 为什么要进行进程等待?
- 子进程退出,父进程如果不管不顾,就可能造成僵尸进程,进而造成内存泄漏。
- 进程一旦变成僵尸状态,谁也没有办法杀死一个已经死去的进程。
- 父进程有时需要知道派给子进程的任务完成的如何。
上述问题在系统中由进程等待解决:
⭐父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
2. 进程等待的方法
wait方法
当子进程已经退出了,但是父进程还在运行,子进程就会变为僵尸进程,为了解决僵尸进程造成的内存泄露,需要采用wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status); //阻塞式等待
返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
举例:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
exit(1); //标识进程运行完毕,结果不正确
}
else if (id == 0)
{
//子进程
int cnt = 5;
while (cnt)
{
printf("cnt: %d, 我是子进程, pid: %d, ppid : %d\n", cnt, getpid(), getppid());
sleep(1);
cnt--;
}
exit(0);
}
else
{
//父进程
printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(7);
pid_t pid = wait(NULL); //阻塞式的等待!一般都是在内核中阻塞,等待被唤醒
if (pid > 0)
{
printf("等待子进程成功, pid: %d\n", pid);
}
}
}
waitpid方法
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
> 0,表示正常返回,waitpid返回收集到的子进程的进程ID
= 0,如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
< 0,如果调用中出错,则返回-1
参数:
Pid =-1,等待任一个子进程,与wait等效
Pid > 0,等待其进程ID与pid相等的子进程
status:
输出型参数,查看进程是否是正常退出以及进程的退出码
options:
options:默认为0,代表阻塞等待,设置为WNOHANG代表父进程非阻塞等待。
(系统提供的大写标记为其实就是宏,WAIT NO HANG,夯就是这个进程没有被CPU调度)
若等待成功但pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若结束,则返回该子进程的ID。
举例:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
exit(1); //标识进程运行完毕,结果不正确
}
else if (id == 0)
{
//子进程
int cnt = 5;
while (cnt)
{
printf("cnt: %d, 我是子进程, pid: %d, ppid : %d\n", cnt, getpid(), getppid());
sleep(1);
cnt--;
}
exit(0);
}
else
{
//父进程
printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());
int status = 0;
// 只有子进程退出的时候,父进程才会执行waipid函数,进行返回。父进程依然还活着!
// wait/waitpid可以在目前的情况下, 让进程退出有一定顺序性
// 可以让父进程进行更多的收尾工作
// id > 0,等待指定进程
// id =-1,等待任意一个子进程退出,等价于wait接口(wait接口属于waitpid的子集)
// options:默认为0,代表阻塞等待,设置为 WNOHANG代表父进程非阻塞等待
pid_t ret = waitpid(id, &status, 0); //默认是在阻塞状态区等待子进程状态变为退出
if (ret > 0)
{
// 0x7F -> 0000...000 0111 1111
printf("等待子进程成功, ret: %d, 子进程收到的信号编号: %d,子进程退出码: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF); //0xff --> 0000...000 1111 1111
//上面的获取子进程收到信号编号和退出码的方式比较繁琐,可以采用系统提供的status宏:
if(WIFEXITED(status))
{
//子进程是正常退出的
printf("子进程执行完毕,子进程退出码:%d\n",WEXITSTATUS(status));
}
else
{
printf("子进程异常退出:%d\n",WIFEXITED(status));
}
}
}
}
💡wait(pid,NULL,0) == wait(NULL)
- 虽然进程具有独立性,但是僵尸进程至少要保留该进程的PCB信息,task_struct里面至少要保留进程退出时的结果信息。
- 本质上父进程执行
wait/waipid
就是读取子进程的tast_struct中的exit_code,exit_signal
,父进程没有读取内核数据结构对象的权限,但是wait/waipid
是系统调用,操作系统拥有这个权限。
3. 子进程的status
- wait 和 waitpid,都有一个status参数,该参数是一个输出型参数
- 如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程
- status 不能简单当作整形来看待,要按照比特位划分
- 最低的7个比特位表示进程接受到的信号,次低8位表示进程退出的退出码
- WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出,若为假,则子进程收到的信号编号)
- WEXITSTATUS(status): 若WIFEXITED非零就提取子进程退出码。(查看进程的退出码)
父进程非阻塞等待执行案例:
#include <iostream>
#include <vector>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <unistd.h>
typedef void (*handler_t)(); //函数指针类型
std::vector<handler_t> handlers; //函数指针数组
void fun_one()
{
printf("这是一个临时任务1\n");
}
void fun_two()
{
printf("这是一个临时任务2\n");
}
// 设置对应的方法回调
// 以后想让父进程闲了执行任何方法的时候,只要向Load里面注册,就可以让父进程执行对应的方法喽!
void Load()
{
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
int main()
{
pid_t id = fork();
if(id == 0)
{
// 子进程
int cnt = 5;
while(cnt)
{
printf("我是子进程: %d\n", cnt--);
sleep(1);
}
exit(11); // 11 仅仅用来测试
}
else
{
int quit = 0;
while(!quit)
{
int status = 0;
pid_t res = waitpid(-1, &status, WNOHANG); //以非阻塞方式等待
if(res > 0)
{
//等待成功 && 子进程退出
printf("等待子进程退出成功, 退出码: %d\n", WEXITSTATUS(status));//WEXITSTATUS(status)显示子进程退出码
quit = 1;
}
else if(res == 0)
{
//等待成功 && 但子进程并未退出
printf("子进程还在运行中,暂时还没有退出,父进程可以在等一等, 处理一下其他事情??\n");
if(handlers.empty())
Load();
for(auto iter : handlers)
{
//执行处理其他任务
iter();
}
}
else
{
//等待失败
printf("wait失败!\n");
quit = 1;
}
sleep(1);
}
}
}
四、进程替换
1. 基本概念
引言
fork之后,父子进程各自执行父进程代码的一部分,但是如果子进程就想执行一个全新的程序呢❓
💡答:用进程的程序替换来完成这个功能
概念
程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据)到调用进程的地址空间中,从而让子进程达到执行其他程序的目的。
进程替换的原理⭐
-
用操作系统提供的接口
exec函数
将新的磁盘上的程序加载到内存 -
与当前进程的页表重现建立映射
调用exec并不创建新进程,所以调用exec前后该进程的id并未改变
2. 如何进行进程替换
所谓的exec函数,本质就是如何加载程序的函数,为了满足不同的调用场景,有六种以exec开头的函数:
头文件:
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
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[]);
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p会自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
int execl(const char *path, const char *arg, ...);
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
//如果不创建子进程,那么替换的只能是父进程,这样替换子进程而不影响父进程
//因为想让父进程聚焦再读取数据,解析数据,指派进程执行代码的功能
if (id == 0)
{
//子进程---子进程加载新程序的时候,是写入,发生写时拷贝并将父子代码分离
//父子进程再代码和数据上就彻底分开了
//ls-a-l
printf("子进程开始运行,pid:%d\n", getpid());
sleep(3);
execl("/user/bin/ls", "ls", "-a", "-l", NULL);
exit(1);
}
else
{
//父进程
printf("父进程开始运行,pid:%d\n", getpid());
int status = 0;
pid_t id = waitpid(-1, &status, 0);//阻塞等待,一定是子进程先运行完毕,父进程获取之后才退出!
if (id > 0)
{
printf("wait success ,exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
int execlp(const char *file, const char *arg, ...);
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
//ls-a-l
printf("子进程开始运行,pid:%d\n", getpid());
sleep(3);
//execl("/user/bin/ls", "ls", "-a", "-l", NULL);
execlp("ls", "ls", "-a", "-l", NULL);
//第一个参数表示你要执行谁--找到程序
//后面的参数表示你想怎么执行--传递选项
exit(1);
}
else
{
//父进程
printf("父进程开始运行,pid:%d\n", getpid());
int status = 0;
pid_t id = waitpid(-1, &status, 0);//阻塞等待,一定是子进程先运行完毕,父进程获取之后才退出!
if (id > 0)
{
printf("wait success ,exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
int execle(const char *path, const char *arg, ...,char *const envp[]);
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>
#define NUM 16
int main(int argc,int*argv[],int*env[])
{
pid_t id = fork();
if (id == 0)
{
printf("子进程开始运行,pid:%d\n", getpid());
//execl("/user/bin/ls", "ls", "-a", "-l", NULL);
execle("ls", "ls", "-a", "-l", NULL, env);
//环境变量具有全局属性,可以被子进程继承
exit(1);
}
else
{
//父进程
printf("父进程开始运行,pid:%d\n", getpid());
int status = 0;
pid_t id = waitpid(-1, &status, 0);//阻塞等待,一定是子进程先运行完毕,父进程获取之后才退出!
if (id > 0)
{
printf("wait success ,exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
int execv(const char *path, char *const argv[]);
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>
#define NUM 16
int main()
{
pid_t id = fork();
//如果不创建子进程,那么替换的只能是父进程,这样替换子进程而不影响父进程
//因为想让父进程聚焦再读取数据,解析数据,指派进程执行代码的功能
if (id == 0)
{
//子进程---子进程加载新程序的时候,是写入,发生写时拷贝并将父子代码分离
//父子进程再代码和数据上就彻底分开了
char* const _argv[NUM] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
NULL
};
printf("子进程开始运行,pid:%d\n", getpid());
sleep(3);
//execl("/user/bin/ls", "ls", "-a", "-l", NULL);
execv("/user/bin/ls", _argv);//这个接口和execl只有传参的差别
exit(1);
}
else
{
//父进程
printf("父进程开始运行,pid:%d\n", getpid());
int status = 0;
pid_t id = waitpid(-1, &status, 0);//阻塞等待,一定是子进程先运行完毕,父进程获取之后才退出!
if (id > 0)
{
printf("wait success ,exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
int execvp(const char *file, char *const argv[]);
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>
#define NUM 16
int main()
{
pid_t id = fork();
if (id == 0)
{
char* const _argv[NUM] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
NULL
};
printf("子进程开始运行,pid:%d\n", getpid());
//execl("/user/bin/ls", "ls", "-a", "-l", NULL);
execvp("ls", _argv);
exit(1);
}
else
{
//父进程
printf("父进程开始运行,pid:%d\n", getpid());
int status = 0;
pid_t id = waitpid(-1, &status, 0);//阻塞等待,一定是子进程先运行完毕,父进程获取之后才退出!
if (id > 0)
{
printf("wait success ,exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
int execvpe(const char*file, char*const argv[], char*const envp[])
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
//ls-a-l
printf("子进程开始运行,pid:%d\n", getpid());
sleep(3);
execvpe("ls", "ls", "-a", "-l", env);
exit(1);
}
else
{
//父进程
printf("父进程开始运行,pid:%d\n", getpid());
int status = 0;
pid_t id = waitpid(-1, &status, 0);//阻塞等待,一定是子进程先运行完毕,父进程获取之后才退出!
if (id > 0)
{
printf("wait success ,exit code:%d\n", WEXITSTATUS(status));
}
}
return 0;
}
事实上,只有execve是真正的系统调用。为了满足不同的场景,其它六个都是系统提供的基本封装,最终都调用
int execve(const char*filename, char*const argv[], char*const envp[]
💡注意事项:
exex系列的程序就是加载器的底层接口
path
—路径+目标文件名
*arg
—传入的选项
...
— 表示可变参数列表
char *const argv[]
— 表示命令行参数的指针数组
最后必须以NULL结尾
也可以用来执行自己写的程序
exec系列的函数不需要返回值判定调用其他进程是否成功,因为一旦调用成功,exec代码也被替换了,没有能接收到exec返回值的变量。
可以在后面加exit,如果调用失败,exit没被替换,就会执行exit退出程序。
3. 为什么要进行进程替换
进程替换和应用场景有关,有时候必须让子进程执行新的程序。