文章目录
- 一、进程创建
- 1.1 初识 fork 函数
- 1.2 fork 函数返回值
- 1.3 写时拷贝
- 1.4 fork 的常规用法
- 1.5 fork 调用失败的原因
- 1.6 创建一批进程
- 二、进程终止
- 2.1 进程退出场景
- 2.2 strerror函数
- 2.3 errno全局变量
- 2.4 程序异常
- 2.5 进程常见退出方法
- 2.6 exit 函数
- 2.7 _exit 函数和 exit 函数的区别
- 三、进程等待
- 3.1 进程等待的必要性
- 3.2 什么是进程等待?
- 3.3 进程等待具体是怎么做的?
- 3.3.1 wait方法
- 3.3.2 waitpid方法
- 3.3.3 父进程只等待一个进程(阻塞式等待)
- 3.3.4 父进程等待多个子进程(阻塞式等待)
- 3.4 获取子进程的退出信息(阻塞式等待)
- 3.5 wait、waitpid的实现原理
- 3.6 非阻塞轮询等待
- 四、结语
一、进程创建
1.1 初识 fork 函数
在 Linux 中,fork 函数用于从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void); // fork 函数声明
返回值:子进程中返回0;父进程中返回子进程的 pid,出错返回-1。
一个进程调用 fork 函数后,当控制转移到内核中的 fork 代码后(执行 fork 函数的代码),内核做了如下一些工作:
-
分配新的内存块和内核数据结构给子进程。
-
将父进程部分数据结构内容拷贝到子进程中。
-
添加子进程到系统进程列表当中。
-
fork 返回,开始调度器调度。
小Tips:其实做完前两步,子进程就已经被创建出来了。
当一个进程调用 fork 之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程。说的再多还是需要通过代码来演示证明。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("befor pid:%d\n", getpid());
fork();
printf("after pid:%d\n", getpid());
return 0;
}
这里打印了三行输出,一行是 befor 两行是 after。所以,fork 之前父进程独立执行,fork 之后,父子两个执行流分别执行。注意,fork 之后,谁先执行完全由调度器决定。
1.2 fork 函数返回值
-
子进程返回0。
-
父进程中返回子进程的 pid,出错返回-1。
关于 fork 函数的返回值问题,在【Linux取经路】揭秘进程的父与子一文中已做了详细介绍,其中包括“为什么给子进程返回0,给父进程返回 pid”、“fork 函数是如何做到返回两次的”。感兴趣的小伙伴可以点回去看看。
1.3 写时拷贝
通常,父子进程代码共享,父子进程再不写入的时候,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式再生成一份。
操作系统是如何知道要进行写时拷贝的呢?答案是:父进程在创建子进程的时候,操作系统会把父子进程页表中的数据项从读写权限设置成只读权限,此后父进程和子进程谁要对数据进行写入就一定会触发权限方面的问题,在进行权限审核的时候,操作系统会识别出来,历史上要访问的这个区域是可以被写入的,只不过暂时是只读状态,父子进程不管谁尝试对数据区进行写入的时候都会触发权限问题,但是针对这这种情况操作系统并不做异常处理,而是把数据拷贝一份,谁写的就把页表项进行重新映射,在拷贝完成后,就把只读标签重新设置成可读可写。
操作系统为什么要采用写时拷贝呢?父进程在创建子进程的时候,单纯的从技术角度去考虑,操作系统完全可以让父子进程共享同一份代码,然后把父进程的多有数据全部给子进程拷贝一份,技术上是完全可以实现的,但是操作系统为什么没有这样干?而是采用写时拷贝呢?原因主要有以下几点,首先假设父进程中国有100个数据,子进程只需要对其中的一个进行修改,剩下的99个子进程只读就可以,那如果操作系统把这100个数据全给子进程拷贝了一份,无疑是干了一件吃力不讨好的工作,全部拷贝既浪费了时间又浪费的物理内存,操作系统是绝对不会允许这种情况发生的,因此,对于数据段,操作系统采用的是写时拷贝的策略。
1.4 fork 的常规用法
-
一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端的请求,生成子进程来处理请求。
-
一个进程要执行一个不同的程序。例如子进程从 fork 返回后,调用 exec 函数。
1.5 fork 调用失败的原因
-
系统中有太多的进程。
-
实际用户的进程数超过了限制。
1.6 创建一批进程
通过 for 循环创建一批进程。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#define N 5
void func()
{
int cnt = 10;
while(cnt)
{
printf("I am chid, pid:%d, ppid:%d\n", getpid(), getppid());
cnt--;
sleep(1);
}
return;
}
int main()
{
int i = 0;
for(i = 0; i < N; i++)
{
pid_t id = fork();
if(id == 0)// 只有子进程会进去
{
func();
exit(0);// 子进程走到这里就退出了
}
}
sleep(1000);
return 0;
}
小Tips:父进程执行的速度是很快的,由于父进程的 for 循环里没有 sleep 函数,所以五个子进程几乎是在同一时间被创建出来,创建出来的每一个子进程会去调用 func 函数,每一个子进程执行完 func 函数后会执行 exit 函数退出。父子进程谁先执行完全是由调度器来决定的。
二、进程终止
2.1 进程退出场景
-
代码运行完毕,结果正确
-
代码运行完毕,结果不正确
-
代码异常终止
一般代码运行完毕,结果正确,我们是不会关心代码为什么跑对了。但是当代码运行完毕,结果不正确,我们作为程序员是需要知道为什么结果不正确,因此进程需要将运行结果以及不正确的原因告诉程序员。这就是 main 函数里常写的 return 0
的作用。return 后面跟的数字叫做进程的退出码,表征进程的运行结果是否正确,不同的返回数字表征不同的出错原因,0表示 success。main 函数 return 的这个0,最终会被父进程,即 bash 拿到。可以在 bash 中输出 echo $?
指令查看上一个子进程的退出码。$?
表示命令行当中最近一个进程运行的退出码。
int main()
{
printf("模拟一段逻辑!\n");
return 0;
}
小Tips:对于一个进程,一般而言只有父进程最关心它的运行情况
2.2 strerror函数
上面提到的退出码本质上是数字,它更适合机器去查看,作为程序员我们可能对数字没有那么敏感,即可能不知道该数字表示的是什么意思。因此 strerror
函数的作用就是将一个退出码转换成为一个错误信息描述。可以通过下面这段代码来打印当前系统支持的所有错误码对应的错误信息。
int main()
{
int i = 0;
for(; i < 200; i++)
{
printf("%d, %s\n", i, strerror(i));
}
return 0;
}
2.3 errno全局变量
errno 是 C 语言给我们提供的一个全局变量,它里面保存的是最近一次执行的错误码,何谓最近一次执行?C 语言为我们提供了很多的库函数,在调用这些库函数失败的时候,C 语言就会将 errno 设置成对应的数字,这个数字就表示调用该函数出错的错误码。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main()
{
int ret = 0;
char* str = (char*)malloc(1000*1000*1000*4);
if(str == NULL)
{
printf("malloc error:%d, %s\n", errno, strerror(errno));
ret = errno;
}
else
{
printf("malloc success!\n");
}
return ret;
}
2.4 程序异常
代码如果出现了异常,本质上代码可能就没有跑完,因此可能就没有执行 return 语句。所以程序如果出现了异常,那么该程序的退出码是没有意义的。因此对于一个执行结束的进程来说,我们要先看它是否出异常,如果没有异常再去看它的退出码是否正确。对于异常我们也需要知道程序为什么异常,以及发生了什么异常。进程出现异常,本质上是我们的进程收到了对应的信号。像程序中除0,空指针解引用,一般都会引发硬件错误,由我们的操作系统向对应的进程发送信号。Linux 系统的所有信号如下图所示。
int main()
{
char* pc = NULL;
*pc = 'a'; // 解引用空指针,会发生段错误
return 0;
}
下面证明该异常是因为程序收到了对应的信号。
2.5 进程常见退出方法
正常终止(指程序的代码执行完了结束,而不是收到信号结束)。
-
从 main 函数返回,即 return
-
调用 exit 函数
-
_exit
异常退出。
-
ctrl+c
-
信号终止
2.6 exit 函数
#include <unistd.h>
void exit(int status);
在代码中的任何地方调用 exit
函数,都表示调用进程直接退出。退出码就是 exit
函数的参数 status
。说这个主要是为了区分 return
和 exit
,return 只有在主函数(main)中出现才表示进程退出,在普通的函数中使用 return 仅表示函数返回,而在函数中使用 exit,也会让进程直接退出。
2.7 _exit 函数和 exit 函数的区别
上面的现象我们是可以理解的,printf 函数后面没有加 \n
,因此要打印的内容先被保存在了缓冲区中,等休眠两秒后,程序执行 exit
退出,程序退出会刷新缓冲区,所以程序运行我们看到的效果是前两秒什么也没打印,在程序退出前才执行了打印。下面我们把 exit
换成 _exit
再看看效果。
这次程序执行后,等待了两秒直接退出了,并没有将信息打印出来。
结论:_exit
是系统调用,exit
是库函数。exit 最后会调用 _exit,但是在调用 _exit 之前,还做了下面几个工作。
-
执行用户通过 atexit 或 on_exit 定义的清理函数。
-
关闭所有打开的流,所有的缓冲区数据均被写入。
-
调用 _exit()。
小Tips:通过上面的现象我们可以的出一个结论,那就是缓冲区一定不在内核中,而是在用户空间。因为如果在内核中,那调用 _exit 函数的时候,也必然会把缓冲区中的数据进行刷新,如果不刷新,那还维护这个缓冲区干嘛呢?正是因为缓冲区在用户区,_exit 作为系统调用看不到用户区的数据,所以才没办法刷新。
三、进程等待
3.1 进程等待的必要性
-
在前面的文章中讲过,子进程退出,父进程如果不管不顾,就可能造成“僵尸进程”的问题,进而会造成内存泄露。
-
另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的
kill -9
指令也无能为力,因为谁也没有办法杀死一个已经死去的进程。 -
最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
-
父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
总结:僵尸进程无法被杀死,需要通过进程等待来杀掉它,进而解决内存泄露的问题,这是进程等待的必要性。其次通过进程等待,让父进程获得子进程的退出情况,看布置的任务完成的怎么样了,这一点对父进程来说是可选项,即父进程也可以选择不关心,如果要关心了,需要通过进程等待去获取。
3.2 什么是进程等待?
进程等待就是在父进程的代码中,通过系统调用 wait/waitpid
,来进行对子进程进行状态检测与回收的功能。
3.3 进程等待具体是怎么做的?
3.3.1 wait方法
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
-
返回值:成功,返回被等待进程的 pid,失败返回-1。
-
参数:输出型参数,获取子进程的退出状态,不关心则可以设置成为 NULL。
3.3.2 waitpid方法
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* status, int options);
-
返回值:当正常返回的时候
waitpid
返回等待到的子进程的进程 ID;如果设置了选项WNOHANG
,而调用的过程中没有子进程退出,则返回0;如果调用中出错,则返回-1,这时errno
会被设置成相应的值以指示错误所在。 -
参数
pid
:pid = -1
表示等待任意一个子进程。与wait
等效;pid > 0
表示等待进程 ID 与pid
相等的子进程。 -
参数
status
:WIFEXITED(status)
:查看子进程是否正常退出。若为正常终止子进程返回的状态,则为真;WEXITSTATUS(status)
:查看进程的退出码。若非零,提取子进程的退出码。 -
参数
options
:0:表示父进程以阻塞的方式等待子进程,即子进程如果处在其它状态,不处在僵尸状态(Z状态),父进程会变成 S 状态,操作系统会把父进程放到子进程 PCB 对象中维护的等待队列中,以阻塞的方式等待子进程变成僵尸状态,当子进程运行结束,操作系统会检测到,把父进程重新唤醒,然后回收子进程;WNOHANG
:非阻塞轮询等待,若 pid 指定的子进程没有结束,处于其它状态,则waitpid()
函数返回0,不予等待。若正常结束,则返回该子进程的 ID。
小Tips:wait 和 waitpid 都只能等待该进程的子进程,如果等待了其它的进程那么就会出错。
3.3.3 父进程只等待一个进程(阻塞式等待)
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
// child
int cnt = 5;
while(cnt)
{
printf("I am child, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(0);
}
else
{
int cnt = 10;
// parent
while(cnt)
{
printf("I am parent, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt--);
sleep(1);
}
int ret = wait(NULL);
if(ret == id)
{
printf("wait success!\n");
}
sleep(5);
}
return 0;
}
结果分析:前五秒父子进程同时运行,紧接着子进程退出变成僵尸状态,五秒钟后父进程对子进程进行了等待,成功将子进程释放掉,最后再五秒钟后父进程也退出,整个程序执行结束。
3.3.4 父进程等待多个子进程(阻塞式等待)
一个 wait
只能等待任意一个子进程,因此父进程如果要等待多个子进程可以通过循环来多次调用 wait
实现等待多个子进程。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define N 5
// 父进程等待多个子进程
void RunChild()
{
int cnt = 5;
while(cnt--)
{
printf("I am child, pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
}
return;
}
int main()
{
for(int i = 0; i < N; i++)
{
pid_t id = fork();// 创建一批子进程
if(id == 0)
{
// 子进程
RunChild();
exit(0);
}
// 父进程
printf("Creat process sucess:%d\n", id);
}
sleep(10);
for(int i = 0; i < N; i++)
{
pid_t id = wait(NULL);
if(id > 0)
{
printf("Wait process:%d, success!\n", id);
}
}
sleep(5);
return 0;
}
小Tips:如果子进程不退出,父进程在执行 wait
系统调用的时候也不返回(默认情况),默认叫做阻塞状态。由此可以看出,一个进程不仅可以等待硬件资源,也可以等待软件资源,这里的子进程就是软件。
3.4 获取子进程的退出信息(阻塞式等待)
在 2.1 小结提到过,进程有三种退出场景。正是因为有这三种退出场景,父进程等待希望获得子进程退出的以下信息:子进程代码是否异常;没有异常,结果对嘛?不对是因为什么呢? 子进程这些所有的退出信息都被保存在 status
参数里面。
-
wait
和waitpid
都有一个status
参数,该参数是一个输出型参数,由操作系统填充。 -
如果传递 NULL,表示不关心子进程的退出状态信息。
-
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
-
status
不能简单的当做整形来看待,可以当做位图来看待,具体细节如下图(只需要关注 status 低16比特位)
小Tips:操作系统没有0号信号,因此,如果低七位是0说明子进程没有收到任何信号。
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
// child
int cnt = 5, a = 10;
while(cnt)
{
printf("I am child, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt--);
sleep(1);
a /= 0; // 故意制造一个异常
}
exit(11); // 将退出码故意设置成11
}
else
{
// parent
int cnt = 10;
while(cnt)
{
printf("I am parent, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt--);
sleep(1);
}
// 目前为止,进程等待是必须的!
//int ret = wait(NULL);
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
// 获取子进程退出状态信息的关键代码
// 0111 1111:0x7F,1111 1111 0000 0000:0xFF00
printf("wait success! exit signal:%d, exit code:%d!\n", status&0X7F, (status >> 8)&0XFF);
}
sleep(5);
}
return 0;
}
小Tips:通过运行结果可以看出,子进程收到了8号信号,子进程的退出码是0。代码中子进程的退出码被我们设置成了11,这侧面印证了我们上面讲到的,进程收到信号后被异常终止,此时代码没有执行完毕,所以此时进程的退出码是不可信的。
// 常规的进程等待代码
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
// 0111 1111:0x7F,1111 1111 0000 0000:0xFF00
//printf("wait success! exit signal:%d, exit code:%d!\n", status&0X7F, (status >> 8)&0XFF);
if(WIFEXITED(status))
{
printf("子进程正常退出,退出码是:%d\n", WEXITSTATUS(status));
}
else
{
printf("子进程被异常终止!\n");
}
}
3.5 wait、waitpid的实现原理
一个进程在退出后,父进程回收之前,它的代码和数据都被释放了,但是它的 PCB 对象并没有被释放,因为它收到的信号和退出码信息都保存在 PCB 对象中,wait 和 waitpid 本质上就是操作系统去检查一个进程是否处于僵尸状态(Z状态),如果处于 Z 状态就去它的 PCB 对象中拿到该进程收到的信号和退出码信息,再把这些信息赋值给 status,然后将该进程的状态设置成 X。这个工作只能由操作系统来做,因为 PCB 对象属于内核数据结构对象,不允许用户直接访问。
3.6 非阻塞轮询等待
前面说过,若父进程采用阻塞式等待,如果子进程没有处于僵尸状态,那么此时父进程处于阻塞状态什么也干不了。若父进程采用非阻塞轮询等待,如果子进程没有处于僵尸状态,那么父进程可以继续去干它的事情。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
// 父进程只等待一个子进程(非阻塞轮询等待)
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
// child
int cnt = 5;
while(cnt)
{
printf("I am child, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt--);
sleep(1);
//a /= 0;
}
exit(11);
}
else
{
// parent
// 目前为止,进程等待是必须的!
//int ret = wait(NULL);
while(1)
{
int status = 0;
int ret = waitpid(id, &status, WNOHANG);
if(ret > 0)
{
// 0111 1111:0x7F,1111 1111 0000 0000:0xFF00
//printf("wait success! exit signal:%d, exit code:%d!\n", status&0X7F, (status >> 8)&0XFF);
if(WIFEXITED(status))
{
printf("子进程正常退出,退出码是:%d\n", WEXITSTATUS(status));
}
else
{
printf("子进程被异常终止!\n");
}
break;
}
else if(ret == 0)
{
// 父进程的任务可以写在这里
printf("child process is running...\n");
}
else
{
printf("等待出错!\n");
}
sleep(1);
}
sleep(2);
}
return 0;
}
小Tips:在非阻塞轮询等待过程中父进程可以去执行自己的任务,前提是该任务轻量化且可返回,非阻塞轮询等待的核心任务还是回收子进程。子进程创建出来父子进程谁先执行是由调度器说了算,进程等待在一定程度上确保了父进程一定是最后一个退出的,这样可以避免子进程变为僵尸进程,进而导致内存泄露的问题。
四、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!