面试 | 操作系统

文章目录

操作系统面试完全指南(Go 校招向)


一、进程与线程

基本概念

概念 定义 类比
程序 静态的可执行文件,存储在磁盘 菜谱
进程 程序的一次执行实例,动态的,有生命周期 正在做菜的厨师
线程 进程内的执行单元,共享进程资源 厨师的一只手
协程 用户态轻量级线程,由程序自己调度 厨师快速切换的注意力

进程 vs 线程(面试必考)

复制代码
进程:
┌─────────────────────────────┐
│ 代码段  数据段  堆  栈        │  ← 独立地址空间
│ 文件描述符  信号处理          │  ← 独立资源
│  Thread1    Thread2          │  ← 可含多个线程
└─────────────────────────────┘

同一进程的线程:
  共享:代码段、数据段、堆、文件描述符、信号处理
  独有:栈、寄存器、程序计数器(PC)、线程 ID
对比项 进程 线程
资源 独立地址空间,资源隔离 共享进程资源
创建开销 大(复制页表、文件描述符等) 小(只分配栈和寄存器)
切换开销 大(需切换页表、TLB 刷新) 小(只切换寄存器和栈)
通信方式 IPC(管道、共享内存、Socket 等) 共享内存(直接读写全局变量)
健壮性 进程间互不影响 一个线程崩溃影响整个进程
并行能力 多进程可跑在多核上 多线程可跑在多核上

面试答法:进程是资源分配的基本单位,拥有独立的地址空间;线程是 CPU 调度的基本单位,共享进程的资源。线程比进程轻量(创建和切换开销小),但线程崩溃会影响整个进程,进程间相互隔离。Go 的 goroutine 是用户态协程,由 runtime 的 GMP 模型调度,初始栈 2KB,切换不需要进入内核态,比线程更轻量。


进程状态机

复制代码
                    ┌────────────────────┐
                    │      新建(New)      │
                    └─────────┬──────────┘
                              │ 创建完成,进入就绪队列
                              ▼
          ┌──── 时间片用完 ──── 就绪(Ready) ◀──── 等待事件完成
          │                    │
          │              调度器选中
          │                    │
          ▼                    ▼
        就绪               运行(Running)
                               │
               ┌───────────────┼───────────────┐
               │               │               │
          等待I/O          进程退出        等待事件
               │               │               │
               ▼               ▼               ▼
          阻塞(Blocked)    终止(Terminated)  阻塞(Blocked)

五种状态详解

状态 说明 Linux 对应
新建 进程正在被创建 ---
就绪 等待 CPU 时间片 R(runnable)
运行 正在 CPU 上执行 R(running)
阻塞 等待 I/O、锁、信号等 S(可中断)/ D(不可中断)
终止 执行完毕,等待回收 Z(僵尸)/ X

进程控制块(PCB)

操作系统为每个进程维护一个 PCB(Process Control Block),包含:

复制代码
PCB 内容:
  进程标识符(PID、父进程 PID)
  CPU 寄存器状态(程序计数器、栈指针等)------ 进程切换时保存/恢复
  进程调度信息(优先级、状态、调度队列指针)
  内存管理信息(页表基地址、内存区间)
  I/O 状态(打开的文件描述符)
  记账信息(CPU 使用时间、创建时间)

PCB 的作用:进程切换时,将当前进程的 CPU 状态保存到 PCB,再从下一个进程的 PCB 恢复状态,实现上下文切换。


上下文切换(Context Switch)

复制代码
进程 A 运行中
    │
    │ 中断/系统调用/时间片用完
    ▼
内核保存进程 A 的 CPU 上下文到 PCB_A
    │
内核选择进程 B 运行
    │
内核从 PCB_B 恢复进程 B 的 CPU 上下文
    │
    ▼
进程 B 继续运行(从上次中断处)

上下文切换的开销

  • 直接开销:保存/恢复寄存器、切换页表(进程切换时)
  • 间接开销:TLB 失效(进程切换需刷新地址转换缓存)、CPU 缓存失效

线程切换比进程切换快,因为同进程的线程共享页表,不需要切换页表和刷新 TLB。


fork() 与 Copy-on-Write

fork() 创建子进程时,并不立即复制父进程的全部内存,而是采用 写时复制(Copy-on-Write)

复制代码
fork() 后:
  父进程页表 ──────────────────────────┐
                                       ▼
  子进程页表 ──────────────────────── 同一物理页(只读标记)

  父/子进程试图写该页时:
    ↓
  触发缺页中断,内核复制该页,各自拥有独立副本
  • fork 本身极快(只复制页表,不复制数据)
  • 只有实际发生写操作的页才会被复制,节省内存
  • fork() 后立即 exec() 加载新程序(shell 的常见模式)则完全不浪费

