操作系统面试题

255 阅读26分钟

操作系统

进程管理

并行和并发的区别?

并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统。

操作系统通过引入进程和线程,使得程序能够并发运行。

(并发使用了虚拟概念中的时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。 空分复用技术是虚拟内存。)

进程与线程的区别?

  1. 进程是资源分配的基本单位。线程是独立调度的基本单位。
  2. 一个进程中可以有多个线程,它们共享进程资源。
  3. 创建和撤销进程时系统要为之分配或回收资源,所付出的开销远大于创建或撤销线程时的开销。
  4. 上下文切换时,进程切换涉及当前执行进程CPU环境的保存及新调度进程CPU环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
  5. 通信时,进程需要通过IPC,线程间可以通过读写统一进程中的共享数据进行通信。

进程的状态转换过程?

  • 进程创建后需要获取到除了处理器以外的所有资源才进入就绪态,等待CPU调度。
  • 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
  • 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。

进程的内存模型是怎么样的?

img

进程调度算法有哪些?

批处理系统

目标是确保吞吐量和周转时间(从提交到终止的时间)

先来先服务

对短作业不利

短作业优先

长作业可能会饿死

最短剩余时间优先

短作业优先的抢占版本

交互式系统

目标是快速进行响应

时间片轮转

公平但不能实现优先级、上下文切换开销大

优先级调度

根据需求为每个进程分配一个优先级,按优先级进行调度,时间的推移增加等待进程的优先度可以避免低优先级的进程饿死

多级反馈队列

时间片轮转和优先级调度算法的结合

实时系统

实时系统要求一个请求在一个确定时间内得到响应。

分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。

进程的通信方式有哪些?细讲几个

管道: 通过调用pipe函数创建,只支持半双工通信(单项交替传输,一边在读一边在写),只能在父子进程或者兄弟进程中使用。

FIFO: 命名管道,去除了管道只能在父子进程中使用的限制,常用于客户-服务器应用程序,FIFO作为中间商在客户进程和服务器进程之间传递数据

消息队列: 相比于 FIFO,消息队列具有以下优点:

  • 消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;
  • 避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;
  • 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。

信号量: 计数器,用于为多个进程提供对共享数据的访问

共享存储: 允许多个进程共享一个给定的存储区,需要用信号量来同步对共享存储的访问

套接字: 可用于不同机器之间的进程通信

进程终止的方法有哪些?

自愿:

正常退出:由于完成了工作而终止

错误退出:发生不那么严重的错误,一般会以对话框的方式告知用户

非自愿:

严重错误:由于进程引起的错误,非法指令执行时进程会收到中断信号,而不是在这类错误出现时直接终止进程

被其他进程杀死:系统调用kill函数

如何控制进程同步?

同步是指多个进程由于合作关系使得它们之间有一定的先后执行关系。

信号量(Semaphore)

最常见的就是PV操作,PV操作被设计成原语,不可分割,通常的做法是执行这些操作的时候屏蔽中断。

P:就是down。如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0

V:就是up。对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。

信号量的取值只能为0或1时,就成了互斥量。

管程

在一个时刻只能由一个进程使用管程,进程在无法继续执行的时候不能一直占用管程。管程引入了条件变量及相关的操作,wait()和signal()来实现同步操作。这部分Java几乎用不到,通常使用JUC包下的Condition即可。

守护进程、僵尸进程和孤儿进程是什么

守护进程:在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。

僵尸进程:如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。

孤儿进程:如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程。(注:任何一个进程都必须有父进程)。一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

说一下几种典型的锁?

从资源占用的角度:独占锁、共享锁

从处理方式来看:悲观锁、乐观锁(无锁编程)

自旋锁和互斥锁的区别?

这两种锁是最底层的锁,属于独占锁,很多高级的锁都是基于他们实现的

当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

  • 互斥锁加锁失败后,线程会释放CPU ,给其他线程;
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

