史上最全的C++/游戏开发面试问题总结(三)—— 操作系统下篇

史上最全的C++/游戏开发面试问题总结(三)—— 操作系统下篇_第1张图片

参考书籍:《Operating Systems: Internals and Design Principles》《操作系统的设计与实现》《现代操作系统》《深入理解计算机系统》《Windows核心编程》《深入理解Linux内核》

如果没有时间的话,没有必要都看,可以根据目录找你关注的问题,回复“操作系统”可以获取这些电子书。如果觉得文章有价值,希望能帮我分享一下~

问:进程是如何调度的?常见的进程调度策略有哪些,Windows、Linux都是采用的哪种(提问概率:★★★★)

1.先来先服务FCFS 该算法既可用于作业调度,也可用于进程调度。当在作业调度中采用该算法时,每次调度都是从后备作业队列中选择一个或多个最先进入该队列的作业,将它们调入内存,为它们分配资源、创建进程,然后放入就绪队列。在进程调度中采用FCFS算法时,则每次调度是从就绪队列中选择一个最先进入该队列的进程,为之分配处理机,使之投入运行。该进程一直运行到完成或发生某事件而阻塞后才放弃处理机。

2.短作业优先/短进程优先(SJF/SPF),短作业优先(SJF)的调度算法是从后备队列中选择一个或若干个估计运行时间最短的作业,将它们调入内存运行。而短进程优先(SPF)调度算法则是从就绪队列中选出一个估计运行时间最短的进程,将处理机分配给它,使它立即执行并一直执行到完成,或发生某事件而被阻塞放弃处理机时再重新调度。

3.高优先权优先(FPF)

1) 非抢占式优先权算法

在这种方式下,系统一旦把处理机分配给就绪队列中优先权最高的进程后,该进程便一直执行下去,直至完成;或因发生某事件使该进程放弃处理机时,系统方可再将处理机重新分配给另一优先权最高的进程。这种调度算法主要用于批处理系统中;也可用于某些对实时性要求不严的实时系统中。

2) 抢占式优先权调度算法

在这种方式下,系统同样是把处理机分配给优先权最高的进程,使之执行。但在其执行期间,只要又出现了另一个其优先权更高的进程,进程调度程序就立即停止当前进程(原优先权最高的进程)的执行,重新将处理机分配给新到的优先权最高的进程。

4.时间片轮询法 每个进程被分配一个时间段,称为时间片,即允许该进程在该时间段中运行。如果在时间片结束时该进程还在运行,则将剥夺CPU并分配给另一个进程。如果该进程在时间片结束前阻塞或结束,则CPU立即进行切换。

无论是Windows还是Linux,进程的调度方法其实都远比前面提到的几个算法要复杂,但是总的来说更像时间片轮巡+抢占式优先权调度算法。

Windows:调度单位是线程。

1.时间配额,类似时间片分时时间配额可以调整。

2.抢占式动态优先级调度 系统总是选择优先级最高的就绪线程进行执行,优先级相同就按时间片轮转。线程分为实时优先级(基本优先级)与可变优先级,实时优先级线程优先级不会改变,可变优先级可能在I/O操作完成、就绪时间过长、信号量等待结束等情况调整。

Linux:调度单位是进程,早期的Linux调度室比较简单的,基本算是纯粹的分时+优先级调度。Linux2.6以后算法变得复杂,与Windows也比较类似。

1.时间片分时,每个进程运行一个时间片,时间片用尽则被中断切换。时间片的长短是根据进程的类型而变化的。

2.抢占式动态优先级调度,当一个进程进入TASKRunning状态时,如果他的优先级比当前运行进程的优先级大,那么就会执行中断切换到这个进程执行。不过,一般进程有静态优先级以及动态优先级,进程的动态优先级是会被操作系统动态调整的。对于长时间没有使用过的CPU的进程会动态增加优先级,对于在CPU上运行较长时间的进程会减少其优先级(CFS调度算法)。

3.强制禁止与调整 如果一个进程的优先级永远比另一个进程高,按照前面的说法,低优先级的进程可能会永远没有机会去运行了。所以为了避免进程“饥饿”,系统会对用完时间片的某些高优先级进程进行禁止,等待其他低优先级进程用完时间片后再恢复。 另外,Linux系统中存在普通进程与实时进程,实时进程优先级要远高于普通进程,如果实时进程在运行,普通进程基本上是没有机会分到时间片的。

参考资料与链接:《操作系统概念》《深入理解Linux内核》

https://blog.csdn.net/gatieme/article/details/51699889

https://zh.coursera.org/lecture/os-pku/windows-de-xian-cheng-diao-du-suan-fa-lvXx0