孤儿进程 vs 僵尸进程

复制代码
孤儿进程:父进程先于子进程退出
  → 子进程被 init 进程(PID=1)收养,init 负责回收
  → 无害,系统自动处理

僵尸进程:子进程已退出,但父进程未调用 wait() 回收
  → PCB 残留在内核,占用 PID
  → 大量僵尸进程会耗尽 PID(Linux 默认上限约 32768)
  → 处理方式:kill 父进程(父进程死后,僵尸子进程由 init 回收)

栈 vs 堆(面试高频)

复制代码
栈(Stack):
  由编译器/运行时自动管理(函数调用时分配,返回时释放)
  存储:局部变量、函数参数、返回地址
  增长方向:向低地址增长
  大小:固定且较小(Linux 默认 8MB,Go goroutine 初始 2KB)
  速度:快(移动栈指针即可)

堆(Heap):
  由程序员手动管理(malloc/new 分配,free/GC 释放)
  存储:动态分配的对象
  增长方向:向高地址增长
  大小:受物理内存限制,可以很大
  速度:慢(需要内存分配器管理空闲块,可能有碎片)
对比项
管理方式 自动(编译器) 手动/GC
分配速度 O(1),极快 较慢(需找空闲块)
大小 小且固定 大,动态增长
碎片 有内存碎片问题
线程安全 每个线程独立栈,天然安全 共享,需要同步

Go 的逃逸分析 :编译器自动判断变量分配在栈还是堆。函数返回后仍被引用的变量(逃逸)分配在堆,否则分配在栈。go build -gcflags="-m" 可查看逃逸情况。


二、进程调度算法

调度目标

  • CPU 利用率:让 CPU 尽量忙碌
  • 吞吐量:单位时间完成的进程数
  • 周转时间:进程从提交到完成的总时间
  • 等待时间:进程在就绪队列中等待的时间
  • 响应时间:交互式系统中,从请求到第一次响应的时间

六种调度算法

1. 先来先服务(FCFS)
复制代码
进程队列: P1(24ms) → P2(3ms) → P3(3ms)
执行顺序: P1──────────────────── P2─── P3───
等待时间: P1=0, P2=24, P3=27
平均等待: (0+24+27)/3 = 17ms

缺点:短进程等待时间长(护航效应)
2. 最短作业优先(SJF)
复制代码
进程队列: P1(6ms) P2(8ms) P3(7ms) P4(3ms)
执行顺序: P4─── P1────── P3─────── P2────────
等待时间: P4=0, P1=3, P3=9, P2=16
平均等待: (0+3+9+16)/4 = 7ms(最优平均等待时间)

缺点:需要预知执行时间(不现实),长进程可能饥饿
3. 优先级调度
复制代码
每个进程有优先级数字,数字越小优先级越高(或反之)
高优先级进程先执行

问题:低优先级进程可能永远得不到执行(饥饿)
解决:老化(Aging)------随时间推移提高等待进程的优先级
4. 时间片轮转(RR,Round-Robin)
复制代码
时间片 = 20ms
进程队列: P1(53ms) P2(17ms) P3(68ms) P4(24ms)

执行: P1─20─ P2─17─ P3─20─ P4─20─ P1─20─ P3─20─ P4─4─ P1─13─ P3─28─
           完成       完成           完成

时间片大小的影响:
  太小 → 频繁上下文切换,开销大
  太大 → 退化为 FCFS,响应时间长
  通常设置为 10-100ms
5. 多级队列调度
复制代码
队列1(前台交互进程):时间片轮转,高优先级
队列2(后台批处理进程):FCFS,低优先级

高优先级队列不为空时,低优先级队列不能运行
6. 多级反馈队列(现代 OS 常用)
复制代码
队列1(时间片=8ms)   ← 新进程先进这里
队列2(时间片=16ms)  ← 在队列1用完时间片 → 降级到这里
队列3(FCFS)         ← 在队列2用完时间片 → 降级到这里

特点:
  I/O 密集型进程(主动让出 CPU)始终在高优先级队列
  CPU 密集型进程逐渐降级到低优先级队列
  实现了对短进程和交互进程的优待

面试答法:现代操作系统通常使用多级反馈队列。它的核心思想是:进程按行为自动分类------I/O 密集型进程频繁主动让出 CPU,始终保持高优先级;CPU 密集型进程会不断消耗时间片,逐渐降级。这样既保证了交互响应,又照顾到了 CPU 密集型任务的吞吐量。


三、内存管理

内存分层结构

