本节重点:
- 学习进程创建,fork/vfork
- 学习到进程等待
- 学习到进程程序替换, 微型shell,重新认识shell运行原理
- 学习到进程终止,认识$?
一、进程创建
1.1.fork函数初识
在linux中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.out Before: pid is 43676 After:pid is 43676, fork return 43677 After:pid is 43677, fork return 0
这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after 消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示:
所以,fork之前父进程独立执行,此时的一行before是父进程执行的,fork之后,父子两个执行流分别执行第一个after同样是父进程执行的,但是第二个after是子进程执行的。注意,fork之后,谁先执行完全由调度器决定。
1.2.fork函数返回值
子进程返回0, 父进程返回的是子进程的pid。
1.3.写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
1.为什么要有写时拷贝?
写时拷贝(Copy-on-Write,简称COW)是一种优化策略,通常用于管理共享数据的副本。它的主要目的是在减少资源消耗的同时提高性能。写时拷贝的工作原理是延迟对象的复制,只有在有写操作发生时才执行实际的拷贝操作。
以下是一些使用写时拷贝的常见场景和优势:
- 节约内存: 写时拷贝允许多个进程或线程共享同一份数据副本,而不会立即复制整个数据结构。只有在某个进程试图修改数据时,才会为该进程创建数据的独立副本。这样可以在一定程度上减少内存的使用。
- 提高性能: 写时拷贝可以减少对共享数据的不必要复制,从而提高程序的性能。因为只有在写入操作时才进行实际的拷贝,读取操作则可以在多个进程之间共享相同的数据副本,减少了不必要的开销。
- 并发性: 写时拷贝使得多个进程可以同时读取相同的数据,而无需互斥地访问。只有在有写入操作时,才需要执行复制操作,并在新副本上执行写入。这有助于提高并发性能。
- 延迟复制: 写时拷贝的特点是在需要修改数据时才执行复制,而不是在创建副本时。这样可以延迟复制操作,避免在数据被多个进程只读访问时进行不必要的复制。
2.创建子进程的时候,为什么不直接把父进程的数据直接给子进程呢?
- 父进程的数据可能很大,直接复制给子进程可能会产生大量的开销。而此时子进程又不会使用父进程的数据,此时拷贝就会造成大量消耗。
3.写时拷贝先开辟空间然后再把数据拷贝过去,我已经要开始写入的,直接把空间给我不就行了,反正你拷贝的数据待会我也要修改,还不如不用拷贝,直接叫写时申请不更好吗?
- 写时拷贝并不是将父进程所有的数据都会进行覆盖,有可能父进程的某些原始数据子进程还会使用到,如果直接只给空间,此时可能无法使用父进程某些数据。
1.4.页表
页表除了虚拟地址和物理地址项,还存在权限一项。
页表的权限字段用于控制对虚拟页面的访问权限,确保系统的安全性和稳定性。这些权限通常包括以下几种:
- 读取权限(Read): 允许程序读取虚拟页面中的内容。如果一个进程尝试在没有读取权限的情况下读取该页面,会触发访问异常,通常导致程序终止或引发其他异常。
- 写入权限(Write): 允许程序修改虚拟页面中的内容。如果一个进程尝试在没有写入权限的情况下写入该页面,同样会触发异常。写权限的存在有助于保护内存不被未经授权的写操作破坏。
- 执行权限(Execute): 允许程序在虚拟页面上执行指令。这是为了防止一些安全漏洞,如缓冲区溢出攻击,通过禁止在某些内存区域执行代码,可以增加系统的安全性。
- 访问权限(Access): 这是一个综合了读、写、执行权限的控制。有时候,页表中的权限字段可能被设计为一个比特位组合,用于同时表示多种权限。
这些权限可以在页表的每一项中进行设置,以实现对虚拟内存的灵活控制。操作系统可以在不同的情况下设置不同的权限,以保障系统的稳定性和安全性。例如,操作系统可能会将一些关键的系统页面设置为只读或不可执行,以防止用户进程对其进行修改或执行。我们来看一段代码
#include <stdio.h> int main() { const char* str = "hello world!"; *str = 'H';//将'h'改为'H',error return 0; }
上面的代码我们在C语言阶段就学习过,上面的代码是运行错误的,上面的常量字符串是具有常性的,它在进程地址空间中的字符常量区,不能对常量数据做任何修改。常量区的代码为什么能保存常量行,为什么不能修改?因为上面代码中的str保存的是'hello world!'的起始地址,前面我们也提到过,它就是我们的虚拟的地址,当我们将'h'改为'H'时,此时就注定会发生从虚拟地址到物理地址的转化,们将'h'改为'H'就是写入的操作,此时我们页表全向项必须具有'w'权限,如果此时我们没有'w'权限,页表就不会形成映射关系,所以写入失败,所以页表有权限,常量区一般都是被映射到只读的物理内存页,于是他就有了语言当中的常量字符串是不能修改的结论的。
写时拷贝是如何做到的呢?当我们要进行写时拷贝之前,此时父子进程的数据段权限都是只读的,当我们要进行写时拷贝的时候,此时写入就会发生错误,此时就会出现缺页中断,然后操作系统就会删掉父子进程的数据段的只读权限,此时就能写入数据,并且重新形成映射关系,待写入操作完成之后,此时再回复父子进程的数据段的只读权限。
1.5.fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子 进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
1.6.fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、进程终止
2.1.进程退出场景
我们先来看一段代码
#include <stdio.h> int main() { int i = 1; int j = 2; int k = i + j; return 0; }
我上面的代码如何判断程序是正常结束的呢?我们的程序没有打印结果,但是我们可以通过main函数的返回值进行判断,返回值为0,表示进程执行成功,非0,表示失败,所以main函数的返回值,叫做进程的退出码。一旦失败,我们就需要找到失败的原因,通过用不同的返回值数字,表示不同的失败原因。我们通过echo $?查看最近执行的一个程序的退出码。
为什么后两次的退出码是0呢?因为后两次运行的程序不再是我们的代码,而是echo $?,它的退出码是0,所以退出码的工作就是告诉父进程或者bash,我运行成功了或者失败了,但是我们作为学习者,我们肯定不仅仅想知道错误码,我们更想知道程序失败的原因,此时我们就更希望这个错误码能转化为错误描述,这个错误描述我们可以使用语言和系统自带的方法,进行转化,也可以自定义!
1.使用语言和系统自带的方法,进行转化
我们来使用一下,看看结果。
#include <stdio.h> int main() { int i = 0; for(i = 0; i < 200; i++) { printf("%d:%s\n",i,strerror(i)); } return 0; }
我们再来看一下运行结果:
从上面的图片我们可以知道,Linux中一共只有134个错误码(0-133),然后我们再看看错误码和错误信息对应。
2.自定义!
运行结果:
main函数return返回的时候,表示进程退出,return 退出码,可以设置退出码的字符串含义。而其他函数的返回值,只仅仅表示该函数结束,仅仅表示函数调用完毕!那么怎么看一个函数是否失败和失败的原因呢?
#include <stdio.h> #include <errno.h> int main() { FILE* fp = fopen("1.txt","r"); printf("%d:%s\n",errno,strerror(errno)); return errno; }
运行结果:
函数也具有和进程退出一样的具体的退出原因,我们把这个叫做错误码,可以通过errno获得。errno 是一个全局变量,通常用于在 C 语言中指示函数调用失败时的错误码。它的声明通常包含在 头文件中。在 C 语言中,当一些函数调用失败时,它们通常会设置 errno 来指示错误的类型。errno 的值通常是一个整数,代表一种特定的错误。可以通过包含 头文件,并查看 errno 的值来获取函数调用失败的原因。在 C 语言中,errno 是一个全局变量,通常由 C 标准库或底层系统调用设置。因此,如果你自己编写的函数没有直接调用标准库函数或底层系统调用,它可能无法直接使用 errno 获取错误信息。
总结进程退出的场景:
- 进程代码执行完,结果是正确的。
- 进程代码执行完,结果是错误的。
- 进程代码没有执行完,进程出异常了(本质是进程收到了异常信号 kill -l查看信号,每个信号都有不用的编号,不同的信号编号表明异常的原因)。
任何进程最终的执行情况,我们可以使用两个数字表明具体的执行情况:signumber和exit_code。
signumber | exit_code | 进程状态 |
0 | 0 | 进程代码执行完,结果是正确的 |
0 | 1 | 进程代码执行完,结果是错误的 |
1 | 0 | 进程代码没有执行完,进程出异常了(此时结果无意义) |
1 | 1 | 进程代码没有执行完,进程出异常了(此时结果无意义) |
2.2.进程常见退出方法
2.2.1.正常终止(可以通过 echo $? 查看进程退出码):
- 1. 从main返回(上面提到过,这里就不多解释了)
return退出 return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。
- 2. 调用exit(3号手册 - C语言库函数)
我们来使用一下
运行结果:
那我们在其他函数调用exit函数接口,我们上面程序的死循环还能终止吗?
运行结果:
exit可以终止当前正在运行的进程,exit的参数就是退出码,在我们的进程代码中,任何地方调用exit接口,都表示进程退出!
- 3. _exit(2号手册 - 系统调用)
我们来使用一下
运行结果:
我们发现_exit功能和我们上面的exit一样,但是他们之间是有区别的。
所以exit会刷新所有标准 I/O 流的缓冲区,而_exit不能刷新所有标准 I/O 流的缓冲区。我们的进程是由操作系统终止的,但是这个终止是用户想要的,所以操作系统就必须为用户提供系统调用_exit,而一个用户想要终止进程,就必须调用系统调用接口,所以我们可以肯定的是exit(库函数)的底层实现是封装了_exit(系统调用)的。所以这些缓冲区是在用户空间(由标准 C 库实现)管理的,而不是在操作系统内核中。如果这些缓冲区是在操作系统内核,那么exit(库函数)和_exit(系统调用)都会刷新缓冲区的,操作系统不会做任何浪费空间,降低效率的事情,如果_exit不会刷新,那么操作系统就根本不会写入,如果写入到操作系统,操作系统一定会刷新缓冲区,事实上,并没有刷新,说明这些缓冲区肯定不是在操作系统内核中。那进程退出的时候,操作系统做了什么呢?释放进程地址空间,释放页表,释放代码和数据空间,但是进程的PCB不能释放,因为我们要获取进程的退出信息。
_exit函数
#include <unistd.h> void _exit(int status); 参数:status 定义了进程的终止状态,父进程通过wait来获取该值
说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值 是255。
exit函数
#include <unistd.h> void exit(int status); //exit最后也会调用_exit, 但在调用exit之前,还做了其他工作:
- 1. 执行用户通过 atexit或on_exit定义的清理函数。
- 2. 关闭所有打开的流,所有的缓存数据均被写入
- 3. 调用_exit
2.2.2.异常退出:
- ctrl + c,信号终止
三、进程等待
3.1.进程等待必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法 杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对, 或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息(exit_code和exit_signal)。
3.2.进程等待的方法
为什么要等待呢?
1.父进程通过wait方式,回收子进程的资源(必然必须)
2.通过wait方式,获取子进程的退出信息(可选的)
【打造你自己的Shell:编写定制化命令行体验】(二):https://developer.aliyun.com/article/1425817