操作系统面试知识点

操作系统的一个主要功能是对设备, 资源的管理, 主要包括:

  • cpu
  • 内存
  • 外存
  • 键盘, 鼠标, 显示器等设备

那对应的可以将操作系统分为几个部分, 分别管理各类资源:

  • 进程调度
  • 进程间通信
  • 内存管理
  • 文件系统
  • IO管理

知识点

进程, 线程, 协程

区别

  • 进程就是正在执行的程序, 每个进程有独立的地址空间.

  • Unix/Linux中存在进程的层次结构, 进程和它所有的子进程(包括间接的)组成一个进程组.
    在Windows中不存在进程的层次结构, 父进程在创建子进程时可以得到子进程的句柄, 通过句柄来控制子进程, 并且句柄可以传送给其他进程.

  • 进程用于将资源集中到一起, 线程是CPU调度的单位.

  • 一个进程的所有线程共享地址空间, 但是每个线程具有独立的: 程序计数器, 寄存器, 栈, 状态.
    对于栈, 每个线程的堆栈有一帧, 保存了线程的函数调用栈.

  • 协程是用户态的轻量级线程, 其调度由用户控制, 共享进程的地址空间. 协程有自己的栈和寄存器, 在切换时需要保存/恢复状态.
    切换时减少了切换到内核态的开销. 一个进程和线程都可以有多个协程. 相比函数调用, 协程可以在遇到IO阻塞时交出控制权,
    进程还拥有自己调度协程的能力.

调度

  • 轮转调度: 每个进程运行一段给定时间, 称为时间片.
  • 优先级调度: 为每个进程赋予一个优先级, 优先级高的先调度. 为了防止饥饿, 可以设置进程的最大时间片,
    还可以动态调整进程的优先级(例如等待时间越长, 优先级越高).
  • 多级队列: 根据进程执行的任务的变化, 设置不同的优先级. 例如给等待用户输入的进程更高的优先级,
    当进程等待磁盘IO时, 降低优先级.

死锁

  • 当多个进程之前出现循环等待时, 每个进程都在等待进程组中其他进程, 因此这些进程都无法继续运行. 称之为死锁.

  • 安全状态: 定义了各个进程占有的资源数, 要请求的资源数, 系统剩余资源数. 从安全状态出发, 存在一个调用顺序,
    使得所有进程都可以得到请求的资源数, 即所有进程都可以执行完.

  • 银行家算法: 基于安全状态的定义, 保证每一次资源分配之后, 系统都处于安全状态.

死锁处理主要从两个方面着手: 死锁预防死锁检测.

死锁预防指在死锁发生之前通过某些方法避免死锁发生. 有下面两种方式:

  • 对加锁请求进行排序或要求同时获得所有锁, 以此保证不会发生循环等待. 难以实现或降低了系统效率.
  • 当有可能发生死锁时, 进行事务回滚. 例如事务请求的锁被其他事务占用时, 将其中一个事务回滚, 让另一个事务继续执行.
    一般是较早执行的事务具有较高优先级. 这种机制都是基于时间戳, 例如用时间戳表示事务的开始时间,
    时间戳小的事务更老. 可分为两类:
    • wait-die 机制: 非抢占, 当事务 T_{i}Ti​ 请求的锁被事务 T_{j}Tj​ 占用时, 如果 T_{i}Ti​ 时间戳小于 T_{j}Tj​ 的,
      说明 T_{i}Ti​ 更老, 允许 T_{i}Ti​ 等待. 否则 T_{i}Ti​ 回滚.
    • wound-die 机制: 抢占式, 当事务 T_{i}Ti​ 请求的锁被事务 T_{j}Tj​ 占用时, 如果 T_{i}Ti​ 时间戳小于 T_{j}Tj​ 的,
      说明 T_{i}Ti​ 更老, T_{j}Tj​ 会回滚, 释放占用的锁, T_{i}Ti​ 得到请求的锁. 否则允许 T_{i}Ti​ 等待.

死锁检测允许系统发生死锁, 并用死锁检测和死锁恢复机制使系统恢复正常状态. 系统周期性地运行死锁检测算法,
以判断是否发生死锁, 如果发生, 就尝试从死锁恢复. 死锁检测可以用等待图来实现, 即维护事务的拓扑排序,
当发现环时就发生了死锁. 死锁恢复通常是回滚一个或多个事务, 这涉及到:

  • 选择牺牲者: 回滚哪些事务的代价最小.
  • 回滚: 事务是回滚到最初是状态, 还是回滚部分.
  • 饿死: 如果一个事务总被作为牺牲者, 就会发生饿死现象. 如何避免. 常用方法是在回滚代价中考虑已回滚次数.

