在linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
返回值:给子进程返回0,给父进程返回子进程id,出错返回-1
当一个进程调用fork之后,就有两个二进制代码相同的进程,相当于从一个执行流变成两个执行流 了。但每个进程都将可以开始它们自己的旅程,看如下程序。
#include
#include int global_value = 100;int main()
{pid_t id = fork();if(id < 0){printf("fork error\n");return 1;}else if(id == 0){int cnt = 0;while(1){printf("我是子进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value);sleep(1);cnt++;if(cnt == 10){global_value = 300;printf("子进程已经更改了全局的变量啦..........\n");}}}else{while(1){printf("我是父进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value);sleep(2);}}sleep(1);
}
执行结果:
fork之前父进程单独执行,fork之后,父子两个执行流分别执行。注意:fork之后,谁先执行完全由调度器决定。
对于上面的执行结果:
在子进程没有改变全局变量global_value的时候,可以看到父子进程之前的global_value以及global_value的地址都是一样的;
在子进程改变全局变量global_value之后,可以看到父子进程的值有了区别,子进程的值是300,父进程是100,因为我们知道进程具有独立性,再往后看奇怪的事情发生了,为什么父子进程的这个全局变量global_value的地址是一样的呢?
那么下面就介绍一下写时拷贝。
一般情况下父子代码共享,当父子再不写入时,数据也是共享的。当任意一方试图对共享的数据进行修改,那么便以写时拷贝的方式给这个进程复制一份副本。具体见下图蓝色区域:
所以子进程修改global_value的内容之后,父子进程表面上global_value的虚拟地址是一样的,但是实际映射到物理内存上是不一样的。
1️⃣1.如何理解fork函数有两个返回值问题?
因为没有调用fork的时候,只有父进程,调用fork之后,子进程也就被创建出来了(创建的过程特别复杂),父子进程两个执行流,两套代码,两个return,那么就意味着有两个返回值。
3️⃣2.如何理解fork返回之后,给父进程返回子进程pid,给子进程返回0?
生活中正常来讲父亲:孩子是1:n
的关系(n>=1),对于父进程来说,父亲找孩子不具有唯一性。而对于子进程来说,孩子找父亲是具唯一性的。所以才会给父进程返回子进程pid,给子进程返回0。
3️⃣3.对于上面的例子代码,如何理解同一个id值,怎么可能会保存两个不同的值,让if和else if同时执行?
两个返回值那么就意味着同一个id要被父子进程的返回值赋值(写入)两次,所以先对id进行赋值(写入)的进程就正常赋值(写入),后对id进行赋值(写入)的进程因为进程具有独立性,那么就会发生写时拷贝,典型的同一个id,虚拟地址一样,但是内容却不一样。
写代码是为了完成某件事情,如何得知我的代码运行的如何呢?——进程退出码
进程退出的时候,都有对应的退出码,作用是标定进程执行的结果是否正确。一般而言,退出码,都必须有对应的退出码的文字描述,1. 可以自定义 2. 可以使用系统的映射关系(不太频繁)
如何设定main函数返回值呢?如果不关心进程退出码,return 0就行。如果未来我们是要关心进程退出码的时候,要返回特定的数据表明特定的错误。
0:success, !0:标识失败, !0具体是几,标识不同的错误
在C语言中有strerror和perror函数可以自动获取错误码,然后打印对应的错误信息。
return 0
这个0就是退出码)exit(23)
这个23就是退出码)_exit(45)
这个45就是退出码)当一个进程正常终止的时候,紧接着输入 echo $?
就可以把最近一次return或者exit和_exit的退出码打印出来。
echo $?
发现返回值是255。用代码对比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]#
很明显的区别就是退出的时候刷不刷新缓冲区的问题。
进程等待必要性:
父子进程中,如果子进程退出,子进程会把对应的退出信息存到子进程的PCB里,需要父进程来处理,但如果父进程不管不顾,就可能造成子进程变为僵尸进程,进而造成内存泄漏。进程一旦变成僵尸状态,kill -9
也无能为力,因为谁也没有办法杀死一个已经死去的进程。
另外,父进程派给子进程的任务完成的如何,例如,子进程是否运行完成?运行结果对还是不对?是否正常退出?这些我们都需要知道。
那么这里就要用到进程等待来解决僵尸进程以及获取子进程信息的问题了。
父进程通过进程等待的方式:回收子进程资源,获取子进程退出信息
#include
#include
pid_t wait(int*status);
返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,通过传入的status获取子进程退出状态,不关心则可以设置成为NULL。
如果终止了多个子进程,则wait()将获取任意的子进程并返回该子进程的进程ID。
pid_ t waitpid(pid_t pid, int *status, int options);
检测值进程退出信息,将子进程退出信息通过status拿出来。
0
:让waitpid使用阻塞等待的方式运行WNOHANG
: 让waitpid使用非阻塞等待的方式运行,若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特
位):
一般代码运行有下面这三种结果:
运行完(代码完,结果对或者代码完,结果不对)
异常(代码没跑完,出异常了)
通过status就可以体现上面的几种运行情况:
退出状态(8到15位):退出码(结果是否正确)
终止信号(0到6位):退出信息,一个进程如果出异常了。那么一定是该进程收到了对应的信号。包括除0或者野指针,其实是属于操作系统识别到了这个进程有问题,然后给这个进程发信号。换句话说我们是可以通过终止信号来得知这个进程是否正常退出。
- 正常退出情况下,退出码被设置,coredump标志位为0,不会设置退出信号
- 异常退出时,退出码不会被设置,coredump标志位为1,退出信号被设置。
通过kill -l
就可以看对应的退出信号,退出信号如果是0那么说明没有问题。如果退出信号非零。那么就根据下面的序号去寻找对应的错误即可。
有了上面的基础接下来看一下waitpid函数的应用。
int main()
{pid_t id = fork();if (id == 0){//子进程int cnt = 5;while (cnt){printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);sleep(1);int* p = NULL;//野指针错误*p = 100;}exit(12); //进程退出}// 父进程int status = 0; // 不是被整体使用的,有自己的位图结构pid_t ret = waitpid(id, &status, 0);if (id > 0){printf("wait success: %d, sig number: %d, child exit code: %d\n", ret, (status & 0x7F), (status >> 8) & 0xFF);}sleep(5);
}
kill -n 进程id
杀掉子进程,那么退出信号就是n。退出码无意义还是0.把waitpid的option选项换成WNOHANG此时的waitpid就是非阻塞等待的方式了,检测id的进程状态如果没有就绪,直接返回,接着执行下面的代码,不会一直卡住去等待,可以用while循环来控制多次检测,每一次都是非阻塞等待。那么多次非阻塞等待就称为轮询。
相比于阻塞等待,非阻塞等待不会占用父进程的所有精力。可以在轮询期间去干一些别的事情。
示例代码:
#include
#include
#include
#include
#include
#include
#include #define NUM 10typedef void (*func_t)(); //使用typedef把func_t定义成一个函数指针类型func_t handlerTask[NUM];//样例任务
void task1()
{printf("handler task1\n");
}
void task2()
{printf("handler task1\n");
}
void task3()
{printf("handler task1\n");
}void loadTask()
{memset(handlerTask, 0, sizeof(handlerTask));handlerTask[0] = task1;handlerTask[1] = task1;handlerTask[2] = task1;
}int main()
{pid_t id = fork();assert(id != -1);if (id == 0){//childint cnt = 3;while (cnt){printf("child running, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);sleep(1);}exit(10);}loadTask();// parentint status = 0;while (1){pid_t ret = waitpid(id, &status, WNOHANG); //WNOHANG: 非阻塞-> 子进程没有退出, 父进程检测时候,立即返回if (ret == 0){// waitpid调用成功 && 子进程没退出//子进程没有退出,我的waitpid没有等待失败,仅仅是监测到了子进程没退出.printf("wait done, but child is running...., parent running other things\n");for (int i = 0; handlerTask[i] != NULL; i++){handlerTask[i](); //采用回调的方式,执行我们想让父进程在空闲的时候做的事情}}else if (ret > 0){// 1.waitpid调用成功 && 子进程退出了printf("wait success, exit code: %d, sig: %d\n", (status >> 8) & 0xFF, status & 0x7F);break;}else{// waitpid调用失败printf("waitpid call failed\n");// break;}sleep(1);}return 0;
}
运行结果:
程序替换的本质就是将指定程序的代码和数据加载到指定的位置。覆盖自己原有的代码和数据。
之前我们创建子进程只能执行父进程代码的一部分,现在我们创建出来子进程想让子进程执行一个全新的程序的话就需要用到进程程序替换。
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id从未改变。
其实有七种以exec开头的函数,统称exec函数:
先来看一下返回值:
RETURN VALUE
The exec() functions return only if an error has occurred.
The return value is - 1, and errno is set to indicate the error.
格式:
int execl(const char *path, const char *arg, ...);
用法:
execl("/usr/bin/ls"/*要执行哪一个程序*/, "ls", "--color=auto", "-a", "-l", NULL/*你想怎么执行*/);// 所有的exec函数都以null结尾perror("ececl"); //打印错误原因
下面是调用成功的例子,可以看到我们设置的退出码123在程序被正常替换之后就没有被执行了。
#include
#include
#include
#include
#include
#include int main(int argc, char* argv[])
{printf("process is running...\n");pid_t id = fork();assert(id != -1);if (id == 0){// 这里的替换,会影响父进程吗?不会的因为进程具有独立性// 类比:命令行怎么写,这里就怎么传sleep(1);// ./exec ls -a -l -> "./exec" "ls" "-a" "-l"execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);exit(123); //能运行到这里就必然是替换失败了}int status = 0;pid_t ret = waitpid(id, &status, 0);if (ret > 0) printf("wait success: exit code: %d, sig: %d\n", (status >> 8) & 0xFF, status & 0x7F);}
对于上述代码如果把ls路径故意写错,必然函数调用失败。如果我们自己通过exit或者return设置退出码了,那么父进程wait之后获取的就是设置好的return或者exit的值,如果没有设置并且函数调用失败,那么就是-1,我们知道在计算机中数值是以补码的形式存储的,那么-1在计算机中就是全1,全1的退出码通过status的8位退出码信息打印出来就是255.
而且父子进程中,当子进程进行程序替换的时候,对父进程会造成影响。那么这时候就要发生程序的写时拷贝了,以此来保证进程间的独立性。
p:path:带p字符的函数,不用告诉程序的路径,你只要告诉要执行谁,它就会自动在环境变量PATH,进行可执行程序的查找!
格式:
int execlp(const char *file, const char *arg, ...);
使用举例:
execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);
这里有两个ls,一个是告诉系统我要执行谁,一个是告诉系统怎么执行。
v:vector可以将所有的执行参数,放入数组中,统一传递,而不用进行使用可变参数列表方案。
格式:
int execv(const char *path, char *const argv[]);
使用举例:
char *const argv_[] = {"ls","-a", "-l","--color=auto",NULL};
execv("/usr/bin/ls", argv_);
对于v和p上面两个函数有所介绍,这里就不多赘述了。
格式:
int execvp(const char *file, char *const argv[]);
使用举例:
char *const argv_[] = {"ls","-a", "-l","--color=auto",NULL};
execvp("ls", argv_);
格式:
int execle(const char *path, const char *arg, ...,char *const envp[]);
mybin.c:
#include
#include int main()
{// 系统就有printf("PATH:%s\n", getenv("PATH"));printf("PWD:%s\n", getenv("PWD"));// 自定义printf("MYENV:%s\n", getenv("MYENV"));return 0;
}
myexec.c:
char *const envp_[] = {(char*)"MYENV=11112222233334444",NULL
};extern char **environ;
//execle("./mybin", "mybin", NULL, envp_); //自定义环境变量指针putenv((char*)"MYENV=4443332211");
//将指定环境变量导入到系统中 environ指向的环境变量表execle("./mybin", "mybin", NULL, environ); //默认的环境变量指针
//实际上,默认环境变量你不传,子进程也能获取
最终看到的效果就是在myexec.c中通过execle函数调用mybin然后将系统的和自定义的环境变量都打印出来了
我们平时想要运行一个程序,就要先加载程序到内存中,那么加载程序它其实用到的是exec函数(加载器),所以exec函数先于main函数执行,而且默认的环境变量通过exec函数就已经传入到main程序里了,所以我们在执行main程序的时候可以直接使用默认的环境变量。
但是自定义的环境变量就需要使用putenv来将其导入到系统中。然后才能通过environ指针来获取。
和上面的几个exec函数功能有相似之处,这里不过多介绍。
int execvpe(const char* file, char* const argv[], char* const envp[]);
程序替换中execve才是真正的系统调用,其他的六个都是封装,为了让我们有很多的选择性。
⚠️值得注意的是:可以使用程序替换,调用任何后端语言对应的可执行程序
下面是在C语言下分别调用C++、python、shell等语言的代码片段。
execl("./mybin", "mybin", NULL);
execl("./mypy.py", "mypy.py", NULL);
execl("./myshell.sh", "myshell.sh", NULL);
exec函数族:execl,execlp,execle,execv,execvp,execve
#include
#include
#include
#include
#include
#include
#include #define NUM 1024
#define OPT_NUM 64char lineCommand[NUM];
char *myargv[OPT_NUM]; //指针数组
int lastCode = 0;
int lastSig = 0;int main()
{while(1){// 输出提示符printf("用户名@主机名 当前路径# ");fflush(stdout);//刷新缓冲区// 获取用户输入, 输入的时候,输入\nchar *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);assert(s != NULL);(void)s;// 一般我们输入完毕回车之后自动带一个\n,要清除最后一个\n,否则不好看lineCommand[strlen(lineCommand)-1] = 0; // ?//printf("test : %s\n", lineCommand);// "ls -a -l -i" -> "ls" "-a" "-l" "-i" -> 1->n// 字符串切割myargv[0] = strtok(lineCommand, " ");//对于ls命令需要做的一点小修改int i = 1;if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0){myargv[i++] = (char*)"--color=auto";}// 进行连续分隔:如果没有子串了,strtok->NULL, myargv[end] = NULLwhile(myargv[i++] = strtok(NULL, " "));//对于cd命令需要做的一点小修改(chdir的使用)// 如果是cd命令,不需要创建子进程,让shell自己执行对应的命令,本质就是执行系统接口// 像这种不需要让我们的子进程来执行,而是让shell自己执行的命令 --- 内建/内置命令if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0){if(myargv[1] != NULL) chdir(myargv[1]);continue;}//对于echo命令需要做的一点小修改,这里就能解释为什么if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0){if(strcmp(myargv[1], "$?") == 0){printf("%d, %d\n", lastCode, lastSig);}else{printf("%s\n", myargv[1]);}continue;}// 这里是一个测试:测试是否成功, 条件编译
#ifdef DEBUGfor(int i = 0 ; myargv[i]; i++){printf("myargv[%d]: %s\n", i, myargv[i]);}
#endif// 内建命令 --> echo// 执行命令pid_t id = fork();assert(id != -1);if(id == 0){execvp(myargv[0], myargv);exit(1);}int status = 0;pid_t ret = waitpid(id, &status, 0);assert(ret > 0);(void)ret;lastCode = ((status>>8) & 0xFF);lastSig = (status & 0x7F);}
}
补充:
当fork()之后,子进程执行的cd->子进程有自己的工作目录->更改的是子进程的目录->子进程执行完毕->继续用的是父进程,即shell.