操作系统,揭开钢琴的盖子-3
C++软件与嵌入式软件面经解析大全(蒋豆芽的秋招打怪之旅)
本章讲解点
- 1.1 操作系统的来历
- 1.2 操作系统的功能
- 1.3 操作系统的硬件知识
- 1.4 linux下编译程序
- 1.5 Makefile
- 1.6 linux的常用指令
- 1.7 进程的概念
- 1.8 进程的状态转换
- 1.9 进程的创建
- 1.10 守护进程
- 1.11 僵尸进程与孤儿进程
- 1.12 wait()或waitpid()系统调用
- 1.13 进程通信——管道
- 1.14 进程通信——系统IPC
- 1.15 进程通信——socket套接字
- 1.16 线程
- 1.17 线程的创建
- 1.18 线程通信——互斥锁
- 1.19 线程通信——信号量
- 1.20 线程通信——条件变量
- 1.21 线程通信——读写锁
- 1.22 线程池
- 1.23 协程
- 1.24 虚拟内存
- 1.25 页表
- 1.26 缺页中断
- 1.27 缺页置换算法
- 1.28 锁
- 1.29 操作系统资源调度方法
- 1.30 IO模型类型
受众:本教程适合于C/C++已经入门的学生或人士,有一定的编程基础。
本教程适合于互联网、嵌入式软件求职的学生或人士。
故事背景
蒋 豆 芽:小名豆芽,芳龄十八,蜀中人氏。卑微小硕一枚,科研领域苟延残喘,研究的是如何炒好一盘豆芽。与大多数人一样,学习道路永无止境,间歇性踌躇满志,持续性混吃等死。会点编程,对了,是面对对象的那种。不知不觉研二到找工作的时候了,同时还在忙论文,豆芽都秃了,不过豆芽也没头发啊。
隔壁老李:大名老李,蒋豆芽的好朋友,技术高手,代码女神。给了蒋豆芽不少的人生指导意见。
导 师:蒋豆芽的老板,研究的课题是每天对豆芽嘘寒问暖。
故事引入
导 师:豆芽,我一个眼神你明白了吧?
蒋 豆 芽:老师,没问题!论文刚把跌!(冲!)
豆芽继续埋头干面经。。。。。。
蒋 豆 芽:(笑容邪魅)老李,你上次讲的很好啊!
隔壁老李:嗯哼,谢谢夸奖哦!
蒋 豆 芽:(暗示)我说你讲的很好啊!
隔壁老李:(得意)我知道啊,我确实讲得好,这一点我很清楚,别人都说我有当老师的气质呢!
蒋 豆 芽:不是,不是这个意思。
隔壁老李:(困惑)那你什么意思?
蒋 豆 芽:我是说,你不打算再继续讲讲?
隔壁老李:(笑容邪魅)我就知道你想说啥,哈哈!
蒋 豆 芽:(不好意思)嘿嘿,大佬,再讲讲吧,操作系统的知识好多都不懂啊,救救豆芽吧!
隔壁老李:哈哈,好吧。上次我们讲了一些预先知识,为后面做铺垫,今天就是要讲我们的重点知识了!
蒋 豆 芽:(激动)好的,老李,来,赶紧上车!
1.7 进程概念
隔壁老李:我们先来看看进程的概念。程序是指令、数据及其组织形式的描述,而进程则是程序的运行实例,包括程序计数器、寄存器和变量的当前值。
我们上一节讲了并发与并行,对于单个CPU而言,一个时刻只能运行一个进程,这自然是个并发的过程。如图:一个时刻内只有一个进程在运行。
隔壁老李:然后我们来看一下Linux的进程结构,一般分为三部分:代码段、数据段(.data与.bss)和堆栈段。
代码段用于存放程序代码,如果有多个进程运行相同的一个程序,那么它们可以使用同一个代码段。代码段还会存储一部分常量,如字符串常量字面值。
而数据段则存放程序的全局变量和静态变量。
堆栈段中的栈用于函数调用,存放着函数的参数、局部变量。
蒋 豆 芽:(疑惑)那么程序是怎么转换为进程的呢?
隔壁老李:这个问题问得很好。听我慢慢讲。我们之前已经讲过,一个程序的生成分为四个阶段:预编译、编译、汇编和链接,最后生成可执行文件。当程序执行时,操作系统将可执行文件复制到内存中,然后经过以下几个步骤转换为进程:
- 内核将程序读入内存,为程序分配内存空间。
- 内核为该进程分配进程标识符(PID,记住这个名称)和其他所需资源
- 内核为进程保存PID及相应的状态信息,并且将进程放入运行队列中等待执行。
- 由操作系统调度执行。
我们来举个例子,比如我们写一个C文件,里面包含了一个main函数,在操作系统里编译然后运行,经过上述一系列步骤,那么操作系统就将这个执行程序“包装”成一个进程,进程里面再创建一个线程来执行这个可执行文件。过程就是如此。
这里解释一下,为什么还要创建一个线程,因为我们现在的操作系统是多线程的体系,一个进程里可以包含多个线程,至少有一个线程。以前的操作系统是单道或多道系统,即进程直接执行程序,那么就跟线程没关系了。我们后面的讲解都是基于多线程体系来的。这一点豆芽你要注意。
隔壁老李:一个进程对应唯一标识符PID。标识符类型为pid_t,是一个无符号整型。同一个可执行程序可以被加载为多个不同的进程。因此进程与PID是一对一关系,而进程与程序文件之间是多对一关系。
蒋 豆 芽:(满头大汗)老李,你慢点,小笔记写不过来了。
1.8 进程五种状态
隔壁老李:(语速加快)进程有五种状态:创建、就绪、执行、阻塞、终止。一个进程创建后,被放入队列处于就绪状态,等待操作系统调度执行,执行过程中可能切换到阻塞状态(并发),任务完成后,进程销毁终止。如图:
创建状态
一个应用程序从系统上启动,首先就是进入创建状态,需要获取系统资源创建进程管理块(PCB:Process Control Block)完成资源分配。
就绪状态
在创建状态完成之后,进程已经准备好,处于就绪状态,但是还未获得处理器资源,无法运行。
运行状态
获取处理器资源,被系统调度,当具有时间片开始进入运行状态。如果进程的时间片用完了就进入就绪状态。
阻塞状态
在运行状态期间,如果进行了阻塞的操作,如耗时的I/O操作,此时进程暂时无法操作就进入到了阻塞状态,在这些操作完成后就进入就绪状态。等待再次获取处理器资源,被系统调度,当具有时间片就进入运行状态。
终止状态
进程结束或者被系统终止,进入终止状态
蒋 豆 芽:(不行了,要打断老李)那老李,我们怎么创建、销毁一个进程呢?
1.9 进程创建方式
隔壁老李:既然我们要让一个程序(可执行文件)运行起来,那么自然就要创建进程了呀,进程的创建方式有两种:一种由操作系统创建;一种由父进程创建。
我们先讲由操作系统创建的进程。在系统启动时,操作系统会创建一些进程,它们承担着管理和分配资源的任务,这些进程维持这系统的稳定运行,被称为系统进程。
另一种方式就是由父进程创建。系统允许一个进程创建新进程(即子进程),子进程又可以创建新的子进程,形成树结构。子进程创建成功后,子进程将存在于系统之中,并且独立于父进程。子进程可以接受系统调度,可以分配资源。那么创建一个子进程,常用fork()函数,其原型如下:
#include <unistd.h> pid_t fork(void); /* fork()函数不需要参数,返回值是一个进程标识符PID。返回值有以下三种情况: (1) 对于父进程,fork()函数返回新创建的子进程的PID。 (2) 对于子进程,fork()函数调用成功会返回0。 (3) 如果创建出错,fork()函数返回-1。 */
fork()函数创建一个新进程后,会为这个新进程分配进程空间,将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段。
这时候,子进程和父进程一模一样,都接受系统的调度。因为两个进程都停留在fork()函数中,最后fork()函数会返回两次,一次在父进程中返回,一次在子进程中返回,两次返回的值不一样,如上面的三种情况。
蒋 豆 芽:具体怎么创建呢?(接下来老李要写代码了,我可以休息下了,吁。)
隔壁老李:(拿出写好的用例)接下来我们给出用例:这个例子让父子进程分别获取PID。豆芽,快点记。
蒋 豆 芽:(满头大汗)遵命。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(){ pid_t pid = fork(); if (pid < 0){ printf("fork error!"); exit(-1); //退出进程 } else if (pid == 0) //子进程 printf("Child--PID : %u, Parent--PID : %u\n",getpid(),getppid()); else{ //父进程 printf("Parent--PID : %u, Child--PID : %u\n",getpid(),pid); sleep(2); //休眠2s,让子进程先运行 } return 0; }
运行结果如下:
Child--PID : 4, Parent--PID : 3 Parent--PID : 3, Child--PID : 4
getpid()用于获得当前进程的pid,而getppid()则是获取父进程的pid。
exit()函数可以退出进程,原型如下:
#include <stdlib.h> void exit(int status);
exit()函数的参数表示进程的退出状态,0表示进程正常退出;其他的值表示进程异常退出。
这时我们就成功使用fork函数创建了子进程。
蒋 豆 芽:(赞同)嗯嗯,原来如此,看着很直观。
隔壁老李:再次强调,子进程完全复制了父进程的地址空间的内容,包括数据段和堆栈段的内容。但是子进程却和父进程共享代码段,这个好理解,因为代码段只读,不会被修改,那么为节约进程空间,共享就行了。
隔壁老李:而在Linux的后续发展中,当创建新进程时,连数据段和堆栈段都不再立马复制了,而是等到需要修改数据段或堆栈段的数据时再复制,这就是写时复制。
这样更加节省了进程空间,效率更高。豆芽,你明白了吗?面试官经常问哦,要好好记住。
蒋 豆 芽:没问题!老李,你讲得很清楚啊,困扰我多年的进程我今天终于明白了。
1.10 守护进程
隔壁老李:???才开始学操作系统,就多年的困扰了?
蒋 豆 芽:(囧)人艰不拆嘛。
隔壁老李:(嘻嘻)前面我们提到了系统进程,其中有一种特殊的进程,那就是守护进程,面试也喜欢问,我们就来介绍一下。
守护进程是运行在后台的一种生存期长的特殊进程。它独立于控制终端,处理一些系统级别任务。
创建过程如下:
创建子进程,终止父进程。方法是调用fork()产生一个子进程,然后使父进程退出。
调用setsid()创建一个新会话。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们,不受它们的影响,方法是调用setsid() 使进程成为一个会话组长。setsid() 调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组和控制终端脱离。
进程组:是一个或多个进程的集合。
会话周期:会话期是一个或多个进程组的集合。
禁止进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现。
将当前目录更改为根目录。使用fork() 创建的子进程也继承了父进程的当前工作目录。由于在进程运行过程中,当前目录所在的文件系统不能卸载,因此,把当前工作目录换成其他的路径,如根目录。
重设文件权限掩码。文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。 由于使用 fork() 函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。 因此,把文件权限掩码设置为0 ,可以大大增强该守护进程的灵活性。 设置文件权限掩码的函数是umask ,通常的使用方法为 umask(0)。
关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。
我们给出一个实例:实现一个简单的守护进程(每隔2s在/tmp/douya.c中写入一句话)
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/stat.h> #define MAXFILE 65535 int main(){ //第一步:创建进程 int pid = fork(); if (pid > 0) exit(0);//结束父进程 else if (pid < 0){ printf("fork error!\n"); exit(1);//fork失败,退出 } //第二步:子进程成为新的会话组长和进程组长,并与控制终端分离 setsid(); //第三步:改变工作目录到 chdir("/"); //第四步:重设文件创建掩模 umask(0); //第五步:关闭打开的文件描述符 for (int i=0; i<MAXFILE; ++i) close(i); while (1){ int fd; if ((fd=open ("/tmp/douya.c", O_CREAT|O_WRONLY|O_APPEND, 0600)) < 0){ perror("open"); exit (1) ; } write(fd, "processing...\n", strlen("processing...\n")+1); close(fd); sleep(2); } return 0; }
编译程序后,我们运行程序:
$ ./douya_protected
发现程序一下子就退出了。我们用指令查看一下后台程序
$ ps -ef | grep protected
实际上发现我们的程序依然运行着,只不过在后台运行。我们查看"/tmp/douya.c"下的日志,可以看到,进程每隔2s写入"processing...\n"。
明白了吧豆芽,所以,如果你想创建一个可以脱离终端运行的进程,可以使用守护进程。其实,linux提供了daemon函数用于创建守护进程,实现原理与上文中介绍的是一样的。这样使用起来就更简单了。
蒋 豆 芽:wonderful!
1.11 僵尸进程与孤儿进程
隔壁老李:接下来我们就要讲僵尸进程。
蒋 豆 芽:哦?还有这种说法?植物大战僵尸吗?
隔壁老李:(敲脑袋)一天想什么呢?好好听讲行不行!我们逐步讲解,在Linux中,正常情况下,子进程是通过父进程创建的,子进程又创建新的进程,子进程退出后,将由父进程调用wait()或者waitpid()系统调用取得子进程的终止状态。然后就要回收子进程的资源。
然而子进程的结束和父进程的运行是一个异步过程,即父进程无法预测子进程什么时候结束。于是就会产生孤儿进程和僵尸进程。
- 孤儿进程,是指一个父进程退出后,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并且由init进程对它们完成状态收集工作。
- 僵尸进程,是指一个进程使用fork函数创建子进程,如果子进程退出,而父进程并没有调用wait()或者waitpid()系统调用取得子进程的终止状态,那么子进程的进程描述符仍然保存在系统中,占用系统资源,这种进程称为僵尸进程。
所以两者的区别是:孤儿进程是父进程已退出,子进程未退出;而僵尸进程是父进程未退出,子进程已退出。
隔壁老李:僵尸进程实例如下:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { pid_t pc = fork(); //创建一个子进程 if (pc > 0){//父进程 printf("in parent process, wait some minutes...\n"); sleep(10); printf("after waiting, parent process exits!\n"); } else if (pc == 0){ //子进程退出,父进程没有调用wait()或waitpid()系统调用 //子进程将成为一个僵尸进程 printf ("in child process, let it exist!\n"); exit(0); } return 0; }
我们通过fork创建子进程,先让父进程等待10s钟,同时让子进程退出,而父进程没有调用wait()或waitpid()系统调用,使得子进程变为僵尸进程。
在程序运行中,我们通过指令查看进程状态:
$ ps aux | grep -w 'Z'
可以清楚地看到,出现了一个僵尸进程。当我们的父进程10s等待完毕后,再次查看
僵尸进程没有了。因为父进程退出后,这个僵尸进程已成为孤儿进程,由init进程管理,而init进程会周期性地调用wait系统调用来清除僵尸进程。
蒋 豆 芽:原来是这样啊,有点意思哈!
隔壁老李:那豆芽,这里介绍了僵尸进程的概念。而在实际应用中,如果系统总是产生大量僵尸进程而得不到回收,就会占用大量的系统资源,当然有害于我们系统的正常运行。所以我们要及时回收我们的进程资源。
1.12 wait()和waitpid()系统调用
蒋 豆 芽:那应该怎么回收呢?
隔壁老李:我们前面说了,可以使用wait()或waitpid()系统调用,进程一旦调用了wait函数,就立即阻塞自己本身,然后由wait函数自动分析当前进程的某个子进程是否已经退出,当找到一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞,直到有一个出现为止。函数原型如下:
#include<sys/types.h> #include<sys/wait.h> pid_t wait(int* status);
子进程的结束状态值会由参数status返回,而子进程的进程识别码也会一起返回。如果不需要结束状态值,则参数status可以设成 NULL。
我们来看个实例:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include<sys/types.h> #include<sys/wait.h> int main() { pid_t pc = fork(); //创建一个子进程 if (pc > 0){//父进程 printf("in parent process\n"); //阻塞在这里,等待子进程退出 pid_t pchild = wait(NULL); printf("catch a child process pid of %d\n", pchild); } else if (pc == 0){ printf ("in child process, PID: %u, PPID: %u\n", getpid(), getppid()); exit(0);//子进程退出 } else { printf("fork error!\n"); exit(-1); } return 0; }
运行结果如下:
in parent process in child process, PID: 2812, PPID: 2811 catch a child process pid of 2812
从结果可以看到,我们成功捕获了子进程的PID,最后子进程将销毁,资源得到释放。
隔壁老李:waitpid()功能与wait相同,但可指定pid进程清理,可以不阻塞。原型如下:
#include <sys/types.h> #include <sys/wait.h> pid_t waitpid(pid_t pid, int *status, in options); //成功:返回清理掉的子进程ID;失败:-1(无子进程)
特殊参数和返回情况,参数pid:
> 0回收指定ID的子进程
-1回收任意子进程(相当于wait)
0回收和当前调用waitpid一个组的所有子进程
< -1回收指定进程组内的任意子进程
参数status:
子进程的结束状态值会由参数status返回,而子进程的进程识别码也会一起返回。如果不需要结束状态值,则参数status可以设成 NULL。
参数options:
Options=WNOHANG时,如果使用了WNOHANG(wait no hung)参数调用waitpid,即使没有子进程退出,它也会立即返回,不会像wait那样永远等下去。
Options=WUNTRACED时,则子进程进入暂停则马上返回,但结束状态不予以理会。
如果不想使用,Options=0
注意:一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。
看一个实例:
#include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> int main(void){ pid_t pid = fork(); if (pid < 0) { perror("fork error!\n"); exit(-1); } if (pid == 0) { for (int i=4; i>0; --i) { printf("This is the child\n"); sleep(2); } exit(32); } else { int stat_val; waitpid(pid, &stat_val, 0);//0阻塞 if (stat_val) //stat_val为传出参数 printf("Child exited with code %d\n", stat_val); else if (stat_val) printf("Child terminated abnormally, signal %d\n", stat_val); } return 0; }
运行结果如下:
This is the child This is the child This is the child This is the child Child exited with code 8192
子进程打印了4次信息后被回收,waitpid成功捕获了子进程,结束状态也一并打印出来了。
隔壁老李:通过wait()或waitpid()系统调用,我们就实现了及时回收子进程的目标。
蒋 豆 芽:嗯嗯,是的。
隔壁老李:豆芽,还有一种办法,我们上一节才学过,你猜猜?
蒋 豆 芽:emmmm,我想起来了,可以通过kill命令终止进程。
隔壁老李:没错!发送指定的信号到相应进程。可以先打开终端并输入下面命令:
$ ps aux | grep Z
会列出进程表中所有僵尸进程的详细内容。
然后输入命令:
$ kill -s SIGCHLD pid(父进程pid)
这样子进程退出后,父进程就会收到信号了。
或者可以强制杀死父进程:
$ kill -9 pid(父进程pid)
这样父进程退出后,这些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并且由init进程对它们完成状态收集工作。
隔壁老李:累死了,这节先讲到这里,我们下一节讲进程的通信方式。
蒋 豆 芽:好,多谢你,老李!
故事完
参考文献
[1] 徐晓鑫. 后台开发:核心技术与应用实践[M].北京:机械工业出版社,2016.
[2] 安德鲁S.塔嫩鲍姆,赫伯特·博斯. 现代操作系统 [M]. 北京:机械工业出版社,2017.7
[3] 蒋豆芽. C++后端面试知识点大全(春秋招必备)[M]. 长沙:豆芽出版社,2020.
<p> - 本专刊适合于C/C++已经入门的学生或人士,有一定的编程基础。 - 本专刊适合于互联网C++软件开发、嵌入式软件求职的学生或人士。 - 本专刊囊括了C语言、C++、操作系统、计算机网络、嵌入式、算法与数据结构等一系列知识点的讲解,并且最后总结出了高频面试考点(附有答案)共近400道,知识点讲解全面。不仅如此,教程还讲解了简历制作、笔试面试准备、面试技巧等内容。 </p> <p> <br /> </p>