互斥锁加锁失败后,线程释放CPU,内核将线程设置为阻塞状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,就可以继续执行。所以互斥锁加锁失败时,会从用户态陷入到内核态,内核帮我们完成线程切换,这里涉及两次线程上下文切换的成本,一次是加锁失败时线程由运行态变为阻塞态,CPU交给其他线程;另一次是锁被释放时,之前阻塞的线程会变为就绪态,调度重新使该线程获得CPU时间。

由于性能开销相对比较大,可能会出现上下文切换的时间大于执行锁住代码的时间,那这时候就应该用自旋锁了。

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在用户态完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。CAS函数可以把“查看锁的状态,如果是空闲的就将锁设置为当前线程持有”这两个步骤合为一个硬件级指令,形成原子指令。

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会占用CPU忙等待,直到它拿到锁。这里的忙等待可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。

自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

介绍一下读写锁

读写锁由读锁和写锁两部分构成,工作原理是读读共享、读写互斥、写写互斥,是用于读多写少的场景。

读写锁分为读优先锁和写优先锁,读优先锁可能会造成写线程饥饿,写优先锁可能会造成读线程饥饿,因此可以搞个公平读写锁,利用队列把获取锁的线程排队,不管写线程还是读线程都按先进先出的原则加锁即可。

讲一下乐观锁和悲观锁?

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。在线文档、Git都是利用了乐观锁的版本号思想。(乐观锁的解决方案有CAS和版本号。)

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

死锁产生的必要条件?

由于系统中存在一些不可剥夺资源,而当两个或两个以上进程占有自身资源,并请求对方资源时,会导致每个进程都无法向前推进,这就是死锁。

  1. 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
  2. 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
  4. 环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。

如何解决死锁?

预防

四个条件破坏任意一个即可。

  1. 破坏互斥条件:使资源不会进行排他性控制,这不太现实。
  2. 破坏请求和保持条件:只要有一个资源得不到分配,也不给这个进程分配其他的资源。
  3. 破坏不可剥夺条件:当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源。
  4. 破坏环路等待条件:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反。比较常用。

避免

银行家算法,假分配判断是否会导致程序变为不安全状态

检测与解除

  1. 资源剥夺:挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他死锁进程
  2. 撤销进程:强制撤销部分甚至全部死锁进程并剥夺这些进程的资源(一般根据指定的进程优先级和撤销进程的代价)
  3. 进程回退:让一个或或多个进程回退到足以避免死锁的地步。区别在于进程回退是资源释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。

用户态和内核态是什么?

操作系统的两种运行状态。是出于访问能力限制的考量,计算机中一些比较危险的操作不放心交由用户态下的CPU随意执行。

内核态: 处于内核态的 CPU 可以访问任意的数据,可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。

用户态: 处于用户态的 CPU 只能受限的访问内存,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。

用户态和内核态之间是如何切换的?(系统调用的过程?)

如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。这个过程也叫系统调用。

用户态->内核态的转换我们称之为trap进内核,也被称为陷阱指令。

步骤如下:

  1. 用户程序调用glibc标准库

  2. glibc库根据不同的体系结构调用系统调用的正确方法设置用户进程传递的参数,用于准备系统调用

  3. glibc库调用软件中断指令(SWI),这个指令通过更新CPSR寄存器将模式改为超级用户模式,然后跳转到地址0x08处

    -------以下是内核态--------

  4. 执行SWI指令后,允许进程执行内核代码,MMU内存管理单元允许内核虚拟内存访问

  5. 从地址0x08开始,进程执行加载并跳转到中断处理程序(ARM中的vector_swi())

  6. 从SWI指令中提取系统调用号SCNO,然后在系统调用表中找到对应的系统调用函数

  7. 执行系统调用完成后,将还原用户模式寄存器,然后再以用户模式执行

Linux 的系统调用主要有以下这些:

TaskCommands
进程控制fork(); exit(); wait();
进程通信pipe(); shmget(); mmap();
文件操作open(); read(); write();
设备操作ioctl(); read(); write();
信息维护getpid(); alarm(); sleep();
安全chmod(); umask(); chown();