内存管理

首先区分物理内存虚拟内存:

  • 物理内存: 指实际的内存, CPU使用物理地址来访问主存中的数据.

  • 虚拟内存: 每个进程都有自己的虚拟地址空间, 其地址是虚拟地址, 进程使用虚拟地址来访问数据.
    实际访问内存时, 由MMU将其映射为物理地址.

    • MMU: 用于将虚拟地址转换到物理地址.

为了应对内存碎片问题, 使用分页机制来管理内存:

  • 分页: 将内存(物理内存和虚拟内存)划分为大小相等(如4KB)的页, 将其作为内存分配的单位.

  • 页表: 记录了虚拟地址到物理地址的映射. 由连续的, 等长的项构成. 每项记录了实际的页地址, 以虚拟的页地址作为索引.
    为了降低页表占用的空间, 还可以采用多级页表. 每个进程都有独立的页表, 进程的用一个页目录基址指针指向了自己的页表.

  • 多级页表: 将虚拟地址划分为多个部分, 每个部分对应一级页表, 逐层搜索. 一般让一级的一个页表正好占据一页.
    这是因为在 64 位机器下, 很多的虚拟地址空间没有被用到, 记录这些虚拟地址映射的页表是无用的, 但是却会占据大量的内存空间.
    为了节省页表占据的内存空间, 多级页表应运而生.

那么如何实现虚拟地址到物理地址的映射呢?

  1. 进程将虚拟地址传递给CPU, CPU将其传递给MMU.
  2. MMU首先检查TLB, 如果存在该虚拟地址的映射, 直接返回该映射.
  3. 如果不存在, 就访问页表. 首先通过进程的页表基址和虚拟地址计算页表项的物理地址, 然后就访问内存, 取出页表项. 并将其放置到TLB.
  4. 得到映射/页表项之后, 如果对应的数据没有存储到内存, 就引起一次缺页中断, 将对应内容放置在内存中.
  5. 将物理地址传递给主存, 主存返回对应数据.

注意几点:

  • 上面说到的CPU访问主存, 实际上CPU一般是访问缓存, 当缓存未命中时缓存再去访问主存, 将数据放置到缓存中.
    注意一点, 页表和进程数据都是可以被缓存的.

  • 缺页中断/page fault: 指虚拟地址指示的页不在内存中的情况. 当内存不足时, 需要将页面换出, 将指示的页面换入.
    如果换出的页面没有被修改, 可以直接抛弃, 否则还需要写回磁盘. 选择什么样的换出策略对内存访问效率非常重要.

  • TLB: MMU中的一个部件, 用于缓存经常被用到的虚拟页面的映射, 其中记录了虚拟页号到实际页号的映射.
    在进行虚拟地址的转换时: 首先使用TLB, 看是否有匹配的, 如果有就可以快速返回, 没有则需要正常的地址转换, 并更新TLB.
    注意一点, TLB可以同时查找其中的所有表项, 不需要逐个匹配, 因此速度很快.

  • 倒排页表: 前面的页表以虚拟地址为索引, 当虚拟地址空间很大时(如64位机中), 即使使用多级页表也会占据很大空间.
    因此可以以实际内存地址作为索引, 每项保存该物理页号对应的虚拟页号. 但是这导致虚拟地址转换需要搜索整个页表,
    需要的时间很长. 一个解决办法是增大TLB, 另一种是使用散列表, 用虚拟地址来进行散列, 散列结果作为索引.
    散列的方法可以保证平均的地址转换时间.

  • 缺页中断分为软缺页中断硬缺页中断:

    • 软缺页中断: 程序访问某个地址时, MMU发现对应页不在内存中, 而实际上页在内存中, 只是没有与该程序的虚拟地址关联.
      例如当多个程序共享某一库时, 操作系统为某一程序加载该库到内存中, 另一个程序访问该库时, 就可以直接使用该库(当该页没有被修改).
      再比如页已被CPU从工作集移除, 但是还没有被写回到磁盘, 而是保存在内存中, 此时程序再次访问该页时就可以直接利用该页.
    • 硬缺页中断: 程序页缺失, 没有被加载到内存中.