复制代码
速度(快→慢)        大小(小→大)        成本(高→低)
CPU 寄存器          字节级              极高
L1 Cache           32KB               高
L2 Cache           256KB              较高
L3 Cache           几MB~几十MB         中
内存(DRAM)        GB 级              低
SSD                GB~TB 级           更低
HDD 磁盘           TB 级              最低

虚拟内存(面试核心)

为什么需要虚拟内存

  1. 程序使用的地址是虚拟地址,相互隔离(进程A的地址0x1000 和 进程B的0x1000 是不同物理位置)
  2. 程序可以使用比物理内存更大的地址空间
  3. 通过 Swap(交换分区)扩展可用内存

虚拟地址 → 物理地址转换过程

复制代码
虚拟地址
    │
    ▼
MMU(内存管理单元)查页表
    │
    ├─ 页表项有效 → TLB 查缓存
    │                  │
    │                  ├─ TLB 命中  → 直接得到物理地址(极快)
    │                  └─ TLB 缺失  → 查内存页表 → 得到物理地址 → 更新TLB
    │
    └─ 页表项无效(页不在内存)→ 缺页中断
                                    │
                              内核处理缺页
                                    │
                              从磁盘(Swap/文件)载入页到内存
                                    │
                              更新页表,重新执行指令

TLB(Translation Lookaside Buffer):页表的硬件缓存,存储最近使用的虚拟→物理地址映射,命中率通常 > 99%,大幅减少页表查找开销。


分页 vs 分段

对比项 分页 分段
划分依据 固定大小(4KB) 逻辑单元(代码段/数据段)
碎片 内部碎片(最后一页未填满) 外部碎片(段间空隙)
地址结构 页号 + 页内偏移 段号 + 段内偏移
程序员感知 透明 可感知(分段符合程序逻辑)

现代OS:段页式------先分段(逻辑隔离),每段再分页(物理管理)。


页面置换算法

当物理内存满了,需要换出一页到磁盘:

OPT(最优置换)------ 理论最优
复制代码
置换未来最长时间不会被访问的页
缺点:无法预知未来,只作理论参考
FIFO(先进先出)
复制代码
访问序列: 1 2 3 4 1 2 5 1 2 3 4 5,内存帧数=3

装入: 1 | 1 2 | 1 2 3 | 缺页换出1→ 4 2 3 | 缺页换出2→ 4 1 3 | ...
缺页次数:9

缺点:Belady 异常(增加内存帧数,缺页次数反而增加)
LRU(最近最少使用)------ 最常用
复制代码
置换最近最长时间没有被访问的页

实现方式:
  精确实现:每页记录访问时间戳,置换时间戳最小的(开销大)
  近似实现:访问位(Reference bit)+ 时钟算法

Go 的 GC、Redis 的 maxmemory-policy 都有类似的 LRU 思想
Clock(时钟置换)------ LRU 的近似实现
复制代码
所有页排成环形,每页有一个访问位(0/1)

置换时:
  指针顺序扫描
  访问位=1 → 置为0,跳过(给它一次机会)
  访问位=0 → 置换此页

特点:O(n) 时间,近似 LRU 效果,开销小

面试答法:LRU 是理论上最接近最优的置换算法,基于局部性原理(最近用过的将来可能还用)。精确 LRU 需要维护访问时间,开销大;实际系统用 Clock 算法近似实现,通过访问位给每个页一次"第二次机会",性能和效果的平衡较好。


多级页表(为什么需要)

32 位系统,4KB 页,单级页表需要 2^20 = 100 万个页表项,每项 4 字节,每个进程需要 4MB 页表,100 个进程就是 400MB,无法接受。

多级页表的解决思路:按需分配页表,只有实际使用的地址范围才创建对应的页表页。

复制代码
32 位两级页表:
虚拟地址: [10位页目录号 | 10位页表号 | 12位页内偏移]

查找过程:
  1. 用页目录号查页目录(第一级)→ 得到页表的物理地址
  2. 用页表号查页表(第二级)→ 得到物理页框号
  3. 物理页框号 + 页内偏移 = 物理地址

好处:进程只用了少量地址空间时,大量页目录项为空,对应的二级页表根本不创建

64 位系统(Linux x86-64)用 4 级页表:PGD → PUD → PMD → PTE,每级索引 9 位。


内存分配算法

管理空闲内存块时,如何为进程分配:

算法 策略 优点 缺点
首次适应(First Fit) 找第一个足够大的空闲块 头部碎片多
最佳适应(Best Fit) 找最小的足够大的空闲块 空间利用率高 产生大量小碎片
最差适应(Worst Fit) 找最大的空闲块 剩余块较大 大块被快速消耗
伙伴系统(Buddy) 按 2 的幂次分配,Linux 内核使用 合并简单 内部碎片大
Slab 分配器 为固定大小对象维护对象池,Linux 内核使用 减少碎片,速度快 复杂

四、死锁

死锁的四个必要条件(缺一不可)

复制代码
1. 互斥条件:资源一次只能被一个进程占用
2. 请求与保持:进程持有资源,同时等待其他资源
3. 不可抢占:资源只能由持有者主动释放
4. 循环等待:进程形成等待环路 A→B→C→A

经典死锁场景

go 复制代码
// 两个 goroutine,两把锁,互相等待
var mu1, mu2 sync.Mutex

go func() {
    mu1.Lock()
    time.Sleep(1ms)
    mu2.Lock()   // 等待 mu2,但 mu2 被另一个 goroutine 持有
    // ...
    mu2.Unlock()
    mu1.Unlock()
}()

go func() {
    mu2.Lock()
    time.Sleep(1ms)
    mu1.Lock()   // 等待 mu1,但 mu1 被上一个 goroutine 持有
    // ...
    mu1.Unlock()
    mu2.Unlock()
}()

死锁处理策略

预防:破坏四个条件之一
复制代码
破坏互斥:使用非独占资源(如只读文件)------ 不总是可行
破坏请求与保持:进程一次性申请所有资源 ------ 资源利用率低
破坏不可抢占:资源可被强制剥夺 ------ 可能导致数据不一致
破坏循环等待:对资源编号,按序申请 ------ 实用!

按序申请锁(破坏循环等待):

go 复制代码
// 规定:永远先锁 mu1 再锁 mu2,消除循环等待
go func() {
    mu1.Lock()
    mu2.Lock()
    // ...
    mu2.Unlock()
    mu1.Unlock()
}()

go func() {
    mu1.Lock()   // 改为先锁 mu1
    mu2.Lock()
    // ...
    mu2.Unlock()
    mu1.Unlock()
}()
避免:银行家算法

进程申请资源前,系统检查分配后是否仍处于安全状态(能找到一个安全序列让所有进程完成)。不安全则拒绝分配。

复制代码
资源 R 总量=10,已分配=7,剩余=3
进程 A 还需 4,进程 B 还需 2,进程 C 还需 3

安全序列检查:
  先满足 B(需2 ≤ 剩余3)→ B 完成,释放资源 → 剩余 3+2=5
  再满足 C(需3 ≤ 5)→ C 完成 → 剩余 5+3=8
  再满足 A(需4 ≤ 8)→ A 完成 ✓ 安全序列:B→C→A
检测与恢复
  • 检测:维护资源分配图,检测图中是否存在环
  • 恢复
    • 杀死部分进程(选开销最小的)
    • 资源抢占(挂起进程,剥夺其资源)
    • 进程回滚(回到安全状态的检查点)

面试答法 :实际系统中死锁预防和避免代价高,通常用检测+恢复,或者直接假设死锁发生概率极低,通过超时+重试处理。数据库系统用等待图检测死锁,并通过回滚代价最小的事务来恢复。Go 中用 go vet 检测潜在死锁,运行时检测 goroutine 全部阻塞时报 deadlock。


五、同步与互斥

临界区问题

复制代码
临界区:访问共享资源的代码段

进入临界区前:申请进入(加锁)
临界区代码:  操作共享资源
离开临界区后:允许他人进入(解锁)

正确的同步方案必须满足

  1. 互斥:同一时刻最多一个进程在临界区
  2. 前进(空闲让进):无进程在临界区时,想进入的进程能立即进入
  3. 有限等待:等待进入临界区的时间有上限(无饥饿)

同步原语

自旋锁 vs 互斥锁(面试高频)
复制代码
互斥锁:获取失败 → 线程进入睡眠(让出 CPU)→ 锁释放时被唤醒
自旋锁:获取失败 → 线程循环忙等(不让出 CPU)→ 直到获取成功

for !atomic.CompareAndSwap(&lock, 0, 1) {
    // 自旋等待,持续占用 CPU
}
对比项 互斥锁 自旋锁
等待方式 睡眠(让出 CPU) 忙等(占用 CPU)
上下文切换 有(进入/唤醒开销)
适用场景 锁持有时间长 锁持有时间极短
适用环境 用户态、内核态 主要用于内核、多核

面试答法 :锁持有时间短(微秒级)且多核环境下用自旋锁,避免线程切换开销;锁持有时间长用互斥锁,避免浪费 CPU。Go 的 sync.Mutex 内部先自旋几次,失败后才睡眠,是两者的结合(自适应自旋)。


互斥锁(Mutex)
go 复制代码
var mu sync.Mutex
mu.Lock()
// 临界区
mu.Unlock()

// 底层实现:CAS 原子操作 + 自旋 + 系统调用(park/unpark)
// 自旋一段时间后若还未获取,让线程进入睡眠,避免持续占用 CPU
读写锁(RWMutex)
go 复制代码
var rw sync.RWMutex

// 读操作:多个读者可以并发
rw.RLock()
// 读取共享数据
rw.RUnlock()

// 写操作:写者独占
rw.Lock()
// 修改共享数据
rw.Unlock()

// 适用:读多写少场景(缓存、配置读取)
信号量(Semaphore)
复制代码
计数信号量:控制同时访问资源的数量上限
二进制信号量:等价于互斥锁(0/1)

P 操作(wait):信号量 -1,若 < 0 则阻塞
V 操作(signal):信号量 +1,若 ≤ 0 则唤醒一个等待进程

// Go 中用 channel 模拟信号量
sem := make(chan struct{}, 10)  // 同时最多 10 个并发

sem <- struct{}{}   // P 操作(获取)
defer func() { <-sem }()  // V 操作(释放)
条件变量(Cond)
go 复制代码
var mu sync.Mutex
cond := sync.NewCond(&mu)
queue := []int{}

// 消费者:等待队列有数据
mu.Lock()
for len(queue) == 0 {     // 必须用 for,不能用 if(防止虚假唤醒)
    cond.Wait()           // 原子地:释放锁 + 进入等待
}
item := queue[0]
queue = queue[1:]
mu.Unlock()

// 生产者:放入数据,唤醒消费者
mu.Lock()
queue = append(queue, item)
cond.Signal()   // 唤醒一个等待者(或 cond.Broadcast() 唤醒所有)
mu.Unlock()
经典同步问题

生产者-消费者问题

复制代码
空槽位信号量 empty=N(初始值=缓冲区大小)
满槽位信号量 full=0
互斥锁 mutex

生产者:
  P(empty)    // 等待有空槽
  P(mutex)    // 进入临界区
  放入数据
  V(mutex)
  V(full)     // 通知消费者有数据了

消费者:
  P(full)     // 等待有数据
  P(mutex)
  取出数据
  V(mutex)
  V(empty)    // 通知生产者有空槽了

哲学家进餐问题(死锁经典):

复制代码
5 位哲学家,5 根筷子,每人需要左右两根
→ 若同时拿左边的,所有人等待右边,死锁

解决方案:
  ① 最多允许 4 人同时拿筷子(破坏循环等待)
  ② 奇数哲学家先拿左边,偶数先拿右边(破坏对称性)
  ③ 用信号量:拿两根筷子作为原子操作

六、Go GMP 调度模型(Go 后端必考)

GMP 三个概念

复制代码
G(Goroutine):Go 协程,初始栈 2KB,用户态轻量级线程
M(Machine)  :OS 线程,真正在 CPU 上执行
P(Processor):逻辑处理器,持有本地 Goroutine 队列,连接 G 和 M

GOMAXPROCS 决定 P 的数量(默认等于 CPU 核数)

调度架构

复制代码
全局队列(Global Queue)
    │ 工作窃取/溢出
    ▼
┌──────────────────────────────────────────────┐
│  P0           P1           P2           P3   │
│  本地队列      本地队列      本地队列      本地队列│
│  [G][G][G]    [G][G]       [G][G][G][G]  []  │
│      │             │             │        │  │
│     M0            M1            M2       M3  │
└──────────────────────────────────────────────┘

关键机制

工作窃取(Work Stealing)

  • P3 的本地队列为空,从其他 P 的队列尾部偷取一半 Goroutine
  • 保证所有 P 始终有活干,CPU 利用率最大化

Hand Off(移交)

  • M0 上的 G0 发生系统调用阻塞(如文件读写)
  • M0 将 P 移交给其他空闲 M,P 继续调度其他 G
  • M0 阻塞完成后,尝试获取 P;若无空闲 P,G0 放入全局队列,M0 进入休眠

抢占式调度

  • Go 1.14 前:协作式抢占,只在函数调用时检查

  • Go 1.14 后:异步抢占,基于系统信号(SIGURG),即使 goroutine 无函数调用也能被抢占

    为什么 goroutine 比线程快:
    1. 创建开销小:goroutine 初始栈 2KB,线程通常 1-8MB
    2. 切换开销小:用户态切换,不进内核,不刷 TLB
    3. 数量可以很多:百万级 goroutine,而线程数千就是上限
    4. 栈动态增长:goroutine 栈按需扩缩(2KB → 最大 1GB)

面试答法:GMP 模型是 Go runtime 的核心调度机制。G 是 goroutine,M 是系统线程,P 是调度器(本地 goroutine 队列)。P 的数量等于 CPU 核数,每个 P 绑定一个 M 运行 G。当 G 发生系统调用阻塞时,P 会脱离当前 M 去找新的 M 继续运行其他 G,保证 CPU 不空闲。工作窃取机制保证负载均衡。


七、内存一致性与 CPU 缓存

CPU 缓存层级与缓存一致性

复制代码
多核 CPU:
  Core1: [L1 Cache] [L2 Cache]  ─┐
  Core2: [L1 Cache] [L2 Cache]  ─┼── [L3 Cache] ── [内存]
  Core3: [L1 Cache] [L2 Cache]  ─┘

问题:Core1 修改了变量 x,Core2 的缓存中 x 还是旧值
解决:MESI 协议(Modified/Exclusive/Shared/Invalid)
  Modified:该缓存行被修改,与内存不一致
  Exclusive:该缓存行只在本核,与内存一致
  Shared:该缓存行在多个核,与内存一致
  Invalid:该缓存行无效,不可使用

内存屏障与可见性

CPU 指令重排:CPU 和编译器会对指令重排以优化性能,但可能破坏多线程的预期执行顺序。

go 复制代码
// 没有同步时,可能被重排:
// 线程1:
a = 1          // 可能被重排到 flag=1 后面
flag = 1

// 线程2:
if flag == 1 {
    print(a)   // 可能打印 0!因为 a=1 被重排了
}

// 解决:使用 sync/atomic 或 mutex,它们隐含内存屏障

Go 内存模型:Go 的 happens-before 规则:

  • channel 发送 happens-before 对应的接收
  • sync.Mutex.Lock() happens-before 上一次 Unlock() 之后的操作
  • sync/atomic 操作提供顺序一致性

七、文件系统

文件系统结构

复制代码
磁盘布局:
┌──────────┬────────────┬──────────┬──────────────────────┐
│ 引导块    │ 超级块      │ inode 表 │ 数据块               │
│ (Boot)   │ (FS元数据) │         │                      │
└──────────┴────────────┴──────────┴──────────────────────┘

超级块:文件系统类型、总块数、空闲块数、inode 总数
inode 表:所有 inode 的数组
数据块:存储实际文件内容和目录条目

文件读取全过程

复制代码
open("/home/user/file.txt", O_RDONLY)
  1. 从根目录 inode 开始
  2. 读根目录数据块,找 "home" 目录项 → 获取 home 的 inode 号
  3. 读 home 的 inode,找到 home 的数据块
  4. 读 home 数据块,找 "user" → 获取 user 的 inode 号
  5. 重复直到找到 file.txt 的 inode
  6. 返回文件描述符(fd),内核在进程的文件描述符表中记录

read(fd, buf, 1024)
  1. 通过 fd 找到对应的 inode
  2. inode 中找到数据块地址
  3. 读取数据块内容到内核缓冲区(Page Cache)
  4. 从内核缓冲区拷贝到用户空间 buf

文件描述符与打开文件表

复制代码
进程A                    内核
fd 表:                  打开文件表:            inode 表:
fd 0 → ──────────────▶ [file0: pos=0, flags] ──▶ inode(stdin)
fd 1 → ──────────────▶ [file1: pos=0, flags] ──▶ inode(stdout)
fd 3 → ──────────────▶ [file2: pos=128, O_RDONLY] ──▶ inode(file.txt)
                                                           ↑
进程B                                                      │
fd 3 → ──────────────▶ [file3: pos=0, O_RDONLY] ──────────┘
                        (不同文件描述符,各自独立的读写位置)
                        (但共享同一个 inode,也就是同一文件)

八、系统调用与中断

系统调用

用户态 vs 内核态

复制代码
用户态:CPU 运行在限制模式,不能直接访问硬件,不能执行特权指令
内核态:CPU 运行在特权模式,可以访问所有硬件和内存

为什么要区分:保护内核,防止用户程序破坏系统

切换时机:
  用户态 → 内核态:系统调用(syscall)、硬件中断、异常(缺页)
  内核态 → 用户态:系统调用返回、中断处理完成

系统调用流程

复制代码
用户程序调用 read(fd, buf, n)
    │
    │(libc 封装:设置参数到寄存器,执行 syscall 指令)
    ▼
CPU 切换到内核态(保存用户态寄存器)
    │
内核的 sys_read() 执行
    │
(可能睡眠等待 I/O)
    │
数据就绪,拷贝到用户空间
    │
恢复用户态寄存器,切换回用户态
    │
    ▼
read() 返回读取的字节数

常见系统调用

类别 系统调用
进程 fork, exec, exit, wait, getpid
文件 open, read, write, close, lseek, stat
内存 mmap, munmap, brk
网络 socket, bind, listen, accept, connect, send, recv
同步 futex(mutex/cond 的底层)

中断机制

复制代码
中断类型:
  硬中断:硬件设备发出(键盘按键、网卡收包、磁盘 I/O 完成)
  软中断:软件触发(系统调用 int 0x80、调试 breakpoint)
  异常:CPU 执行出错(除零、缺页、段错误)

中断处理流程:
  1. 硬件保存当前 CPU 状态(压栈)
  2. 查中断向量表,跳转到对应的中断处理程序
  3. 执行中断处理(内核态)
  4. 恢复 CPU 状态,继续被中断的程序

中断与进程调度的关系:
  时钟中断(timer interrupt)是进程调度的触发器
  每次时钟中断,内核检查当前进程时间片是否用完
  用完则触发上下文切换,调度下一个进程

九、经典面试问题速答

Q:进程、线程、协程的区别?

进程是资源分配单位,有独立地址空间,切换开销大,崩溃不影响其他进程。线程是调度单位,共享进程资源,切换比进程快,一个线程崩溃影响整个进程。协程是用户态的轻量级线程,由程序自己调度(不进内核),切换开销极小,Go 的 goroutine 就是协程,初始栈只有 2KB,可以轻松创建百万个。

Q:进程间通信有哪些方式,各有什么特点?

共享内存最快(直接读写同一块内存,零拷贝),需配合信号量同步;管道简单但只能亲缘进程,单向;消息队列有结构、可设优先级,适合解耦;Socket 可跨机器,最通用;信号用于异步通知(如 kill 命令)。实际项目中,同机进程通信用 Unix Domain Socket 或共享内存,跨机器用 TCP Socket。

Q:什么是死锁,如何避免?

死锁是多个进程/线程因竞争资源而互相等待,陷入无法前进的状态,必须同时满足互斥、请求与保持、不可抢占、循环等待四个条件。最实用的避免方法:① 统一加锁顺序(破坏循环等待);② 使用超时机制,加锁失败则回退重试;③ 尽量减少锁的粒度和持有时间。Go 的 sync.Mutex 没有超时,可以用 tryLock + 重试或通过 channel 实现带超时的锁。

Q:LRU 如何实现,时间复杂度是多少?

用哈希表 + 双向链表实现 O(1) 的 get 和 put。哈希表存 key → 链表节点的指针,双向链表按访问时间排序(头部最近访问,尾部最久未访问)。get 时把节点移到头部;put 时若 key 存在则更新并移到头部,若缓存满则删除尾部节点再插入头部。这也是 LeetCode 146 题的标准解法。

Q:虚拟内存有什么作用?

三个作用:① 进程隔离------每个进程有独立的虚拟地址空间,互不干扰;② 内存超卖------进程可以使用比物理内存更大的地址空间,不常用的页换出到磁盘(Swap);③ 内存共享------多个进程可以映射同一物理页(如共享库),节省内存。

Q:缺页中断是什么?

进程访问的虚拟页不在物理内存中时,MMU 触发缺页中断,内核找到对应的数据(在 Swap 或文件中),分配一块物理内存,把数据载入,更新页表,然后重新执行触发中断的指令。整个过程对程序透明。

Q:用户态和内核态的区别,为什么要区分?

用户态 CPU 运行在非特权模式,不能直接访问硬件、不能执行特权指令;内核态可以执行任何指令,访问所有内存和硬件。区分是为了保护:防止用户程序误操作或恶意破坏内核数据和硬件。系统调用是用户程序请求内核服务的标准通道,每次系统调用都要切换到内核态,有一定开销(微秒级),所以高性能程序尽量批量操作减少系统调用次数。

Q:什么是上下文切换,开销来自哪里?

上下文切换是 CPU 从运行一个任务切换到另一个任务,需要保存当前任务的寄存器、程序计数器等状态,再恢复下一个任务的状态。开销来自:① 直接开销:保存/恢复寄存器(几十纳秒);② 间接开销:进程切换时需刷新 TLB(地址转换缓存失效),可能引发大量 TLB miss,导致后续内存访问变慢。这是为什么 goroutine(用户态切换,不刷 TLB)比线程快的重要原因。

Q:进程调度算法中,为什么现代 OS 用多级反馈队列?

因为进程的特性(I/O 密集型还是 CPU 密集型)在运行时才能体现,无法事先知道。多级反馈队列让进程的行为"暴露"自己的特性:I/O 密集型进程频繁主动让出 CPU,始终保持在高优先级队列,获得快速响应;CPU 密集型进程不断消耗时间片,自然降级到低优先级队列,获得高吞吐。这是自适应的调度策略,不需要预知进程类型。

Q:信号量和互斥锁的区别?

互斥锁是二值信号量的特例,用于互斥访问(0/1)。信号量更通用:计数信号量可以控制同时访问资源的进程数量(如连接池限制最大连接数为 10)。另一个关键区别:互斥锁必须由加锁的线程解锁(所有权),信号量可以由其他线程执行 V 操作(无所有权),适合生产者-消费者这种跨线程的同步场景。


十、一张图记住核心

复制代码
进程与线程:
  进程=资源隔离(独立地址空间)+ 通信需IPC
  线程=轻量调度(共享内存)+ 切换快 + 崩溃影响全进程
  goroutine=用户态协程(2KB栈)+ runtime调度 + channel通信

内存管理:
  虚拟内存 → 页表 → MMU → 物理地址
  TLB缓存热点页表项,缺页中断从磁盘载入
  置换算法:LRU(哈希表+双向链表 O(1) 实现)

死锁四条件:互斥 + 请求保持 + 不可抢占 + 循环等待
死锁预防最实用:按序加锁 + 超时回退

同步原语对比:
  Mutex(互斥锁):独占访问
  RWMutex(读写锁):读多写少场景
  Semaphore(信号量):控制并发数量
  Cond(条件变量):等待某个条件成立

系统调用:用户态→内核态→执行服务→内核态→用户态
          每次切换有微秒级开销,高性能代码要减少 syscall 次数

Q:Go 的 GMP 模型是什么?

GMP 是 Go runtime 的协程调度模型。G(goroutine)是协程任务,M(Machine)是系统线程,P(Processor)是调度器,持有本地 G 队列。GOMAXPROCS 个 P 并行运行,每个 P 绑定一个 M。G 发生阻塞系统调用时,P 脱离 M 去找空闲 M 继续调度,保证 CPU 不浪费。P 队列为空时从其他 P 窃取(Work Stealing)。goroutine 比线程快的原因:用户态切换(不进内核)、初始栈只有 2KB、数量可以百万级。

Q:栈内存和堆内存的区别?

栈由编译器自动管理,存局部变量和函数调用信息,分配/释放极快(移动栈指针),但大小有限(默认 8MB)。堆由程序员/GC 管理,存动态分配的对象,大小灵活但分配慢且有碎片问题。Go 通过逃逸分析决定变量分配在哪:函数返回后仍被引用的变量逃逸到堆,否则分配在栈。

Q:自旋锁和互斥锁怎么选?

锁持有时间极短(微秒级)且是多核环境用自旋锁,避免线程切换开销;锁持有时间较长用互斥锁,避免长时间空转浪费 CPU。Go 的 sync.Mutex 内部先自旋若干次,失败后才睡眠,是自适应的结合方案,不需要手动选择。

Q:什么是 fork-exec 模式,Copy-on-Write 有什么用?

shell 执行命令时,先 fork() 复制当前进程,再 exec() 在子进程中加载新程序。fork 本身很快,因为 Copy-on-Write 不立即复制内存,父子进程共享同一物理页,只有写操作发生时才复制那一页,避免了立即复制大量内存的开销。


总结 :操作系统面试核心考点是进程线程协程区别GMP 调度模型死锁四条件与解决方案虚拟内存与缺页中断LRU 实现用户态内核态切换栈与堆的区别。这七个方向吃透,操作系统面试就稳了。

相关推荐
张元清2 小时前
React 19 Hooks:新特性及高效使用指南
前端·javascript·面试
想吃火锅10052 小时前
【leetcode】98.验证二叉搜索树
算法·leetcode·职场和发展
微露清风2 小时前
系统性学习Linux-第五讲-基础IO
linux·运维·学习
柏木乃一2 小时前
Linux线程(8)基于单例模式的线程池
linux·运维·服务器·c++·单例模式·操作系统·线程
17(无规则自律)2 小时前
嵌入式 Linux 启动:设备树的加载、传递和解析全流程分析
linux·stm32·嵌入式硬件·unix
Trouvaille ~2 小时前
【贪心算法】专题(三):排序、博弈与区间的贪婪法则
c++·算法·leetcode·青少年编程·面试·贪心算法·蓝桥杯
kebidaixu2 小时前
VS Code安装 Remote - SSH 扩展
linux·服务器·ssh
AI+程序员在路上2 小时前
瑞芯微 RV1126B ADB 调试命令完全指南
linux·adb
努力学算法的蒟蒻2 小时前
day111(3.13)——leetcode面试经典150
算法·leetcode·面试