目录
📖一、进程创建函数---fork函数
声明: 目录1中内容基于这篇文章
目录1往后内容是单独成立的
1.初识fork函数
在 Linux 中,fork 函数用于从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void); // fork 函数声明
返回值:子进程中返回0;父进程中返回子进程的 pid,出错返回-1。
一个进程调用 fork 函数后,当控制转移到内核中的 fork 代码后(执行 fork 函数的代码),内核做了如下一些工作:
-
分配新的内存块和内核数据结构(如PCB)给子进程。
-
将父进程部分数据结构内容拷贝到子进程中。
-
添加子进程到系统进程列表当中。
-
fork 返回,开始调度器调度。
小Tips:其实做完前两步,子进程就已经被创建出来了。
(调度器调度解释:)
调度器调度就是操作系统中负责合理分配 CPU 时间给各个进程或线程的功能模块。它会根据不同的调度算法,如考虑任务的优先级、到达顺序、执行时间等因素,决定哪个任务接下来可以使用 CPU,以保证系统高效、稳定且公平地运行,让多个任务能有序地共享 CPU 资源。
2.fork返回值
-
子进程返回0。
-
父进程中返回子进程的 pid,出错返回-1。
这里是这么理解的:
fork生成一个进程后,就会存在父进程和子进程,父进程需要知道子进程运行的怎么样,所以父进程中返回子进程的pid。而子进程没有需要了解的其他进程,所以返回0
3.写时拷贝
在使用fork创建父子进程时,初始阶段父子进程共享代码段和数据段的物理内存空间。这意味着,它们在内存中指向同一份代码和数据,此时操作系统并不会立即为子进程分配独立的物理内存来存放数据,这样做的目的是为了减少内存开销和提升进程创建效率 。
解释:
而当父子进程中任意一方尝试修改数据(如写入新值、修改变量内容等操作)时,操作系统的 “写时拷贝” 机制就会被触发。系统会:
- 分配新内存:为执行写入操作的进程(父进程或子进程)单独分配一块新的物理内存空间。
- 复制数据:将原有共享内存中的数据完整复制到新分配的内存空间中。
- 更新页表:修改该进程的页表,使其指向新开辟的空间,后续的写入操作都将在这块独立的内存中进行,而另一进程仍维持对原有共享内存的引用
操作系统是如何知道要进行写时拷贝的呢?
答案是:父进程在创建子进程的时候,操作系统会把父子进程页表中的数据项从读写权限设置成只读权限,此后父进程和子进程谁要对数据进行写入就一定会触发权限方面的问题。
在进行权限审核的时候,操作系统会识别出来,历史上要访问的这个区域是可以被写入的,只不过暂时是只读状态,因此父子进程不管谁尝试对数据区进行写入的时候都会触发权限问题,但是针对这这种情况操作系统并不做异常处理,而是把数据拷贝一份,谁写的就把页表项进行重新映射,在数据拷贝完成后,就把只读标签重新设置成可读可写。
操作系统为什么要采用写时拷贝呢?
父进程在创建子进程的时候,从技术角度去考虑,操作系统完全可以让父子进程共享同一份代码,然后把父进程的所有数据全部给子进程拷贝一份,技术上是完全可以实现的,但是操作系统为什么没有这样干?而是采用写时拷贝呢?
原因主要有以下几点,首先假设父进程有100个数据,子进程只需要对其中的一个进行修改,剩下的99个子进程只读就可以,那如果操作系统把这100个数据全给子进程拷贝了一份,无疑是干了一件吃力不讨好的工作,全部拷贝既浪费了时间又浪费的物理内存,操作系统是绝对不会允许这种情况发生的,因此,对于数据段,操作系统采用的是写时拷贝的策略。
4.fork 的常规用法
-
一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端的请求,生成子进程来处理请求。
-
一个进程要执行一个不同的程序。一个进程中父进程执行某一段代码,子进程执行某一段代码,互不冲突
5.fork 调用失败的原因
-
系统中有太多的进程。
-
实际用户的进程数超过了限制。
📖二、进程终止
2.1 进程退出情况
一个进程只有三种情况会退出:
-
代码运行完毕,结果正确
-
代码运行完毕,结果不正确
-
代码异常终止
一般代码运行完毕,结果正确,我们是不会关心代码为什么跑对了。但是当代码运行完毕,结果不正确,我们作为程序员是需要知道为什么结果不正确,因此进程需要将运行结果以及不正确的原因告诉程序员。这就是 main 函数里常写的 return 0 的作用。
return 后面跟的数字叫做进程的退出码,表征进程的运行结果是否正确,不同的返回数字表征不同的出错原因,0表示 success。main 函数 return 的这个0,最终会被父进程,即 bash 拿到。可以在 bash 中输出 echo $? 指令查看上一个子进程的退出码。$? 表示命令行当中最近一个进程运行的退出码。
2.2 strerror函数
上面提到的退出码本质上是数字,它更适合机器去查看,作为程序员我们可能对数字没有那么敏感,即可能不知道该数字表示的是什么意思。因此 strerror
函数的作用就是将一个退出码转换成为一个错误信息描述。可以通过下面这段代码来打印当前系统支持的所有错误码对应的错误信息。
int main()
{
int i = 0;
for(; i < 200; i++)
{
printf("%d, %s\n", i, strerror(i));
}
return 0;
}
这里还有很多,一直到200,就不展示了
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系统的所有信号如下图所示。
一共有64种异常
这里用代码举个例子
int main()
{
char* pc = NULL;
*pc = 'a'; // 解引用空指针,会发生段错误
return 0;
}
2.5 exit 函数
#include <unistd.h>
void exit(int status);
exit
函数的作用是终止程序执行,并向操作系统返回指定的退出状态码。
exit的退出码为参数status
区分return与exit:
return 只有在主函数(main)中出现才表示进程退出,在普通的函数中使用 return 仅表示函数返回。而在函数中使用 exit,会让进程直接退出。
当然,exit的退出码也可以被bash识别到
代码展示:
可以发现,直接就退出进程了,没有执行后续代码,并且返回的退出码为1
2.6 _exit 函数和 exit 函数的区别
我们分为两段代码来测试一下
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
void func1()
{
sleep(2);
printf("Hello world");
exit(1);
}
void func2()
{
sleep(2);
printf("hello world");
_exit(2);
}
int main()
{
func1();
//func2();
}
注意:我们的输出printf没有加 ' \n ' ,所以内容应当是先被保存在缓冲区的,在程序结束前出输出来了
第一次运行(只运行func1):
可以发现,第一次调用的exit函数输出了Hello world
第二次运行(只运行func2):
第二次运行调用的_exit 什么都没打印出来,这是为什么?
其实这是因为,exit的底层调用的是_exit
_exit
是系统调用,exit
是库函数。exit 最后会调用 _exit,但是在调用 _exit 之前,还做了下面几个工作。
-
执行用户通过 atexit 或 on_exit 定义的清理函数。
-
关闭所有打开的流,所有的缓冲区数据均被写入。
-
调用 _exit()。
可以理解为exit在调用_exit函数前会将缓冲区的内容刷新出来,而_exit不会刷新缓冲区的内容
为什么_exit函数不会刷新缓冲区?
因为_exit是系统函数,可以直接与内核交互,但是它没有刷新缓冲区,这也证明了缓冲区并不在内核
但是exit能刷新缓冲区,主要是因为它是 C 标准库函数,处于用户空间,这也说明了缓冲区位于用户空间
(初步认为->:)最后,正是因为缓冲区不在内核中,而在用户空间中,所以_exit不能刷新出缓冲区,exit能刷新出缓冲区
📖三、进程等待
3.1 进程等待的必要性
在前面的文章中讲过,子进程退出,父进程如果不管不顾,就可能造成“僵尸进程”的问题,进而会造成内存泄露。
另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的 kill -9 指令也无能为力,因为谁也没有办法杀死一个已经死去的进程。
最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
总结:僵尸进程无法被杀死,需要通过进程等待来杀掉它,进而解决内存泄露的问题,这是进程等待的必要性。
其次通过进程等待,让父进程获得子进程的退出情况,看布置的任务完成的怎么样了,这一点对父进程来说是可选项,即父进程也可以选择不关心,如果要关心了,需要通过进程等待去获取。
3.2 什么是进程等待?
进程等待就是在父进程的代码中,通过系统调用 wait/waitpid,
来进行对子进程进行状态检测与回收的功能。
3.3 进程等待具体是怎么做的?
#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);
参数说明
pid
:指定要等待的子进程的进程 ID。如果pid
为-1
,则等待任意一个子进程,如果pid
大于0
,则只等待指定进程 ID 的子进程。status
:与wait
函数中的status
参数作用相同,是一个指向int
类型变量的指针,用于存储子进程的退出状态信息。可以通过一些宏来解析该状态信息
宏如 WIFEXITED(status)
用于判断子进程是否正常退出,WEXITSTATUS(status)
用于获取正常退出子进程的退出码等。
options
:提供了一些额外的选项来控制waitpid
的行为。常用的选项有 WNOHANG,表示如果指定的子进程没有结束,waitpid
不会阻塞当前进程,而是立即返回0
;还有WUNTRACED
,用于跟踪子进程的停止状态,即当子进程因收到信号而停止时,waitpid
也会返回。
3.4 wait使用实例->:
#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)
{
// child
sleep(10);
exit(0);
}
else
{
int ret = wait(NULL);
if(ret == id)
{
printf("wait success!\n");
}
sleep(5);
}
return 0;
}
结果分析:出现父子进程后,父子进程同时进行,子进程10秒后退出,变成了僵尸进程,而父进程因为wait一直在等待子进程的返回值,当父进程收到子进程的返回值后,传给了ret
需要注意的是:wait只能等待一个子进程!!!
3.5 父进程等待多个子进程(阻塞式等待)
一个 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;
}
可以发现,每一个子进程都被wait依次捕获到了
小Tips:如果子进程不退出,父进程在执行 wait
系统调用的时候也不返回(默认情况),默认叫做阻塞状态。由此可以看出,一个进程不仅可以等待硬件资源,也可以等待软件资源,这里的子进程就是软件。
3.6 waitpid使用实例
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid;
int status;
// 创建子进程
pid = fork();
if (pid == 0) {
// 子进程
sleep(2);
exit(5);
} else if (pid > 0) {
// 父进程
// 等待子进程结束,使用WNOHANG选项,不会阻塞父进程
pid_t wpid = waitpid(pid, &status, WNOHANG);
if (wpid == 0) {
printf("子进程还未结束,父进程继续执行其他任务...\n");
// 父进程可以在这里执行其他操作
sleep(3);
// 再次等待子进程结束,这次不使用WNOHANG选项,会阻塞父进程
wpid = waitpid(pid, &status, 0);
}
if (wpid == pid) {
if (WIFEXITED(status)) {
int exit_code = WEXITSTATUS(status);
printf("子进程已退出,退出码为 %d\n", exit_code);
}
} else if (wpid == -1) {
perror("waitpid失败");
}
} else {
perror("fork失败");
}
return 0;
}
3.7 获取子进程的退出信息(阻塞式等待)
在 2.1 小结提到过,进程有三种退出场景。正是因为有这三种退出场景,父进程等待希望获得子进程退出的以下信息:子进程代码是否异常;没有异常,结果对嘛?不对是因为什么呢? 子进程这些所有的退出信息都被保存在 status
参数里面。
-
wait
和waitpid
都有一个status
参数,该参数是一个输出型参数,由操作系统填充。 -
如果传递 NULL,表示不关心子进程的退出状态信息。
-
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
-
status
不能简单的当做整形来看待,可以当做位图来看待,具体细节如下图(只需要关注 status 低16比特位)
小Tips:操作系统没有0号信号,因此,如果低七位是0说明子进程没有收到任何异常信号。
3.8 wait、waitpid的实现原理
一个进程在退出后,父进程回收之前,它的代码和数据都被释放了,但是它的 PCB 对象并没有被释放,因为它收到的信号和退出码信息都保存在 PCB 对象中,wait 和 waitpid 本质上就是操作系统去检查一个进程是否处于僵尸状态(Z状态),如果处于 Z 状态就去它的 PCB 对象中拿到该进程收到的信号和退出码信息,再把这些信息赋值给 status,然后将该进程的状态设置成 X。这个工作只能由操作系统来做,因为 PCB 对象属于内核数据结构对象,不允许用户直接访问。
📖四、完结
创作不易,留下你的印记!为自己的努力点个赞吧!