问:操作系统如何管理内存?如何解释虚拟内存?进程地址空间,堆、栈是什么样的(提问概率:★★★★★)

一般操作系统分为内核区域与用户区,用户区对应的是虚拟内存,32位系统下0-4G。因为每个进程理论上都可以使用这个大小的内存,所以需要引入虚拟内存通过页处理转换到真正的物理内存上面。每个进程分为内存虚拟内存与用户虚拟内存,内核区有内核代码数据区(PCB等)。进程虚拟部分有堆,栈,bss,data初始化的全局静态,text代码段等。

史上最全的C++/游戏开发面试问题总结(三)—— 操作系统下篇_第2张图片

问:虚拟内存的意义(提问概率:★★★)

1.节省内存资源,拿出一部分硬盘空间来充当内存使用,动态替换加载

2.使得应用程序在逻辑上认为它拥有连续的可用的内存,方便管理

3.提高安全性,程序员只知道逻辑地址

问:常见页面置换算法(提问概率:★★★)

最佳置换算法(OPT)

最佳置换算法是由Belady于1966年提出的一种理论上的算法。其所选择的被淘汰页面,将是以后永不使用的,或是在最长(未来)时间内不再被访问的页面。采用最佳置换算法,通常可保证获得最低的缺页率,由于无法预测各页面将来的使用情况,只能利用“最近的过去”作为“最近的将来”的近似。

先进先出置换算法(FIFO)

总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面给予淘汰。

时钟页面置换算法(clock)

这种算法只是模型像时钟,其实就是一个环形链表的第二次机会算法,表针指向最老的页面。缺页中断时,执行相同的操作,包括检查R位等。

最近最久未使用(LRU)算法

LRU置换算法是选择最近最久未使用的页面予以淘汰

参考资料与链接:

https://www.cnblogs.com/fkissx/p/4712959.html

https://blog.csdn.net/wuxy720/article/details/78941721

问:操作系统内存为什么要分成堆与栈?二者功能上有什么差异?(提问概率:★★★)

栈由编译器分匹配,空间小,Linux默认8M左右,WIndows默认1M左右(可以调整用户栈)。栈主要用来存储函数调用堆栈信息,局部变量,函数调用参数等相关信息,速度快。

我们想在运行时控制存储数据结构的内存需求大小,以及其生命周期,所以要有一个满足我们要求的空间,也就是堆。堆由大片的可利用的块或空闲组成,堆中的内存可以按照任意顺序分配和释放。堆的大小理论上与虚拟内存大小相差无几,但是实际上用户可用的堆要小很多。

问:听过孤儿进程与僵尸进程么(提问概率:★★)

这两个概念主要在Linux/Unix系统上

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。孤儿进程由于被init进程收集,所以结束时会被正常的处理,不会遗留冗余的信息在系统中。

僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符等信息仍然保存在系统中,会对系统造成查询与内核占用负担,这种进程称之为僵尸进程。我们一般要采取一些手段避免僵尸进程占用系统资源,常见的方式有两种,一种是子进程执行结束通过回调通知父进程处理,另一种是创建两次子进程并通过终止第一个子进程把子进程的子进程交给init处理。

参考资料与链接:《unix环境高级编程》

问:进程同步与线程同步(线程安全)(提问概率:★★★★★)

由于线程的执行是基于时间片抢占的,所以我们不能确定线程的执行顺序,一旦多个线程操作一个共享资源的时候很可能由于执行顺序不一致(竞争条件)导致数据混乱,也可能有多个线程对资源同时进行修改(多线程多核情况下),所以需要一定的手段来协调不同线程的执行顺序、控制当前线程读取或修改资源的权限,从而保护资源数据。这个手段其实就是线程同步。

同理,一般一个进程至少包含一个主线程,所以进程同步的目的与线程同步一样,都是为了避免破坏共享资源数据或者操作不当导致的相关问题。不过同一个进程下的线程可以共享进程的堆空间数据,不同进程间的通信则通过管道、信号、共享内存等方式。所以,二者的共享资源以及对共享资源的获取方式是不同的。线程粒度更小,同步方式更多,可以认为进程的同步是线程同步方式的子集。

对于线程同步这里举个例子,一个全局变量Num执行Y=++Num,Y=++Num不是原子操作,其实在执行过程中分为读Num、写Num、赋值三个步骤。如果A线程在执行到Num++的时候,B线程给Num执行了++操作(或者说A线程Num++的时候被B线程抢占),那么Num其实执行了两次++。那么切换回去后Y的值也相当于Y = Num+2;。更关键的问题是,这个情况是一定概率下发生的,线程的切换以及执行顺序我们无法控制。这个例子其实不算严重,做多也就是数据出问题,不过有的涉及到指针删除操作的如果执行混乱可能导致程序崩溃,所以为了保证线程安全,我们要实现线程同步。