下面再介绍页面置换算法, 即内存不足时, 操作系统应该选择将什么页换出.

  • 最近未使用(not recently used, NRU)

    页分为四类:

    1. 未被访问, 未被修改.
    2. 未被访问, 已被修改.
    3. 已被访问, 未被修改.
    4. 已被访问, 已被修改.

    当进行页面被读/写时, 记录其被访问, 当页面被写时, 记录其被修改. 访问位可以每隔一段时间清空,
    以便区分出最近访问的页面. 当需要换出页面时, 从最小的非空类中随机选择一页.

  • 先进先出(FIFO)

  • 第二次机会(second chance)

    基于FIFO, 但是为每个页维护一个访问位, 当页面被加入内存时, 设置该位. 当要换出时, 检查队列头部的页,
    如果访问记录为1, 就将其移到队尾, 清除访问位, 继续寻找下一个队首.

  • 时钟页面

    思想和第二次机会一样, 但是不使用队列, 而是将记录维护为一个环形链表, 用一个指针遍历此环形链表.
    减少了队列操作带来的代价.

  • 最近最少使用(least recentlt used, LRU)

    实际上是最近未使用, 即将已经缓存的最长时间没有使用的页面换出. 一般采用链表队列实现:

    • 当数据被加入到缓存时, 加入到队列末尾.
    • 当访问一个缓存时, 将其移动到队列末尾.
    • 当要换出一个缓存页时, 选择队头的页面.

    推广的还有 LRU-K 算法, 这需要记录页最近的使用次数, 当达到 k 次时, 将其缓存. 同样用链表队列维护缓存,
    更新缓存的方式与LRU相同. LUR-K 用访问历史队列记录最近访问的页及其次数:

    • 第一次访问一个页时, 将其加入到访问历史队列末尾, 访问次数记为1. 如果队列已满, 将对头出列(LRU, 也可以用其他算法, 如FIFO).
    • 当访问的页在访问历史队列中时, 将访问次数加一, 并将其转移到队列尾部. 如果访问次数等于 K,
      将该页缓存, 并转移到缓存队列(此时不需要记录访问次数).
    • 缓存队列的维护与 LRU 相同.

另外一个需要考虑的问题是如何来管理内存. 这又可以分为对物理内存的管理和对虚拟内存的管理.

对于物理内存管理, 可以分为对大物理内存的请求和对小物理内存的请求.
对大物理内存的请求, 可以以页框为基本单位, Linux采用伙伴系统来实现.
对小物理内存的请求, 采用 slab 分配器.

slab是一种对象缓存机制. 内核会为常用的对象(即struct, 如文件对象, task_struct)提前分配空间,
当需要该对象时, 直接将其分配给请求者, 当释放该对象时, 内核将其回收.

对于虚拟内存管理, 操作系统需要为每一个进程做独立的内存管理, 一般讨论的是 malloc 等内存分配函数的做法,
也就是说主要是堆内存的管理. 方法包括:

  • 隐式空闲链表: 在每个空闲块的开始记录块是否空闲, 块的大小. 通过块大小可以知道下一个块的起始地址.
    此方法寻找空闲块需要线性时间复杂度.

  • 显式空闲链表: 每个空闲块还记录了下一个空闲块的起始地址, 这样减少了寻找空闲块的时间, 但是寻找大小匹配的空闲块的时间仍然是线性的.
    其中还有不同的查找算法, 例如首次适应, 下一次适应等等.

  • 分离空闲链表: 类似于 linux 部分对 malloc 实现的讨论, 参见该处.


经过一段时间之后, 内存中很可能会出现大量不连续的页框. 此时如果进程请求一个大的连续页框, 尽管内存足够,
但是连续内存却不足, 因此不能满足需求. 这个问题有两种解决办法:

  • 利用分页单元, 将非连续的空闲页框映射到连续的线性地址空间.
  • 利用一种技术尽量避免这种情况.

第一种解决办法有很多缺点:

  • 有时候必须要求是连续页框, 如DMA.
  • 会导致频繁地修改页表, 增加访存次数, 导致TLB不断刷新.
  • 内核有时候使用大的页框效率更高.