宏内核与微内核?

宏内核: 宏内核是将操作系统功能作为一个紧密结合的整体放到内核。由于各模块共享信息,因此有很高的性能。

微内核: 将一部分操作系统功能移出内核,从而降低内核的复杂性。移出的部分根据分层的原则划分成若干服务,相互独立。

在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。

因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。

中断分类有哪些?

  1. 外中断

由 CPU 执行指令以外的事件引起,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。

  1. 异常

由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。

  1. 陷入

在用户程序中使用系统调用。这是主动的过程。

一个程序从开始运行到运行结束的完整过程

  1. 预编译:主要处理源代码文件中的以“#”开头的预编译指令
  2. 编译:把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
  3. 汇编:将汇编代码转变成机器可以执行的指令(机器码文件)。
  4. 链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。

谈谈你对动态链接库、静态链接库的理解?

静态链接: 函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。但同时也带来了一些缺点,空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。

动态链接: 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。动态链接的优点就是,节省空间,即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。但存在的缺点就是因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。

内存管理

讲一讲虚拟内存

虚拟内存是为了让物理内存扩充成更大的逻辑内存,从而使程序获得更多的可用内存。为了更好的管理内存,操作系统将内存抽象成地址空间,每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每个块称为一页,这些页会被映射到离散的物理内存中去,而且也不要求所有页都在物理内存中,当程序引用到不在物理内存中的页时(产生缺页中断),硬件会执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。也就是说,一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。

讲一下分页系统的地址映射机制

内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表存储着页(程序地址空间)和页框(物理内存空间)的映射关系,通过页表先找到页,再使用页内偏移地址找到最终对应的实际物理地址

但页表会带来两个重要的问题:

  1. 虚拟物理地址到物理地址转换速度要快
  2. 虚拟空间很大时页表也会很大

快表: 可以理解为页式内存管理的高速缓存,一般存放在CPU内部的高速缓冲存储器。快表是基于局部性原理(时间、空间)以空间换时间的一种思路。引入快表后,要进行虚拟地址到物理地址的转换时就会先查快表,没有时再访问内存中的页表,拿到真实物理地址后再次访问内存,有的话就直接访问物理地址。

多级页表: 绝大部分程序并不会用到所有页表项的映射地址,如果在内存中记录下所有的页表项就造成了巨大的空间浪费。相比于单级页表一对一的关系,两级页表中的一级页表项是一对多的关系,相当于是分组管理,当某一区间不需要用到时,负责该区间的一级页表项就为空,二级页表项也就不用存储相应的数据,这样就能节省大量空间。

讲一讲常见的几种内存管理机制

分段:一个段是逻辑上相关的数据,这些数据构成一个独立的地址空间,每个段的长度不同且可以动态增长。

分页:虚拟内存采用的方法,将地址空间划分成固定大小的页,每一页再与内存进行映射。

段页式:程序的地址空间划分多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页,这样就结合了分段系统的共享和保护优点和分页系统的虚拟内存功能。

分页与分段的比较

  • 对程序员的透明性:分页透明,但是分段需要程序员显式划分每个段。
  • 地址空间的维度:分页是一维地址空间,分段是二维的。
  • 大小是否可以改变:页的大小不可变,段的大小可以动态改变。
  • 出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。

页面置换算法有哪些?

访问的页面部在内存中就发生缺页中断从而将需要的页调入内存中,如果内存已无空闲空间,则必须从内存中调出一个页面到磁盘对换区中来腾出空间。

最佳置换

选择被换出的页面将是最长时间内不再被访问的,这样可以保证最低的缺页率,但这是理想化的,无法实现

最近最久未使用LRU

内存中维护一个所有页面的链表,当一个页面被访问时,将这个页表移到链表表头,这样就能保证链表尾部的页面是最近最久未访问的,淘汰的就是这个页面。

最近最少使用NFU