常见的同步方式有:临界区(Windows上属于用户模式下的一种实现方式,本质上是通过信号量实现,进程内部的线程同步)、互斥量(内核对象,可以跨进程同步,属于信号量的特例)、事件(内核对象,可以跨进程同步)、信号量(内核对象,可以跨进程同步)

参考资料与链接:

https://www.cnblogs.com/nufangrensheng/p/3521654.html

https://blog.csdn.net/wuhuagu_wuhuaguo/article/details/78591330

https://www.cnblogs.com/TenosDoIt/p/3601458.html

问:什么是原子操作,什么是临界区(提问概率:★★★★)

原子操作:就是计算机里不可拆分、不可中断的操作,要么执行完成,要么执行失败。例如:x = 1,其汇编指令是:mov 1,eax。x对应寄存器eax,一步到位,所以是原子操作。而X++,需要先读取x,如load eax,x(将x读入eax寄存器中),然后再去执行加1操作,分成两步,不是原子操作。

临界区:是一个访问共享资源的程序代码段(共享资源包括:鼠标键盘设备或是一个用户态的堆内存等),这个代码段同一时刻只能被一个线程锁访问。当有一个线程进入临界区代码段时,其他线程或是进程必须等待。

从本质上讲,临界区只是一个代码段,只要你用锁去实现的达到其效果的片段都是临界区。宏观上来说,属于进程(线程)同步的一种方式,操作系统可能会提供类似的API(或者说同步对象)供你调用来实现临界区的效果。

问:什么是竞争条件,为什么会产生,如何避免?(提问概率:★★★★★)

由于多线程的执行顺序不同造成结果不同的情况叫做竞争条件race condition。竞争条件产生的根本原因就是因为多线程执行顺序的不确定性,如果没有合理的控制好线程之间的同步,那么就会造成竞争条件(例子在线程同步问题里有描述),严重的话就会造成进程崩溃。最常见的手段就是给可能产生竞争条件的代码区加锁(Mutex,本质是内核中的锁对象),使用临界区(Critical Section)。

当然,你还需要了解哪些地方的代码可能产生竞争条件。这个问题并不简单,有的时候我们往往会忽略掉一些特殊的情况,比如函数的返回值、引用的跨线程传递等。

参考资料与链接:《C++并发编程》

问:常见锁以及其使用(提问概率:★★★★)

互斥锁(抢锁失败则阻塞,让出CPU):用来保护临界区的,达到同一时刻只能一个线程访问这块区域。

读写锁(允许多个读线程操作):很多业务逻辑里面,读的操作要远远大于写的操作,而读操作不会改变内存数据,这样使用互斥锁就会大大降低性能,所以添加读写锁来进行效率上的优化。

自旋锁(抢锁失败一直循环等待,一直占用cpu):属于互斥锁的一种实现方式。假如有A、B两个线程,由于抢占资源需要对资源加锁,当A先访问后B再访问就会由于失败而进入阻塞状态,而同时他会保存上下文信息进行线程切换。但是其实A线程可能很快就会使用完资源释放锁,这样B线程还要再切换回来,进行了两次上下文切换,浪费了系统资源,降低性能。这里用自旋锁的话,B线程就会循环等待而不用进行上下文切换。

问:乐观锁与悲观锁(提问概率:★★★)

乐观锁先读取并处理(通过cpu提供的原子操作指令组成的CAS完成),处理后看数据和处理人前一样不,一样表示没人动过可以改,否则拒绝(认为没有加锁)。

悲观就是认为不加锁一定会出问题,所以执行前必须先加锁后再处理。

问:生产者与消费者问题(提问概率:★★★★★)

生产者消费者问题,其实就是一个生活中常见的例子,一个生产者可以不停的生产产品,放到一个存储室里面,另外有一个消费者不停的从生产者那里获取产品并使用。不过我们要保证存储室满的时候生产者要暂停生产,消费者每次去拿产品的时候存储室那里要有产品。放到操作系统里面,就是要我们通过进程(线程)去模拟去解决这个问题。一般来说,就是有两个进程(线程),一个模拟生产者,一个模拟消费者,操作系统有一个缓冲区作为存储室,这个模型其实就是我们在进行进程(线程)通信中经常遇到的情况。当然,复杂一点的话,还可能出现一个生产者对应多个消费者,多个生产者对应多个消费者的情况。

