目录
1. 进程创建
1.1 fork函数
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h> pid_t fork(void); 返回值:子进程中返回0,父进程返回子进程id,出错返回-1
1.2 fork系统调用内部宏观流程
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
fork创建子进程,系统中多了一个进程,及包含进程对应的PCB结构体以及对应的地址空间及页表映射关系,并将代码和数据加载到内存中,并将该进程加载到运行队列,等待操作系统调度器的调度!一旦该进程被调度起来,CPU就可以通过代码和数据及地址空间和页表映射,在物理内存找到对应的代码,进行运行!
当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以 开始它们自己的旅程,看如下程序。
int main( void ) { pid_t pid; printf("Before: pid is %d\n", getpid()); if ( (pid=fork()) == -1 ) perror("fork()"),exit(1); printf("After:pid is %d, fork return %d\n", getpid(), pid); sleep(1); return 0; } 运行结果: [root@localhost linux]# ./a.out Before: pid is 43676 After:pid is 43676, fork return 43677 After:pid is 43677, fork return 0
这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after 消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示
所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。
1.3 fork后子进程执行位置分析
fork前后,父子进程所有代码共享!
- 代码汇编后,加载到内存,都有与之对应的地址
- 因为进程随时可能被中断(可能并没执行结束),下次回来,还必须从之前的位置继续执行(不是最开始位置),就需要要求CPU必须随时记录当前进程执行的位置,所以,CPU有对应的寄存器数据EIP(PC指针,也叫做程序计数器,用于记录当前正在执行代码的下一行代码的地址),因此在进程切换时,需要将寄存器信息带走(上下文数据)。
寄存器在CPU内,只有一份(切换进程必须带走数据),寄存器内的数据,是可以有多份的(寄存器上下文数据)创建子进程的时候,也会将上下文数据给子进程(解释了fork之后,子进程不会从before处运行),虽然父子进程各自调度,各自会修改EIP,但是不重要了,子进程已经认为自己的EIP起始值,是fork之后的代码
1.4 fork后共享代码分析
创建子进程,给予进程分配对应的内核结构,必须子进程自己独有了,因为进程具有独立性!理论上,子进程也要有自己的代码和数据!可是一般而言,我们没有加载的过程,也就是说,子进程没有自己的代码和数据!!所以,子进程只能”使用“父进程的代码和数据!
代码:都是不可被写的,只能读取,所以父子共享,没有问题!
数据:可能被修改的,所以,必须分离!
对于数据而言:
- 创建进程的时候,就直接拷贝分离吗?(导致可能拷贝子进程根本就不会用到的数据空间,即便是用到,也可能只是只读)
- 编译器在编译时,尚且知道节省空间,操作系统同样如此,如分批加载挂起状态
- 因此创建子进程,不需要将不会被访问的或者只读取的数据,再去拷贝一份,浪费空间
但是,还有必须拷贝的数据,什么样的数据值得拷贝?将来会被父子进程写入的数据!!如上图g_val或者接收fork返回值的变量
- 一般而言,即便是操作系统,也无法知道那些空间可能被写入!
- 即便是提前拷贝了,也可能不会立马使用,因此造成空间浪费!
- 所以操作系统提供了写时拷贝技术,来对父子进程的数据进行分离!
1.5 fork返回值
- 子进程返回0,
- 父进程返回的是子进程的pid
1.6 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
因为有写时拷贝技术的存在,所以,父子进程得以彻底分离!完成了进程的独立性的技术保证!(写时拷贝的好处)写时拷贝只在写入数据时发生拷贝,且对只读数据和代码不发生拷贝,节省空间!写时拷贝,是一种延时申请技术,可以提高整机内存的使用效率!
1.7 fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
1.8 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
2.进程终止
进程终止时,操作系统本质上释放进程申请的相关内核数据结构和对应的代码和数据(本质上是释放资源)
2.1 进程退出场景
进程终止的常见方式?
a. 代码跑完,结果正确
b. 代码跑完,结果错误
c. 代码没有跑完,进程崩溃(信号部分)
指针a和b方式,涉及问题:
main函数的返回值?main函数返回值的意义是什么?return 0; 含义是什么?为什么总是0?其他值是否可以?
main函数的返回值并不是总是0,返回0表示程序sucess,非0表示运行结果不正确。
意义一:main函数的返回值适用于返回上一级进程,用来评判该进程执行结果用的,为进程的退出码。
意义二:非零值有无数个,不同的非零值就可以标识不同的错误原因!在我们程序运行结束之后,结果不正确时,根据返回码,方便定位错误的原因细节!
2.2 strerror函数—返回描述错误号的字符串
#include <string.h> char *strerror(int errnum); int strerror_r(int errnum, char *buf, size_t buflen); /* XSI-compliant */ char *strerror_r(int errnum, char *buf, size_t buflen); /* GNU-specific */
#include<unistd.h> #include<stdio.h> #include<sys/types.h> #include<string.h> int main(){ printf("Hello world! pid = %d, ppid = %d\n", getpid(), getppid()); for(int i = 0; i < 150; ++i){ printf("%d: %s\n", i, strerror(i)); } return 0; }
2.3 进程常见退出方法
正常终止(可以通过 echo $? 查看进程退出码):
1. 从main返回
2. 调用exit
3. _exit
异常退出:
ctrl + c,信号终止
2.4 _exit函数和exit函数
_exit函数是系统提供的系统调用接口:
#include <unistd.h> void _exit(int status); 参数:status 定义了进程的终止状态,父进程通过wait来获取该值
说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。
exit函数是C语言库提供的接口:
#include <unistd.h> void exit(int status);
exit 或者 _exit 在任何地方调用,都表示直接终止进程!!!
Linux提供了系统调用接口_exit()函数在unistd.h头文件中,而C的库函数exit底层实则封装了系统调用接口_exit:
- 执行用户通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
测试代码:
int main() { printf("hello"); exit(0); } 运行结果: [root@localhost linux]# ./a.out hello[root@localhost linux]# int main() { printf("hello"); _exit(0); } 运行结果: [root@localhost linux]# ./a.out [root@localhost linux]#
分析图:
2.5 return退出
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返 回值当做 exit的参数。
有且只有在main函数内,return语句,就是终止进程的!return退出码!
3. 进程等待
3.1 进程等待必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对, 或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
3.2 进程等待方法
wait方法:
#include<sys/types.h> #include<sys/wait.h> pid_t wait(int*status); 返回值: 成功返回被等待进程pid,失败返回-1。 参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
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。
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退 出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
- option默认为0,表示阻塞等待,status为输出型参数
- waitpid(pid, NULL, 0) == wait(NULL)
3.3 获取子进程status
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特 位):
测试代码:
#include <sys/wait.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> int main() { pid_t pid; if ( (pid=fork()) == -1 ) perror("fork"); exit(1); if ( pid == 0 ){ //子进程休眠20秒,会变成僵尸进程 //需要父进程等待回收 sleep(20); exit(10); } else { //父进程 int st; int ret = wait(&st);//阻塞等待 if ( ret > 0 && ( st & 0X7F ) == 0 ){ // 正常退出 //子进程退出码 printf("child exit code:%d\n", (st>>8)&0XFF); } else if( ret > 0 ) { // 异常退出 //子进程退出信号 printf("sig code : %d\n", st&0X7F ); } } return 0; }
测试结果:
[root@localhost linux]# ./a.out #等20秒退出 child exit code:10 [root@localhost linux]# ./a.out #在其他终端kill掉 sig code : 9
进程异常退出,或者崩溃,本质是操作系统杀掉了你的进程!!!
操作系统如何杀掉进程呢!本质是通过发送信号的方式!
父进程通过wait或者waitpid可以拿到进程的退出结果,为什么要用wait/waitpid函数呢!直接全局变量不行吗??
答案是不行,因为进程具有独立性,其创建子进程虽然具有相同虚拟内存地址,但是数据会发生写时拷贝,父进程无法获取到,况且如果是信号,更是无法获取!
既然进程具有独立性,进程退出码,不也是子进程的数据吗??父进程又凭什么拿到呢??wait/waitpid究竟干了什么呢??
因为wait、waitpid是系统调用接口,是系统创建的结构,可以访问内核,本质便是读取子进程的task_struct结构,此结构肯定包含了对应的信号码和退出码!
3.4 进程阻塞等待和非阻塞等待
options:WNOHANG选项,代表父进程非阻塞等待!本质为了避免魔术数字#define WNOHANG 1;
Linux C语言写的 -> 系统调用接口 -> OS自己提供的接口 ->就是C语言函数 -> 系统提供的一般大写的标记位 WNOHANG,其实就是宏定义!
WNOHANG —— Wait No Hang(等待过程中没夯住),夯住本质就是指这个进程没有被CPU调度,要么是在阻塞队列,要么是在等待被调度!
3.5 waitpid系统调用接口分析
阻塞等待和非阻塞等待,一般都是在内核中阻塞,等待被唤醒,如scanf和cin,底层必定封装了系统调用(阻塞等待),父进程通过调用waitpid来进行等待,如果子进程没有退出,waitpid这个系统调用,立马返回(非阻塞)
3.6 阻塞等待代码和基于非阻塞调用的轮询检测方案
阻塞等待:
#include<stdio.h> #include<unistd.h> #include<sys/wait.h> #include<stdlib.h> #include<sys/types.h> #include<string.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(111); }else{ //父进程 printf("我是父进程, pid = %d, ppid = %d \n",getpid(), getppid()); int status = 0; pid_t ret = waitpid(id, &status, 0); //printf("获取子进程退出信号: %d \n获取子进程退出码 %d\n", status & 0x7F,(status >> 8) & 0xFF); //pid_t ret = wait(NULL); //阻塞方式进行等待 if(WIFEXITED(status)){ printf("等待进程成功, ret = %d, 子进程退出码:%d\n", ret, WEXITSTATUS(status)); } } return 0; }
基于非阻塞调用的轮询检测方案:
#include<iostream> #include<vector> #include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> 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); } int main(){ pid_t id = fork(); if(id == 0){ //子进程 int cnt = 5; while(cnt--){ printf("我是子进程:%d\n", cnt); sleep(1); } exit(11); }else{ //父进程 int quit = 0; while(!quit){ int status = 0; int res = waitpid(id, &status, WNOHANG); if(res > 0){ printf("进程等待成功,退出状态码:%d\n", WEXITSTATUS(status)); quit = 1; }else if(res == 0){ printf("等待子进程退出,处理其他事情中......\n"); if(handlers.empty()){ Load(); } for(auto e : handlers){ e(); } }else{ printf("进程等待错误\n"); quit = 1; } sleep(1); } } return 0; }
4. 进程程序替换
4.1 替换原理
fork之后,父子进程各自执行代码的一部分—如果子进程就想执行一个全新的程序呢?
进程的程序替换,来完成这个功能!
程序替换,是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中!使得子进程拥有自己的代码!
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
进程替换的本质是加载新的代码和数据到内存,改变子进程页表的映射关系,完成替换
4.2 替换函数
其实有六种以exec开头的函数,统称exec函数:
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[]); int execvpe(const char *file, char *const argv[],char *const envp[]); //使用可变参数列表
实际Linux提供了七个替换函数!额外一个
int execve(const char *path, char *const argv[], char *const envp[]);
4.3 函数解释
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回-1
- 所以exec函数只有出错的返回值而没有成功的返回值。
4.4 命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量
exec调用举例如下:
#include <unistd.h> int main() { char* const argv[] = { "ps", "-ef", NULL }; char* const envp[] = { "PATH=/bin:/usr/bin", "TERM=console", NULL }; execl("/bin/ps", "ps", "-ef", NULL); // 带p的,可以使用环境变量PATH,无需写全路径 execlp("ps", "ps", "-ef", NULL); // 带e的,需要自己组装环境变量 execle("ps", "ps", "-ef", NULL, envp); execv("/bin/ps", argv); // 带p的,可以使用环境变量PATH,无需写全路径 execvp("ps", argv); // 带e的,需要自己组装环境变量 execve("/bin/ps", argv, envp); exit(0); }
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在 man手册第3节。这些函数之间的关系如下图所示。
下图exec函数族 一个完整的例子:
4.5 实现简易shell
用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左 向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结 束。
然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。 所以要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> #define NUM 1024 #define SIZE 32 char cmd_line[NUM]; void dealStr(char* str, char** argv) { char* dealstr = NULL; const char* sep = " "; size_t i = 0; for (dealstr = strtok(str, sep); dealstr != NULL; dealstr = strtok(NULL, sep)) { if (i < SIZE) { argv[i] = dealstr; i++; } } if(strcmp(argv[0], "ls") == 0){ argv[i] = (char*)"--color=auto"; } argv[++i] = NULL; } //shell运行原理:通过让子进程执行命令,父进程等待&&解析命令 int main(){ while(1){ //1.打印提示信息 printf("[root@localhost myshell]#"); fflush(stdout); //2.获取用户输入 memset(cmd_line, '\0', sizeof cmd_line); char *g_argv[SIZE] = { NULL }; if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL){ continue; } cmd_line[strlen(cmd_line) - 1] = '\0'; //printf("echo:%s\n", cmd_line); //3.命令行字符串分割 dealStr(cmd_line, g_argv); //4.TODO 内置命令,让父进程(shell)自己执行的命令,叫做内置命令,内建命令 // 内建命令本质就是shell中的一个函数调用 if(strcmp("cd", g_argv[0]) == 0){ //not child execute, father execute if(g_argv[1] != NULL) chdir(g_argv[1]); continue; } //5.fork pid_t id = fork(); if(id < 0){ perror("fork"); exit(1); }else if(id == 0){ execvp(g_argv[0], g_argv); exit(1); } int status = 0; pid_t ret = waitpid(id, &status, 0); if(ret > 0){ printf("exit code:%d -> result:%s\n",WEXITSTATUS(status), strerror(WEXITSTATUS(status))); }else{ printf("wait fail!\n"); exit(1); } }//end while return 0; }
5. 函数和进程之间的相似性:
exec/exit就像call/return
一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。 这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程 之内的模式扩展到程序之间。
一个C程序可以fork/exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来 返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。