因此更倾向于使用第二种方法, 其中一种技术称为伙伴系统(buddy system).
伙伴系统将空闲内存组织为多个链表, 每个链表包含多个大小固定的页框, 大小分别为 1, 2, 4, ..., 1024.

  • 分配页的过程为: 对于请求, 先向上取整使其成为 2^k2k, 然后从链表中寻找. 如果对应链表没有页框,
    就可以将其下一个页框分解为两个, 放在当前链表. 如果下一个链表也不足, 再向下搜索. 如果最大的一个还是空的,
    就返回错误(其实可以尝试将前面的页框合并来满足当前请求).

  • 释放页的过程: 将页框加入到对应链表上, 如果链表中存在相邻的, 就可以将二者合并, 并转移到下一个链表上.
    并再次检查是否能够合并, 直到到达最后一个链表.(由于加入页框时会检查是否能够合并, 因此请求失败时不需要检查嫩否合并小的页框, 因为肯定是不能的)

伙伴系统同样适用于虚拟内存的管理.

文件系统

  • 操作系统不关心文件的内容, 只是将其看做无结构的字节序列.

  • 通常每个磁盘都在开始处记录主引导记录(MBR, 现在还有GPT), 其后是分区表, 记录了各个分区的开始结束位置.
    后面剩余的空间被划分为若干个分区, 每个分区可以使用不同的文件系统.

  • 每个分区开始是引导块, 其中的程序负责将本分区的操作系统装载到内存中, 即使分区不存在操作系统也会保留引导块.
    其后是超级块, 包含着文件系统的所有关键参数, 如文件系统类型, 数据块数量等.
    再后面是诸如磁盘空间管理信息, i节点, 数据等等.

磁盘块管理

记录文件用到了哪些磁盘块, 如何分配/释放磁盘块是文件系统的关键任务之一. 下面是一些磁盘块分配方法:

  • 连续分配: 给每个文件分配连续的磁盘块. 这样具有非常好的读性能, 支持随机读写, 但是不利于附加.
    并且容易导致碎片, 浪费磁盘空间.

  • 链表分配: 离散地分配磁盘块, 在每个磁盘块的开头记录文件下一个磁盘块的位置. 连续读写性能较好,
    但是随机存取较慢. 一个微妙的问题是, 由于磁盘开头的记录, 导致磁盘块数据的大小不是2的整数次幂,
    可能会导致存取速度下降, 因为很多程序以2的整数次幂存取文件.

  • 在内存中采用表的链表分配: 维护一个文件分配表(FAT), 文件的第一个磁盘块作为索引,
    每一项记录了当前块的位置和下一块磁盘块的索引.

  • i节点: i节点大小是固定的, 用一个数组保存文件占用磁盘块, 当文件占用磁盘块数量超过数组大小时,
    数组最后一个元素指向的是保存后续磁盘块地址的磁盘块.

另外一个问题是如何记录空闲磁盘块:

  • 空闲表: 用磁盘块来记录空闲块的地址, 磁盘块最后一个用来记录下一个保存了空闲块地址的磁盘.
    能快速地取得空闲的块, 但是占用空间较多.

  • 位图: 用一位表示磁盘块是否空闲. 取得空闲块时需要搜索位图, 但是所需空间少于空闲表法.

中断

包括由外部设备产生的硬件中断, 用户程序调用系统接口引起的软件中断, 代码执行出错(如除0)导致的异常.
中断发生后, 系统转入内核态并保存现场, 通过中断向量表来执行对应处理程序, 分别调用对应的设备驱动程序, 系统调用表, 异常服务例程.
Linux系统现在在执行中断相应的时候, 默认屏蔽其他的中断信号.

硬件中断

外部设备在完成某项任务时, 产生一个中断, 以通知操作系统. 以I/O举例其过程:

  1. 操作系统通过设备驱动程序给设备控制器分配任务, 之后CPU就可以开始其他工作, 而不必等待.
  2. 在任务完成时, 设备控制器通知中断控制器, 中断控制器根据信号屏蔽状态决定是否响应该信号.
  3. 如果中断控制器决定响应, 将会通知CPU, 并将信号编号发送给CPU.
  4. CPU一旦决定响应中断, 会先保存现场, 例如将PC, 寄存器等压入堆栈, 然后切换到内核态. 根据信号编号,
    找到对应的中断处理程序, 并执行之. 完成后恢复到之前的状态继续运行.

软件中断

用户程序调用系统接口时产生. 通过中断向量表转向系统调用表, 找到对应的系统调用, 并执行之.