对于进程来说分为跨机器与本地。跨机器通信用socket,一般使用阻塞的消息队列来模拟这个问题。而本地进程间的通信,常见的方式是使用管道、信号量(由于这些通信机制本身就带有互斥等特性)等。

对于线程,可以共享进程的内存来通信。也是通过阻塞的消息队列来模拟,生产者写到队列头,消费者取队列尾。

具体的实现细节可以参考下面链接。

参考资料与链接:

https://zh.wikipedia.org/wiki/%E7%94%9F%E4%BA%A7%E8%80%85%E6%B6%88%E8%B4%B9%E8%80%85%E9%97%AE%E9%A2%98

https://www.cnblogs.com/hckblogs/p/7858545.html

https://www.infoq.cn/article/producers-and-consumers-mode

问:银行家算法(提问概率:★★★★)

银行家算法,目的是解决资源分配不合理导致的死锁问题。在银行中,客户申请贷款的数量是有限的,每个客户在第一次申请贷款时要声明完成该项目所需的最大资金量,在满足所有贷款要求时,客户应及时归还。银行家在客户申请的贷款数量不超过自己拥有的最大值时,都应尽量满足客户的需要。在这样的描述中,银行家就好比操作系统,资金就是资源,客户就相当于要申请资源的进程。

银行家(也就是操作系统),监控所有资源以及当前进程所需要的所有资源数量。按照当前情况先满足一个进程,把他想要的资源给他,然后再计算最后看这个序列会不会产生资源死锁的情况,如果可能出现死锁就放弃把资源给他。

问:常见进程通信方式与原理(提问概率:★★★★★)

进程间通信的本质是在内存开辟一块区域,然后两个进程通过这块内存进行信息交流。

管道(pipe):半双工,用于父子进程通信,父进程在内核开辟一块缓冲区,得到两个文件描述符分别指向两端,子进程同理,然后父进程关闭读端进行写操作,子进程关闭写端进行读操作从而实现了两个进程的通信。

有名管道(FIFO):半双工,相比管道添加了一个路径名从而可以使两个不相关的进程进程通信,也可以跨机器通信。

信号(signal):类似于软中断,如果进程接受到了信号,就相当于发生了中断,这时候进程会切换到内核态去执行信号处理函数。进程之间可以通过系统调用互相发送信号,也可以在收到信号时做相应的回调。信号是类型是系统中事先定义好的,我们可以在发送某些特殊的信号加一些参数。

消息队列:系统提供的一种机制,可以从一个进程向另一个进程发送自定义的消息,这些消息会被放到一个特殊的内存区域,可以通过一个指针获取。多个消息的指针会构成一个队列,被内核管理着,其他进程可以从这个队列里面获取消息数据。

共享内存:通过系统调用开辟一块特殊的内存,然后映射到不同的进程上面,这样不同的进程在访问这一块内存的时候都可以像访问自己用户区内存一样,从而实现通信。

信号量:内核实现的一种特殊的对象,由操作系统赋予其控制进程状态的特性,信号量的值表示有几个任务可以访问该信号量所保护的共享资源。二进制信号量是信号量的一个子集,通过PV操作实现访问资源线程的互斥(实现临界区)

套接字(socket):常用于跨机器通信,属于应用层与传输层的一个连接媒介。

参考资料与链接:

https://www.cnblogs.com/taobataoma/archive/2007/08/30/875743.html

https://jin-yang.github.io/post/kernel-signal-introduce.html

https://blog.csdn.net/gatieme/article/details/50908749

https://blog.csdn.net/chen22075x/article/details/41594445

https://blog.csdn.net/qq_19782019/article/details/79746627

问:RPC有接触过么(提问概率:★★★)

RPC是指远程过程调用,比如说有两台计算机A和B。A机器上的一个应用想要调用B机器上应用提供的某个函数,由于不在一个内存空间,你的函数地址以及变量内容都不一样,不能直接调用。不过可以通过某种手段,将函数的参数、名称等内容传递到另一台机器,另一台机器通过解析你的数据转换成你想要调用的本地函数。RPC主要是针对跨机器通信,有时候我们的函数必须在其他的机器上也能良好运行,所以需要RPC。当然,另一方面,我认为RPC也可以增加我们的代码可读性。

史上最全的C++/游戏开发面试问题总结(三)—— 操作系统下篇_第3张图片

游戏开发那些事

回复"gamebook",获取游戏开发书籍

回复"C++面试",获取C++/游戏面试经验

回复"游戏开发入门",获取游戏开发入门文章

回复"操作系统",获取操作系统相关书籍

长按二维码关注我

你可能感兴趣的:(史上最全的C++/游戏开发面试问题总结(三)—— 操作系统下篇)