文章目录
- [操作系统面试完全指南(Go 校招向)](#操作系统面试完全指南(Go 校招向))
-
- 一、进程与线程
-
- 基本概念
- [进程 vs 线程(面试必考)](#进程 vs 线程(面试必考))
- 进程状态机
- 进程控制块(PCB)
- [上下文切换(Context Switch)](#上下文切换(Context Switch))
- [fork() 与 Copy-on-Write](#fork() 与 Copy-on-Write)
- [孤儿进程 vs 僵尸进程](#孤儿进程 vs 僵尸进程)
- [栈 vs 堆(面试高频)](#栈 vs 堆(面试高频))
- 二、进程调度算法
- 三、内存管理
-
- 内存分层结构
- 虚拟内存(面试核心)
- [分页 vs 分段](#分页 vs 分段)
- 页面置换算法
-
- [OPT(最优置换)------ 理论最优](#OPT(最优置换)—— 理论最优)
- FIFO(先进先出)
- [LRU(最近最少使用)------ 最常用](#LRU(最近最少使用)—— 最常用)
- [Clock(时钟置换)------ LRU 的近似实现](#Clock(时钟置换)—— LRU 的近似实现)
- 多级页表(为什么需要)
- 内存分配算法
- 四、死锁
- 五、同步与互斥
-
- 临界区问题
- 同步原语
-
- [自旋锁 vs 互斥锁(面试高频)](#自旋锁 vs 互斥锁(面试高频))
- 互斥锁(Mutex)
- 读写锁(RWMutex)
- 信号量(Semaphore)
- 条件变量(Cond)
- 经典同步问题
- [六、Go GMP 调度模型(Go 后端必考)](#六、Go GMP 调度模型(Go 后端必考))
- [七、内存一致性与 CPU 缓存](#七、内存一致性与 CPU 缓存)
-
- [CPU 缓存层级与缓存一致性](#CPU 缓存层级与缓存一致性)
- 内存屏障与可见性
- 七、文件系统
- 八、系统调用与中断
- 九、经典面试问题速答
- 十、一张图记住核心
操作系统面试完全指南(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 级 最低
虚拟内存(面试核心)
为什么需要虚拟内存:
- 程序使用的地址是虚拟地址,相互隔离(进程A的地址0x1000 和 进程B的0x1000 是不同物理位置)
- 程序可以使用比物理内存更大的地址空间
- 通过 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。
五、同步与互斥
临界区问题
临界区:访问共享资源的代码段
进入临界区前:申请进入(加锁)
临界区代码: 操作共享资源
离开临界区后:允许他人进入(解锁)
正确的同步方案必须满足:
- 互斥:同一时刻最多一个进程在临界区
- 前进(空闲让进):无进程在临界区时,想进入的进程能立即进入
- 有限等待:等待进入临界区的时间有上限(无饥饿)
同步原语
自旋锁 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 实现 、用户态内核态切换 、栈与堆的区别。这七个方向吃透,操作系统面试就稳了。