异常

常见问题

负数, 浮点数的二进制表示

整型负数采用补码表示, 即绝对值的反码加一.

浮点数采用 IEEE754 标准, v = (-1)^{s}*M*2^{E}v=(−1)s∗M∗2E. 以32位浮点数为例, 其中:

  • s 是符号位, 由最高位bit表示. 为0时 (-1)^s = 1(−1)s=1, 此时浮点数为正数, 为1时浮点数为负数.
  • E 是指数位, 由其后 8 个bit表示, 是一个无符号整数, 0 <= E <= 2550<=E<=255, 但是实际中指数可以是负数,
    因此规定指数 E 的实际值为指数位的值减去 127, 127 = 255/2127=255/2.
  • M 是有效位, 由最低的 23 bit表示, 并且 1 <= M < 21<=M<2, 也就是说 M 总是以 1.xyz1.xyz 的形式出现,
    所以在二进制表示中省略了前面的1, 只表示小数点后面的数字. 这样就节省了一个比特,
    以后在翻译时会自动加上1.

指数E还有几种特殊情况:

  • E 全为0时: E = 1 - 127 = -126E=1−127=−126, 此时在计算有效位时不再加上1. 这是为了表示0而规定的.
    即此时浮点数为 (-1)^s * 0.xyz * 2^{-126}(−1)s∗0.xyz∗2−126. 这也可以用来表现极小值.

  • E 全为1时: 如果有效位全为0, 表示正/负无穷大; 如果有效数字不全为0, 这个数是 NaN.

64位浮点数中符号位占 1 bit, 指数位占 11 bit, 最后的有效数字占 52 bit.

原子操作的实现原理

原子操作指不可被中断的一个或多个连续操作. 原子操作的实现需要CPU, 主存等硬件的支持.
其中还要认识到多处理器和单处理器在实现上是有巨大差别的, 下面均是针对多处理器. 常见的CPU实现原子操作的原理为:

  1. 处理器保证基本内存操作的原子性: 例如从内存读取/写入一个字节是原子的, 处理器保证当一个处理器在读取/写入一个字节时,
    其他处理器不能访问该字节. 但是复杂的操作就需要更加复杂的方式来保证原子性了.
  2. 使用总线锁保证原子性: CPU提供 LOCK # 信号, 当一个CPU在总线上输出此信号时, 其他CPU的请求将被阻塞,
    这就保证只有一个CPU可以访问内存, 从而保证了原子性.
    总线锁使得其他CPU无法访问内存, 而实际上我们只需要保证对某一内存地址的访问是原子的. 这使得总线锁的开销比较大, 因此引入缓存锁.
  3. 使用缓存锁保证原子性: 当数据保存在缓存中时, 利用缓存一致性来实现原子性. 但是当数据不能被缓存,
    例如数据跨越多个缓存行时, 就需要使用总线锁定.

缓存一致性: 现代的系统中, CPU一般不会直接访问内存, 而是通过缓存, 包括读取和写入, 而写入分为直写回写.
直写指的是处理器直接通过缓存将数据写到内存, 回写指的是缓存不会立刻将修改同步到主存, 仅仅是更新缓存的数据.
回写有时具有更高的效率, 因为可以节省写内存的次数, 但是在多处理器系统中却又引发了不一致问题.
在多处理器系统中, 为了保证效率, 我们为每个处理器都配备了自己的缓存, 那么在写回模式下就需要保证各个处理器的缓存的内容是一致的.
常见的算法如MESI算法, 将缓存内容分为四种状态: Modified, Exclusive, Shared, Invalid, 含义分别是:

  • Invalid: 内容要么不在缓存中, 要么已经失效/过时. 此段内容无法使用, 需要重新从主存加载.
  • Shared: 内容可以使用, 并且和主存内容一致, 多个处理器可以拥有相同的地址的内容, 可以读取, 不能写入.
  • Exclusive: 内容可以使用, 并且和主存内容一致, 但是对于一个地址的内容, 如果在一个处理器的缓存中是该状态,
    其他处理器就不能持有该地址的内容. 持有该地址内容的处理器可以对其进行读写.
  • Modified: 缓存内容已经被所属的处理器修改, 这导致该地址的内容在其他处处理器上的副本变为Invalid状态.
    如果处理器要抛弃该段缓存内容, 需要先将之写回到主存.
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值