一.进程创建
1.fork函数
在这里我们来详细的学习一下 fork 函数;fork 是 Linux 中非常重要的一个系统调用函数,它用于在当前进程下创建一个新的进程,新进程是当前进程的子进程;我们可以 man 2号手册来查看 fork 函数:
头文件:unistd.h
函数原型:pid_t fork(void)
函数功能:创建一个子进程
函数返回值:创建成功 -- 给父进程返回子进程的pid,给子进程返回0;
创建失败 -- 给父进程返回-1,没有子进程被创建
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t id = fork(); //创建子进程
if(id == -1) {
printf("fork fail\n");
return -1;
} else if (id == 0) { //子进程
while(1) {
printf("子进程, pid:%d, ppid:%d, id:%d\n", getpid(), getppid(), id);
sleep(1);
}
} else { //父进程
while(1) {
printf("父进程, pid:%d, ppid:%d, id:%d\n", getpid(), getppid(), id);
sleep(1);
}
}
return 0;
}
tips:我们在编写 makefile 的时候,目标文件的依赖方法中,可以
用 “$@” 表示要形成的目标文件,即依赖关系中 “:” 左边的内容;
用 “$^” 表示目标文件的依赖文件,即依赖关系中 “:” 右边的内容。
2.fork函数返回值
如何理解 fork 函数有两个返回值呢?
其实大体来说,我们可以将fork函数分为三步
1、调用_CREATE函数,也就是进程创建部分
2、调用_CLONE函数,也就是资源拷贝部分
3、进程创建成功,return 0; 失败,return -1前2步也就是父进程通过fork函数创建子进程的步骤,在执行完_CLONE函数后,fork函数会有第一次返回,子进程的pid会返回给父进程。
要注意的是,在第3步中,fork函数不是由父进程来执行,而是由子进程来执行,当父进程执行完_CLONE函数后,子进程会执行fork函数的剩余部分,执行最后这个语句,fork函数就会有第二次返回,如果成功就返回0,失败就返回-1。
我们就可以总结得出,父子进程都执行fork函数,但执行不同的代码段,获取不同的返回值。所以fork函数的返回值情况如下:
父进程调用fork,返回子线程pid(>0)
子进程调用fork,子进程返回0,调用失败的话就返回-1
这也就说明了fork函数的返回值是2个
另外,为什么 fork 给父进程返回子进程的 pid,而给子进程返回0呢? – 因为一个父进程可能有多个子进程,而一个子进程只能有一个父进程,父进程需要子进程的 pid 来判别不同的子进程,而子进程则不需要判别父进程。
3.写时拷贝
在上一节 进程地址空间 中我们写了如下程序:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 100;
int main() {
int id = fork();
if(id < 0) {
perror("fork fail");
return 1;
} else if(id == 0) {
int cnt = 0;
while(1) {
if(cnt == 5) {
g_val = 200;
printf("子进程已经修改了全局变量...........................\n");
}
cnt++;
printf("我是子进程,pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
} else {
while(1) {
printf("我是父进程,pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
我们发现,子进程和父进程中 g_val 变量的地址相同,但是值却不相同;我们现在知道 – OS会为每一个进程都创建一个进程地址空间以及页表,然后将通过页表将地址空间映射到物理内存;
对于父子进程来说,父进程和子进程共享代码和数据,但是为了保证进程的独立性,当其中一方想要修改数据时,就会发生 写时拷贝 – OS 会在物理内存中重新开辟一块空间,然后将原空间中的数据拷贝都新空间,再修改页表映射关系,最后再让进程修改对应的数据;
所以虽然表面上父子进程 g_val 的地址相同,但这只是虚拟地址相同,而物理地址并不相同,所以父子进程 g_val 的值也能够不同;对于接受 fork 返回值的变量 id 来说也一样,先进行 return 的进程会对 id 进行写时拷贝,所以对于父子进程来说,id 的值不同:
4.fork常规用法
fork 一般应用于一下两种场景:
一个父进程希望复制自己,使父子进程同时执行不同的代码段;例如,父进程等待客户端请求,生成子进程来处理请求;我们前面使用的 fork 都属于这种情况。
一个进程要创建子进程来执行一个不同的程序;例如子进程从 fork 返回后,调用 exec 系列函数;这是我们下面要重点学习的内容。
5.fork调用失败原因
如下两种原因可能会导致 fork 调用失败:
- 系统中有太多的进程;
- 实际用户的进程数超过了限制;
写一个死循环创建进程的程序来测试我们当前OS最多能创建多少个进程:
#include <stdio.h>
#include <unistd.h>
int main() {
int cnt = 0;
while(1) {
int id = fork();
if(id < 0) { //进程创建失败
printf("fork fail, cnt:%d\n", cnt);
break;
} else if(id == 0) { //子进程
printf("子进程持续创建中...\n");
}
cnt++;
}
return 0;
}
二.进程终止
1.进程退出码
我们运行一个进程是为了让该进程完成某一项任务,而既然是完成任务,就需要对任务执行结果的正确性进行标定;进程退出码的作用就是就是标定一个进程执行结果是否正确,不同的退出码表示不同的执行结果,一般来说:
0表示进程运行结果正确;
非0表示运行结果错误;
对于非0来说,不同的数字有又对应着不同的错误,我们可以自己设定不同退出码所对应的错误信息,也可以使用系统提供的退出码映射关系:
在 Linux 中,存在一个变量 “?” – 该变量中始终保存着最近一个进程执行完成时的退出码,我们可以使用 “echo $?” 来查看最近一个进程的退出码:
2.进程退出的情况
进程退出时一共有如下三种情景:
- 代码运行完毕且结果正确 – 此时退出码为0;
- 代码运行完毕且结果不正确 – 此时退出码为非0;
- 代码异常终止 – 此时退出码无意义。
3.进程退出的方法
进程退出有如下几种方法:
- main 函数 return 返回;
- 调用 exit 终止程序;
- 调用 _exit 终止程序。
我们平时接触最多的就是通过 main 函数 return 返回来退出进程,但其实我们也可以通过库函数 exit 和系统调用 _exit 来直接终止进程:
3.1.库函数 exit
头文件:stdlib.h
函数原型:void exit(int status);status:status 定义了进程的终止状态,父进程通过wait来获取该值
函数功能:终止进程
3.2.系统调用 _exit
头文件:unistd.h
函数原型:void _exit(int status);status:status 定义了进程的终止状态,父进程通过wait来获取该值
函数功能:终止进程
exit 和 _exit 的区别
关于 exit 和 _exit 的区别和联系,我们以一个例子说明:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
printf("hello linux");
exit(1);
//_exit(1);
printf("process is done...\n");
return 0;
}
首先,由于 exit 是C语言库函数,而 _exit 是系统调用,所以可以肯定的是 exit 的底层是 _exit 函数,exit 是 _exit 的封装;
exit 会将我们的进程直接终止,无论程序代码是否执行完毕;
其次,由于计算机体系结构的限制,CPU之和内存交互,所以数据会先被写入到缓冲区,待缓冲区刷新时才被打印到显示器上;而在上面的程序中,我们没用使用 ‘\n’ 进行行缓冲的刷新,可以看到,exit 最后打印了 “hello linux”,而 _exit 什么都没有打印;所以 exit 在终止程序后会刷新缓冲区,而 _exit 终止程序后不会刷新缓冲区;
最后,由于 exit 的底层是 _exit,而 _exit 并不会刷新缓冲区,也可以反映出 缓冲区不在操作系统内部,而是在用户空间。
三.进程等待
1.为什么要进行进程等待
我们创建一个进程的目的是为了让其帮我们完成某种任务,而既然是完成任务,进程在结束前就应该返回任务执行的结果,供父进程或者操作系统读取。
所以,一个进程在退出的时候,不能立即释放全部资源 – 对于进程的代码和数据,操作系统可以释放,因为该进程已经不会再被执行了,但是该进程的PCB应该保留,因为PCB中存放着该进程的各种状态代码,特别是退出状态代码。
对于父子进程来说,当子进程退出后,如果父进程不对子进程的退出状态进行读取,那么子进程就会变成 “僵尸进程”;而进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼” 的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程;进而就会造成内存泄漏;
所以,我们需要父进程对子进程进行 进程等待,获取子进程的退出信息,并让操作系统回收子进程资源 (释放子进程的 PCB)。
进程等待的本质
我们知道,子进程的退出信息是存放在子进程的 task_struct 中的,所以进程等待的本质就是从子进程 task_struct 中读取退出信息,然后保存到相应变量中去:
2.如何进行进程等待
在 Linux 下,我们一般通过以下两种系统调用来进行进程等待
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait 和 waitpid 都可以获取子进程的退出信息,并让操作系统回收子进程资源 (释放子进程的 PCB);
status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;
options:指定父进程等待方式 – 阻塞式等待和非阻塞式等待;
(1)wait系统调用函数
我们可以通过 wait 系统调用来进行进程等待:
头文件:sys/types.h sys/wait.h
函数原型:pid_t wait(int *status);status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;
返回值:成功返回被等待进程的pid,失败返回-1;
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
int id = fork();
if(id == -1) {
printf("fork error\n");
exit(-1);
} else if(id == 0) { //子进程
int cnt = 5;
while(cnt--) {
printf("子进程, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(1);
} else { //父进程
sleep(10);
int status = 0;
pid_t ret = wait(&status);
if(ret == -1) {
printf("wait fail\n");
exit(1);
} else {
printf("wait success\n");
}
printf("exit code:%d\n", status);
}
return 0;
}
我们可以通过一个监控脚本来检测子进程从创建到终止到被父进程回收的过程:
while :; do ps axj | head -1 && ps axj | grep mycode | grep -v grep; sleep 1; done
可以看到,最开始父子进程都处于睡眠状态 S,之后子进程运行5s退出,此时由于父进程还要休眠5s,所以没有对子进程进行进程等待,所以子进程变成僵尸状态 D;5s过后,父进程使用 wait 系统调用对子进程进行进程等待,所以子进程由僵尸状态变为彻底死亡状态。
(2)status位图结构
在上面的例子中,子进程使用 exit 终止进程时返回的退出码是1,但是我们发现保存子进程退出信息的 status 的值非常奇怪,这是由于 status 的位图结构造成的;
wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充;
如果传递NULL,表示不关心子进程的退出状态信息;否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程;
status 不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 低16比特位):
可以看到,status 低两个字节的内容被分成了两部分 – 第个一字节前七位表示退出信号,最后一位表示 core dump 标志;第二个字节表示退出状态,退出状态即代表进程退出时的退出码;对于正常退出的程序来说,退出信号和 core dump 标志都为0,退出状态等于退出码;对于异常终止的程序来说,退出信号为不同终止原因对应的数字,退出状态未用,无意义。
所以 status 正确的读取方法如下:
printf("exit signal:%d, exit code:%d \n", (status & 0x7f), (status >> 8 & 0xff));
其中,status 按位与上 0x7f 表示保留低七位,其余九位全部置为0,从而得到退出信号;
status 右移8位得到退出状态,再按位与上 0xff 是为了防止右移时高位补1的情况;
WIFEXITED 与 WEXITSTATUS 宏
Linux 提供了 WIFEXITED 和 WEXITSTATUS 宏 来帮助我们获取 status 中的退出状态和退出信号,而不用我们自己去按位操作:
WIFEXITED (status):若子进程正常退出,返回真,否则返回假;(查看进程是否是正常退出)(wait if exited)
WEXITSTATUS (status):若 WIFEXITED 为真,提取子进程的退出状态;(查看进程的退出码)(wait exit status)
if(WIFEXITED(status)) { //正常退出
printf("exit code:%d\n", WEXITSTATUS(status));
} else { //异常终止
printf("exit signal:%d\n",WIFEXITED(status));
}
(3)waitpid系统调用
我们也可以用 waitpid 来进行进程等待:
头文件:sys/types.h sys/wait.h
函数原型:pid_t waitpid(pid_t pid, int *status, int options);pid:Pid=-1,等待任意一个子进程,与wait等效;Pid>0.等待其进程id与pid相等的子进程;
status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;
options:等待方式,options=0,阻塞等待;options=WNOHANG,非阻塞等待;
返回值:waitpid调用成功时返回被等待进程的pid;如果设置了WNOHANG,且waitpid发现没有已退出的子进程可收集,则返回0;调用失败则返回-1;
eg:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
int id = fork();
if(id == -1) {
printf("fork error\n");
exit(-1);
} else if(id == 0) { //子进程
int cnt = 5;
while(cnt--) {
printf("子进程, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(1);
} else { //父进程
sleep(10);
int status = 0;
pid_t ret = waitpid(id, &status, 0); //阻塞等待
if(ret == -1) {
printf("wait fail\n");
exit(1);
} else {
printf("wait success\n");
}
printf("exit signal:%d, exit code:%d\n", (status & 0x7f), (status >> 8 & 0xff));
}
return 0;
}
可以看到,waitpid 和 wait 还是有很大区别的 – waitpid 可以传递 id 来指定等待特定的子进程,也可以指定 options 来指明等待方式。
(4)阻塞与非阻塞等待
waitpid 函数的第三个参数用于指定父进程的等待方式:
其中,options 为0代表阻塞式等待,options 为 WNOHANG 代表非阻塞式等待;
阻塞式等待即当父进程执行到 waitpid 函数时,如果子进程还没有退出,父进程就只能阻塞在 waitpid 函数,直到子进程退出,父进程通过 waitpid 读取退出信息后才能接着执行后面的语句;
而非阻塞式等待则不同,当父进程执行到 waitpid 函数时,如果子进程未退出,父进程会直接读取子进程的状态并返回,然后接着执行后面的语句,不会等待子进程退出。
轮询
轮询是指父进程在非阻塞式状态的前提下,以循环方式不断的对子进程进行进程等待,直到子进程退出。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
void task1() {
printf("task is running...\n");
}
void task2() {
printf("task is runnning...\n");
}
int main() {
int id = fork();
if(id == -1) {
printf("fork error\n");
exit(-1);
} else if(id == 0) { //子进程
int cnt = 5;
while(cnt--) {
printf("子进程, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
exit(1);
} else { //父进程
int status = 0;
while(1) { //轮询
pid_t ret = waitpid(id, &status, WNOHANG); //非阻塞式等待
if(ret == -1) {
printf("wait fail\n"); //调用失败
exit(1);
} else if(ret == 0){ //调用成功,但子进程未退出
printf("wait success, but child process not exit\n");
task1(); //执行其他命令
task2();
} else { //调用成功,子进程退出
printf("wait success, and child exited\n");
break;
}
sleep(1);
}
if(WIFEXITED(status)) { //正常退出
printf("exit code:%d\n", WEXITSTATUS(status));
} else { //异常终止
printf("exit signal:%d\n",WIFEXITED(status));
}
}
return 0;
}
3.进程等待总结
为了读取子进程的退出结果以及回收子进程资源,我们需要进行进程等待;
进程等待的本质是父进程从子进程 task_struct 中读取退出信息,然后保存到 status 中;
我们可以通过 wait 和 waitpid 系统调用进行进程等待;
status 参数是一个输出型参数,父进程通过 wait/waitpid 函数将子进程的退出信息写入到 status 中;
status 以位图方式存储,包括退出状态和退出信号,若退出信号不为0,则退出状态无效;
我们可以使用系统提供的宏 WIFEXITED 和 WEXITSTATUS 来分别获取 status 中的退出状态和退出信号;
进程等待的方式分为阻塞式等待与非阻塞式等待,阻塞式等待用0来标识,非阻塞式等待用宏 WNOHANG 来标识;
由于非阻塞式等待不会等待子进程退出,所以我们需要以轮询的方式来不断获取子进程的退出信息。
四.进程程序替换
1.什么是进程程序替换
在上面进程创建中我们提到,fork 函数一般有两种用途 – 创建子进程来执行父进程的部分代码以及创建子进程来执行不同的程序,创建子进程来执行不同的程序就是进程程序替换。
进程程序替换是指父进程用 fork 创建子进程后,子进程通过调用 exec 系列函数来执行另一个程序;当进程调用某一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,然后从新程序的启动例程开始执行;
但是原进程的 task_struct 和 mm_struct 以及进程 id 都不会改变,页表可能会变;所以调用 exec 并不会创建新进程,而是让原进程去执行另外一个程序的代码和数据。
2.进程程序替换的原理
进程程序替换其实就是用新程序的代码和数据去替换原进程物理内存中的代码和数据,除了可能会改变原进程的页表映射之外,其他的内核数据都不变,比如 task_struct、mm_struct;图示如下:
3.如何进行进程程序替换
(1)exec系列函数
Linux 提供了一系列的 exec 函数来实现进程程序替换,其中包括六个库函数和一个系统调用:
实现进程程序替换的系统调用函数就一个 – execve,其他一系列的 exec 库函数都是为了满足不同的替换场景而对 execve 系统调用进行的封装:
库函数中有六种以 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[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
这些函数如果调用成功则加载新的程序并启动代码开始执行,不再返回;如果调用出错则返回-1;
注:exec 函数一旦调用成功,就代表着原程序的代码和数据已经被新程序替换掉了,也就是说,原程序后续的语句都不会再被执行了,所以 exec 调用成功后没有返回值,因为该返回值没有机会被使用;只有 exec 调用失败,原程序可以继续往下执行时,exec 返回值才会被使用。
(2)函数命名理解
- l (list):表示参数采用列表;
- v (vector):表示参数采用数组;
- p (path):表示系统会自动到环境变量PATH路径下搜索文件,即对于替换Linux指令相关程序时我们不用带路径;
- e (env):表示自己维护环境变量;
(3)函数如何使用
我们想要执行一个程序,无非就两个步骤 :
一是找到该可执行程序;
二是指定程序执行的方式;
对于 exec 函数来说,“p”用来找到程序,“l” “v” 用来指定程序执行方式;“e” 用来指定环境变量。
eg:
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);
}
execl && execlp
exec 函数的使用其实很简单,第一个参数为我们要替换的程序的路径,,如果该程序在PATH环境变量中,且 exec 函数带有 “p”,我们可以不带路径,只写函数名;
我们以Linux指令 “ls” 为例,我们知道,ls 是Linux中 “/usr/bin” 目录下的一个可执行程序,且该程序处于PATH环境变量中,那么如果我们要替换此程序,exec 函数的第一个参数如下
execl("/usr/bin/ls", ...) //execl 需要带路径
execlp("ls", ...) //execlp 可以不带路径
注意:带 “p” 的 exec 函数可以不带路径的前提是被替换程序处于PATH环境变量中,如果条件不成立,即使函数中有 “p”,我们仍然要带路径。
第二个参数为如何执行我们的程序,这里我们只需要记住:在 Linux 命令行中该程序如何执行我们就如何传参 即可;需要注意的是,命令行中多个指令是以空格为分隔的一整个字符串,而 exec 中我们需要对不同选项进行分割,即每一个选项都要单独分为一个字符串,所以可以看到 exec 函数中存在可变参数列表 “…”;同时,我们需要将最后一个可变参数设置为 NULL,表示传参完毕。
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //命令行中怎么执行就如何传参
execlp("ls", "ls", "-a", "-l", NULL); //命令行:ls -a -l
eg:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t id = fork();
if(id == -1) {
perror("fork");
return 1;
} else if (id == 0) { //子进程
printf("pid: %d, child process is runnning...\n", getpid());
int ret = execl("/usr/bin/ls", "ls", "-l", "-a", "--color=auto", NULL); //进程程序替换
if(ret == -1) { //替换失败,以下语句可以被执行
printf("process exec failed\n");
exit(1);
}
printf("pid: %d, child process is done...\n", getpid());
return 0;
}
//父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0); //进程等待
if(ret == -1) {
perror("waitpid");
return 1;
} else {
printf("wait pid: %d, exit signal: %d, exit code: %d\n", ret, (status & 0x7f), (status >> 8 & 0xFF));
}
return 0;
}
可以看到,我们在命令行上使用 “ls -a -l” 和我们使用进程程序替换得到的结果是一样的。
exec使用总结:
第一个参数:
若有“p”且该函数的地址存放在环境变量PATH中,则直接写函数名,不用写路径;
否则都要写明路径。
第二个参数:在 Linux 命令行中该程序如何执行我们就如何传参
若有“v”,则将各个指令依次存入指针数组
若有“l”,则将每一个选项都要单独分为一个字符串,给exec传参