Linux -- 进程【下】
目录
一、创建进程
1、fork
2、写时拷贝
二、进程终止
1、进程退出场景
2、进程常见退出方法
3、退出码
4、 exit与_exit函数
三、进程等待
1、进程为什么要等待
2、方法
2.1 status参数
2.2 wait函数
2.3 waitpid函数
2.4 非阻塞轮询
四、进程替换
1、概念
2、原理
3、进程替换函数
4.1 execl
4.2 execlp
4.3 execle
4.4 execv
4.5 execvp
4.6 execvpe
4.7 总结
一、创建进程
1、fork
# fork函数从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
// 返回值:子进程中返回0,⽗进程返回⼦进程id,出错返回-1
# 进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回, 开始调度器调度
# 当一个进程调用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.outBefore: pid is 43676After:pid is 43676, fork return 43677After:pid is 43677, fork return 0
# 这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示:
# 所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。
2、写时拷贝
# 通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
# 原理:父进程创建子进程之前,数据段权限可读写,代码段权限默认都是只读的。当我们父进程创建子进程之后,操作系统给子进程创建页表,通过页表映射指向和父进程一样的物理内存,所以父子进程默认共享代码和数据。此时操作系统就把数据段的页表项的权限改为只读的,然后子进程要修改数据,此时操作系统查找页表时发现虚拟地址和物理地址都有,但是权限是只读的 ,操作系统就会出错,注意这不是真的出错!是操作系统通过检测到用户对只读的区域做写入,并且查找区域划分发现是对数据段写入,并且该进程是子进程,此时操作系统就会重新申请物理空间,然后把原数据内容拷贝,此时父子进程的数据段权限都可读写。
# 因为有写时拷贝技术的存在,所以父子进程得以彻底分离,完成了进程独立性的技术保证。写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。
二、进程终止
1、进程退出场景
# 进程退出只有以下三种情况:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止(进程崩溃)
2、进程常见退出方法
- 正常终止(可以通过echo$?查看进程退出码):
- 从main返回
- 调用exit
- _exit
- 异常退出:
- ctrl+c,信号终止
3、退出码
# Linux Shell中的主要退出码:
退出码 | 含义 |
0 | 命令成功执行 |
1 | 通用错误代码 |
2 | 命令(或参数)使用不当 |
126 | 权限被拒绝(或)无法执行 |
127 | 未找到命令,或 PATH 错误 |
128+n | 命令被信号从外部终止,或遇到致命错误 |
130 | 通过 Ctrl+C 或 SIGINT 终止(终止代码 2 或键盘中断) |
143 | 通过 SIGTERM 终止(默认终止) |
255/* | 退出码超过了 0-255 的范围,因此重新计算(LCTT 译注:超过 255 后,用退出取模) |
# 在编程中,我们通常认为main函数是代码的入口,但实际上它只是用户级别代码的入口。main函数是被其他函数间接调用的,例如在VS2013中,main函数由__tmainCRTStartup函数调用,而__tmainCRTStartup函数又是通过加载器被操作系统调用。所以,main函数是间接性被操作系统所调用。
# 由于main
函数是这样被调用的,当main
函数调用结束后,应该给操作系统返回相应的退出信息。这个退出信息以退出码的形式作为main
函数的返回值返回。一般情况下,我们以0
表示代码成功执行完毕,以非0
表示代码执行过程中出现错误。这就是为什么我们常在main
函数的最后返回0
。
# 比如我们执行以下代码:
#include<stdio.h>
int main()
{printf("hello tata!\n");return 0;
}
# 我们可以通过指令echo $?
查看最近一次进程的退出码。
# 进程正常退出返回0
,如果进程不是正常退出就会返回其对应的退出码,在C语言中我们可以通过strerror
函数打印出对应的错误信息。
#include<stdio.h>
#include<string.h>
int main()
{for(int i=0;i<150;i++){printf("[%d]->%s\n",i,strerror(i));}return 0;
}
# 注意: 退出码都有对应的字符串含义,帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。
4、 exit与_exit函数
# 首先我们来介绍一下exit
函数,其可以在代码中的任何地方退出进程,并且exit
函数在退出进程前会做一系列工作:
- 执行用户通过
atexit
或on_exit
定义的清理函数。- 关闭所有打开的流,所有的缓存数据均被写入。
- 调用
_exit
函数终止进程。
# 比如说以下这段代码:
#include<stdio.h>
#include<stdlib.h>int main()
{printf("hello tata!");exit(1);return 0;
}
# 如果我们使用的是_exit
函数,那么进程就会直接退出,并不会做任何处理。
#include<stdio.h>
#include<unistd.h>int main()
{printf("hello tata!");_exit(1);return 0;
}
# 最后我们来谈谈return
,exit
,_exit
之间的区别与联系。
首先只有在main函数当中的return才能起到退出进程的作用,子函数当中return不能退出进程,而exit函数和_exit函数在代码中的任何地方使用都可以起到退出进程的作用。
使用exit函数退出进程前,exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程,而_exit函数会直接终止进程,不会做任何收尾工作。
# 其中执行return num
等同于执行exit(num)
,因为调用main
函数运行结束后,会将main
函数的返回值当做exit
的参数来调用exit
函数。
三、进程等待
1、进程为什么要等待
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
2、方法
# 为了解决僵尸进程,获取子进程的退出信息我们需要使用进程等待。其中进程等待有两个关键的函数wait
与waitpid
:
2.1 status参数
# 其中这两个关于进程等待的函数都有一个共同的参数status,如果对status参数传入NULL,表示不关心子进程的退出状态信息。否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。
# *status虽然是一个整型变量,但*status不能简单的当作整型来看待,因为status的不同比特位所代表的信息不同,一般我们只考虑低的16个比特位。
# 在*status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。
# 一般我们可以通过相关的位运算得到进程的退出码与退出信号。
exitCode = (status >> 8) & 0xFF; //退出码 11111111
exitSignal = status & 0x7F; //退出信号 01111111
# 为了降低用户的使用成本,操作系统也为我们提供了两个宏表示对应的退出码与退出信号。
- WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。
- WEXITSTATUS(status):用于获取进程的退出码。
2.2 wait函数
- 原型:pid_t wait(int* status);
- 参数:输出型参数,获取子进程的退出状态,不关心可设置为
NULL
。- 返回值:等待成功返回被等待进程的
pid
,等待失败返回-1。
# 比如下面这段代码,我们用父进程一直等待子进程,然后获取其退出信息.
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<sys/types.h>
int main()
{pid_t id = fork();//创建子进程if(id==0){//chlldint count=10;while(count--){printf("I am child:PID:%d,PPID:%d\n",getpid(),getppid());sleep(1);}exit(0);}//fatherint status=0;pid_t ret=wait(&status);//如果等待成功if(ret>0){printf("wait child success\n");if(WIFEXITED(status)){//退出正常printf("exit code:%d\n",WEXITSTATUS(status));}else{printf("exit signal:%d\n",status&0x7f);}}sleep(10);return 0;
}
# 子进程正常退出,父进程成功获取退出信息,子进程就不会形成僵尸进程。
# 如果我们通过指令杀死进程,父进程同样能等待成功并返回对应的信号。
2.3 waitpid函数
# 同样我们再来介绍waitpid
函数。
- 原型:pid_t waitpid(pid_t pid, int* status, int options);
- 返回值:等待成功返回被等待进程的pid。如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程,则返回0。如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
- 参数:
pid:待等待子进程的pid,若设置为-1,则等待任意子进程。
status:输出型参数,获取子进程的退出状态,不关心可设置为NULL。
options:当设置为WNOHANG时,若等待的子进程没有结束,则waitpid函数直接返回0,不予以等待。若正常结束,则返回该子进程的pid。
# 例如,创建子进程后,父进程可使用waitpid
函数一直等待子进程(此时将waitpid
的第三个参数设置为0),直到子进程退出后读取子进程的退出信息。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{pid_t id = fork();if (id == 0){//child int count = 10;while (count--){printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());sleep(1);}exit(0);}//father int status = 0;pid_t ret = waitpid(id, &status, 0);if (ret >= 0){//wait success printf("wait child success...\n");if (WIFEXITED(status)){//exit normal printf("exit code:%d\n", WEXITSTATUS(status));}else{//signal killed printf("eixt siganl %d\n", status & 0x7F);}}sleep(10);return 0;
}
# 并且我们还可以使用创建等待多进程的方式。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{pid_t ids[10]={0};for (int i = 0; i < 10; i++){pid_t id = fork();if (id == 0){//childprintf("child process created successfully...PID:%d\n", getpid());sleep(3);exit(i); //将子进程的退出码设置为该子进程PID在数组ids中的下标}//fatherids[i] = id;}for (int i = 0; i < 10; i++){int status = 0;pid_t ret = waitpid(ids[i], &status, 0);if (ret >= 0){//wait child successprintf("wait child success..PID:%d\n", ids[i]);if (WIFEXITED(status)){//exit normalprintf("exit code:%d\n", WEXITSTATUS(status));}else{//signal killedprintf("exit signal %d\n", status & 0x7F);}}}return 0;
}
2.4 非阻塞轮询
# 在传统的父子进程关系中,当子进程未退出时,父进程通常处于阻塞等待状态,在此期间父进程不能进行其他操作。
# 然而,我们可以采用非阻塞等待的方式。具体做法是在调用waitpid函数时,向第三个参数options传入WNOHANG。这样,如果等待的子进程没有结束,waitpid函数将直接返回 0,父进程不进行等待,可以去做自己的事情。而当等待的子进程正常结束时,waitpid函数会返回该子进程的pid,此时父进程可以读取子进程的退出信息。
# waitpid()的第三个参数options的取值:
# options设置为0表示阻塞调用,设置为WNOHONG非阻塞调用。
# 我们平时的函数都是阻塞调用,都是要调用完函数才结束,下面举个形象的例子来理解。
# 为了更直观的感受到非阻塞,我们通过一段代码来观察:
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>// 函数指针类型
typedef void (*func_t)();#define NUM 5
func_t handlers[NUM+1];// 如下是任务
void DownLoad()
{printf("我是一个下载的任务...\n");
}
void Flush()
{printf("我是一个刷新的任务...\n");
}
void Log()
{printf("我是一个记录日志的任务...\n");
}// 注册
void registerHandler(func_t h[], func_t f)
{int i = 0;for(; i < NUM; i++){if(h[i] == NULL) break;}if(i == NUM) return;h[i] = f;h[i+1] = NULL;
}int main()
{registerHandler(handlers, DownLoad);registerHandler(handlers, Flush);registerHandler(handlers, Log);pid_t id = fork();if(id == 0){// 子进程int cnt = 3;while(1){printf("我是一个子进程,pid:%d, ppid:%d\n", getpid(), getppid());sleep(1);cnt--;}exit(10);}//父进程while(1){int status = 0;pid_t rid = waitpid(id, &status, WNOHANG);if(rid > 0){printf("wait success, rid:%d, exit code:%d, exit signal:%d\n", rid, WEXITSTATUS(status), status&0x7F); // ridbreak;}else if(rid == 0){// 函数指针进行回调处理int i = 0;for(; handlers[i]; i++){handlers[i]();}printf("本轮调用结束,子进程没有退出\n");sleep(1);}else{printf("调用失败\n");break;}}return 0;
}
# 这里我们定义了三个任务函数,然后定义函数指针和函数指针数组,然后注册三个任务函数到函数指针数组里面,然后我们让子进程死循环不退出,此时父进程一直非阻塞轮询,子进程不退出父进程就一直非阻塞轮询,同时父进程等待时还完成了自己的任务,所以我们就可以看到父进程在下载打印和刷新。
四、进程替换
# fork()之后,父子各自执行父进程代码的一部分,如果子进程就想执行一个全新的程序呢?进程的程序替换来完成这个功能!程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。
1、概念
# 我们前面知道,父子进程是共享代码与数据的,如果修改子进程的数据就会发生写实拷贝。而今天我们需要修改子进程的代码,则需要进行进程替换。
# 当进程替换时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的启动例程开始执行。
# 如果父子进程共享数据与代码,当对子进程进行进程替换时就会发生写实拷贝,所以对子进程就行进程替换并不会影响父进程。
2、原理
# 用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
# 下面我们简单见一下什么是进程替换:
3、进程替换函数
# 进程替换可以使用以下6种以exec开头的函数,统称exec函数:
# 这些函数如果调用成功则加载新的程序,从启动代码开始执行,不再返回,如果调用出错则返回-1。所以exec函数只有出错的返回值而没有成功的返回值。
4.1 execl
- 原型:int execl(const char *path, const char *arg, …)
- 参数:
path
是要执行程序的路径,arg
是可变参数列表,表示你要如何执行这个程序, 注意以NULL
为参数传递的结尾。
execl("/usr/bin/ls","ls","-l","-a", NULL); //执行ls -l -a
4.2 execlp
- 原型:int execlp(const char *file, const char *arg, …);
- 参数:
file
是要执行程序的名称,arg
是可变参数列表,表示你要如何执行这个程序, 注意以NULL
为参数传递的结尾。
execlp("ls", "ls", "-a", "-l", NULL);//执行ls -a -l
4.3 execle
原型:int execle(const char *path, const char *arg, …, char *const envp[]);
参数:path是要执行程序的路径,arg是可变参数列表,表示你要如何执行这个程序, 注意以NULL为参数传递的结尾,envp是你自己设置的环境变量。
# 例如,你设置了MYVAL
环境变量,在mycmd
程序内部就可以使用该环境变量。
char* env[] = { (char *const)"MYVAL=2021",NULL
};execle("./other", "other", NULL, env); //执行./other
4.4 execv
- 原型:int execv(const char *path, char *const argv[]);
- 参数:
path
是要执行程序的路径,argv
是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL
结尾。
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);//执行ls -a -i -l
4.5 execvp
原型:int execvp(const char *file, char *const argv[]);
参数:file是要执行程序的名称,argv
是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL
结尾。
char *const argv[] = {(char*const)"other",(char*const)"-a",(char*const)"-l",NULL};execvp(argv[0], argv); // 执行ls -a -l
4.6 execvpe
原型:int execvpe(const char *path, char *const argv[], char *const envp[]);
参数:path
是要执行程序的路径,argv
是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL
结尾,envp是你自己设置的环境变量。
char *const argv[] = {(char*const)"other",(char*const)"-a",(char*const)"-b",(char*const)"-c",(char*const)"-d",NULL};char *const env[] = {(char *const)"MYVAL=123456789",NULL};execvpe("./other", argv, env); // 执行./other
4.7 总结
# 最后为了方便记忆,我们将这些接口归类形成如下表格:
替换函数接口后后缀 | 含义 |
---|---|
l(list) | 参数采用列表方式 |
v(vector) | 参数采用数组方式 |
p(path) | 自动搜索环境变PATH ,进行程序查找 |
e(env) | 自己维护环境变量,或者说自定义环境变量,可以传入自己设置的环境变量 |
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,需自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 否 | 否,需自己组装环境变量 |
# 事实上,在系统调用中,只有execve才是真正的系统调用,其他五个函数(如execl、execle、execlp、execv、execvp)都是对execve函数的封装,目的是为了满足不同用户的需求。这也导致了在man手册中,execve位于第 2 节,而其他五个函数在第 3 节。
# 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);
}
# 下图exec函数簇一个完整的例子: