面试 | 操作系统

文章目录

操作系统面试完全指南(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 实现 、用户态内核态切换 、栈与堆的区别。这七个方向吃透,操作系统面试就稳了。

相关推荐
未济5 天前
linux 配置环境变量
linux
傲世仙尊5 天前
目录即文件-Ext文件系统收尾篇
linux·c语言
晨米酱5 天前
AGENTS.md:Agent 的上下文策略层
面试·架构·agent
_艾伦 耶格尔.5 天前
进程间通信
linux
Liuqy-055 天前
Linux IO编程——静态库、动态库
linux
彧azz5 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
我爱cope5 天前
【操作系统 | 计算机硬件:CPU、内存与 I/O 如何支撑操作系统?】
学习·操作系统
-梅5 天前
linux(8) 软硬链接
linux·运维·服务器
AIgorithmGEEK5 天前
[Linux]线程三部曲(上):一个执行流的诞生——从操作系统一路拆到 pthread_create
linux·线程·pid
琥珀色糖5 天前
SimlpeHttp
linux·服务器