先进先出FIFO

物理地址、逻辑地址、虚拟内存的概念

  1. 物理地址:它是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址从主存中存取,是内存单元真正的地址。
  2. 逻辑地址:是指计算机用户看到的地址。事实上,逻辑地址并不一定是元素存储的真实地址,即数组元素的物理地址(在内存条中所处的位置),并非是连续的,只是操作系统通过地址映射,将逻辑地址映射成连续的,这样更符合人们的直观思维。
  3. 虚拟内存:是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。

Linux

网络IO模型有哪些,展开讲讲?

网络IO模型有五种:

  1. 阻塞IO
  2. 非阻塞IO
  3. IO多路复用
  4. 信号驱动IO
  5. 异步IO

阻塞IO

img

由上图可知,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据)都被block了。

几乎所有程序员一开始接触的都是listen()、send()、recv()等接口,这种IO模式是不适用于如今高并发服务器下的,即使是多线程模型,也会遇到瓶颈。

非阻塞IO

img

由上图可知,用户进程发出系统调用后,kernel中的数据还没有准备好,也不会阻塞用户进程,而是返回一个error,用户进程直到kernel数据还未准备好,会不断主动询问kernel数据是否准备好。这样虽然避免了用户进程阻塞,但会导致循环调用recv()占用大量CPU时间。

多路复用IO

img

也就是常说的select/epoll,他的好处就在于单个process就能同时处理多个网络连接的IO,基本原理就是select/epoll这个function会不断轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。由上图可看出,当用户进程调用了select,整个进程会被block,同时kernel会监视所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回,这时用户进程再调用read操作将数据从kernel拷贝到用户进程。

我们注意到IO多路复用在处理少量连接时性能甚至不如阻塞IO,原因在于用户进程是阻塞的同时还多了一个系统调用。但IO多路复用的优势并不是对单个连接处理的快,而是在于能处理更多的连接。

上述模型中select()需要动态维护他的三个参数,可读事件句柄、可写事件句柄、错误事件句柄。根据select()捕捉到的不同类型的句柄,服务器程序作出不同的响应。但select模型并不是实现“事件驱动”的最好选择,因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。Linux提供的方案就是epoll,他避免了轮询,而是基于回调机制,后面会说到。

信号驱动IO

调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。

异步IO

img

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

select和epoll的对比

select:随着连接数增加,性能急剧下降;连接数有限制;基于线性轮询

epoll:随着连接数增加,性能基本没有下降;连接数无限制;基于回调通知机制,支持水平触发和边缘触发两种模式。

介绍一下epoll

epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

触发模式:

epoll对文件描述符的操作有两种模式:LT(level trigger)ET(edge trigger) 。LT模式是默认模式,LT模式与ET模式的区别如下: LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。 ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

文件描述符

Linux中一切皆文件,Linux进程可以打开成百上千个文件,为了表示和区分这些文件,Linux会给每个文件分配一个编号(数组下标),称为文件描述符。

Linux进程启动后,会在内核空间中创建一个PCB控制块,PCB内部有一个文件描述符表,记录着当前进程所有可用的文件描述符,除此之外,还需要维护两张表:打开文件表、i-node表,这两张表整个系统只有一个,而文件描述符表一个进程有一个。

Linux文件描述符表示意图

通过文件描述符,可以找到文件指针,从而进入打开文件表。该表存储了以下信息:

  • 文件偏移量,也就是文件内部指针偏移量。调用 read() 或者 write() 函数时,文件偏移量会自动更新,当然也可以使用 lseek() 直接修改。
  • 状态标志,比如只读模式、读写模式、追加模式、覆盖模式等。
  • i-node 表指针。

然而,要想真正读写文件,还得通过打开文件表的 i-node 指针进入 i-node 表,该表包含了诸如以下的信息:

  • 文件类型,例如常规文件、套接字或 FIFO。
  • 文件大小。
  • 时间戳,比如创建时间、更新时间。
  • 文件锁。