IO · 计算机基础面试题
前端考 OS/计基,不是要你去写内核,而是三个很实际的能力:
- 理解并发模型——浏览器的多进程、Node 的事件循环、Flutter 的 Isolate,本质上都是进程/线程模型的变体;
- 给性能问题归因——"为什么这个循环这么慢"的答案常常在缓存局部性,"为什么内存一直涨"的答案在虚拟内存与 GC;
- 跟后端对等对话——"你这个服务能撑多少并发"取决于 I/O 模型,而不是"用了什么框架"。
所以本页的写法是:每个知识点都给"底层原理 + 前端/Node 侧的映射",让你答得出"所以我拿它干什么"。
📌 结构:零、一条主线 · 一、进程/线程/协程 · 二、并发与锁 · 三、内存管理 · 四、I/O 模型 · 五、CPU 缓存与调度 · 六、进程通信 · 七、存储与文件系统 · 八、自检 · 九、误区。
高频 Top 12(不会的话优先补这些)
| # | 题目 | 一句答案 | 本页 |
|---|---|---|---|
| 1 | 进程和线程的区别 | 进程是资源分配的基本单位,线程是 CPU 调度的基本单位 | 1.1 |
| 2 | 协程为什么比线程轻量 | 切换不进内核、不换页表、只需保存少量寄存器 | 1.3 |
| 3 | 死锁的四个必要条件 | 互斥、占有且等待、不可抢占、循环等待——破坏任一个即可 | 2.3 |
| 4 | 悲观锁与乐观锁怎么选 | 冲突频繁用悲观(互斥),冲突少用乐观(CAS + 版本号) | 2.2 |
| 5 | 虚拟内存解决什么问题 | 隔离 + 简化链接 + 突破物理内存 + 共享 | 3.1 |
| 6 | 分页和分段的区别 | 分页是物理视角、定长、消外部碎片;分段是逻辑视角、变长、消不了外部碎片 | 3.2 |
| 7 | 什么是缺页中断 | 访问的页不在物理内存(或没映射)→ 陷入内核 → 调页/报错 | 3.3 |
| 8 | 阻塞/非阻塞 与 同步/异步 的区别 | 前者看线程是否被挂起,后者看结果怎么回来 | 4.1 |
| 9 | select / poll / epoll 的区别 | epoll 用红黑树 + 就绪链表,不用每次全量拷贝与 O(n) 轮询 | 4.3 |
| 10 | epoll 是异步 I/O 吗 | 不是——它是同步 I/O 的多路复用(Reactor),数据还得你自己 read | 4.4 |
| 11 | Node 真的是单线程吗 | JS 执行单线程,I/O 靠 libuv 的多路复用 + 线程池 | 4.6 |
| 12 | 为什么顺序遍历数组比跳着快 | 空间局部性——一次取一个 cache line(64B),顺带把后面几个元素也取进来了 | 5.2 |
零、一条主线:程序眼里的世界长什么样
理解所有计基问题的前提,是这张图:
你的代码(用户态)
├── 看到的是【虚拟地址】,不是物理内存 → 进程间天然隔离
├── 要读写文件/网络/分配内存,必须【系统调用】陷入内核 → 有切换成本
├── CPU 不直接读内存,先过 L1/L2/L3 缓存 → 访问模式决定速度
└── 一次 I/O 要经历:用户缓冲 → 内核缓冲 → 设备 → (拷贝次数决定吞吐)衍生出的四条"为什么"(面试时顺着这四条讲,逻辑是通的):
| 现象 | 根因 |
|---|---|
| 为什么要有线程/协程 | 系统调用与上下文切换太贵,要减少阻塞与切换 |
| 为什么会有锁和死锁 | 多个执行单元共享资源且调度顺序不可控 |
| 为什么内存会"不够" | 虚拟内存 + 换页,工作集超出物理内存就抖动 |
| 为什么高并发要改架构 | 每个连接一个线程扛不住 → 多路复用 → 事件驱动 |
一、进程 / 线程 / 协程
1.1 本质区别
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 是什么 | 资源分配的基本单位 | CPU 调度的基本单位 | 用户态的轻量级执行单元 |
| 地址空间 | 独立 | 与同进程线程共享 | 共享(同一线程内) |
| 调度者 | 内核 | 内核 | 用户程序自己(语言运行时/框架) |
| 切换成本 | 最高(换页表、TLB 失效) | 中(~微秒级,仍要进内核) | 最低(不进内核,保存少量寄存器) |
| 通信方式 | IPC(管道/共享内存/socket) | 共享变量 + 锁 | 共享变量 + 主动让出(await/yield) |
线程之间共享什么、独占什么(高频追问):
| 共享 | 独占 |
|---|---|
| 堆、全局变量、静态区 | 栈、程序计数器(PC)、寄存器 |
| 打开的文件描述符、信号处理的部分语义 | 线程局部存储(TLS)、调度优先级的部分状态 |
Linux 的实现上,线程就是"共享了地址空间的进程"(
task_struct),调度的是线程而非进程—— 所以更准确的说法是:进程是资源的容器,线程是调度实体。
1.2 上下文切换到底贵在哪
一次线程切换的成本不止"保存寄存器":
- 保存/恢复寄存器、栈指针、PC;
- 进入内核态(用户态↔内核态切换);
- 内核调度器选下一个任务(CFS 红黑树);
- 若是跨进程切换:换页表(写 CR3)→ TLB 大面积失效;
- CPU 缓存被冲掉(新任务的数据不在缓存里,前几次访问全是 miss)。
所以"多开几个线程就快了"是错的:线程数超过 CPU 核数后,增加的是切换开销而不是吞吐。 I/O 密集型可以多于核数(反正会阻塞),CPU 密集型应该≈核数(或核数±1)。
1.3 协程为什么轻
协程的切换完全在用户态:
- 不进内核 → 没有系统调用与特权级切换;
- 不换页表 → TLB 不失效;
- 需要保存的上下文极少(少量寄存器 + 栈指针);
- 调度是协作式的:只有在
await/yield这类明确的让出点才切换 → 不需要锁保护大部分临界区(因为没有抢占)。
| 抢占式(线程) | 协作式(协程) | |
|---|---|---|
| 切换时机 | 任意指令处(时间片到) | 只在你主动让出时 |
| 需要锁 | 是 | 大部分场景不需要(除非多协程跑在多线程上) |
| 风险 | — | 一个协程不让出,整条线程就卡死(同步阻塞调用、死循环) |
这就是为什么 Go / Kotlin / Dart 的
await里不能塞 CPU 密集的死循环,以及 为什么 Node 里一段同步长任务会卡掉所有请求——它们是协作式的,你占着不让,别人就全等着。
1.4 一张映射表:把概念落到你写的代码上
| 场景 | 用的是 | 意味着 |
|---|---|---|
| 浏览器 | 多进程架构:浏览器进程 / 渲染进程 / GPU 进程 / 网络进程(+ 站点隔离后按站点分进程) | 一个标签页崩了不会带崩整个浏览器;跨标签页不能直接共享内存 |
| 渲染进程内 | 主线程(JS + 布局 + 绘制)+ 合成线程 + 光栅线程 + Worker 线程 | JS 与渲染互斥 → 长任务直接卡 UI;重活要丢给 Web Worker |
| Web Worker | 线程,但不共享内存(靠 postMessage 结构化克隆) | 没有数据竞争;但传大对象有拷贝成本(用 Transferable / SharedArrayBuffer 解决) |
| Node.js | JS 单线程 + libuv(网络用 epoll/kqueue/IOCP,文件用线程池) | 见 4.6 |
| Flutter / Dart | Isolate:独立堆、不共享内存、通过 Port 传消息 | 天生没有锁的问题;但对象要拷贝,大图/大列表传递有成本 |
| Go | goroutine(M:N 协程,跑在多线程上) | 既轻量又能吃多核;但有数据竞争风险 |
→ 浏览器事件循环与 Node 差异见 A09。
二、并发与锁
2.1 竞态:单线程也有竞态
竞态条件 = 结果依赖于多个执行单元的调度顺序。
⚠️ 前端常见误解:"JS 是单线程,所以不会有并发问题。" 错。单线程只是没有指令级抢占,但
await之间有明确的让出点,同一个"逻辑事务"可能被打断:
// 竞态示例:check-then-act 被 await 打断
async function pay(orderId) {
if (await isPaid(orderId)) return; // ① 检查
await doPay(orderId); // ② 期间另一个 pay() 也通过了 ①
}| 前端/Node 里的"并发"来源 | 处理 |
|---|---|
await 之间的状态被别人改 | 加幂等键 / 把 check-act 做成服务端原子操作 |
| 多个请求并发、返回顺序不确定 | 请求序号丢弃过期响应 / AbortController 取消 |
| 多个标签页同时操作本地数据 | Web Locks API(navigator.locks.request) |
| Worker 间共享内存(SAB) | SharedArrayBuffer + Atomics(需 COOP/COEP 响应头) |
| 多进程/多实例的服务端 | 分布式锁(Redis SET NX PX / Redlock) |
2.2 锁的取舍:不是越重越好
| 锁 | 机制 | 适用 | 代价 |
|---|---|---|---|
| 互斥锁 mutex | 抢不到就睡眠(进内核等待队列) | 临界区较长 | 上下文切换开销 |
| 自旋锁 spinlock | 抢不到就循环忙等 | 临界区极短、多核 | 占着 CPU 空转 |
| 读写锁 | 读共享、写独占 | 读多写少 | 写饥饿;实现比互斥锁重 |
| 乐观锁 / CAS | 不加锁,更新时校验版本 | 冲突少 | 冲突多时反复重试;ABA 问题(用版本号解决) |
| 无锁(lock-free) | CAS 循环 | 极低延迟场景 | 实现复杂、难验证 |
判断依据:临界区执行时间 vs 一次上下文切换时间。 临界区比切换还短 → 自旋更划算;临界区长 → 睡觉(互斥)更划算。
2.3 死锁:四条件与四种破法
死锁必须同时满足四个条件,破坏任意一个即可:
| 条件 | 含义 | 怎么破 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个持有 | 通常破不了(资源本来就是独占的)→ 换思路:资源池化/复制 |
| 占有且等待 | 持有一个还去等另一个 | 一次性申请全部资源(要么全拿到,要么都不拿) |
| 不可抢占 | 不能强行收回 | 允许抢占:申请不到就释放已持有的再重试 |
| 循环等待 | A 等 B、B 等 A | 按固定顺序申请资源(给资源编号,必须从小到大申请)→ 最常用、最实用 |
答题模板:先说四条件,再给"按序加锁"这个工程上最常用的解法, 最后补一句"数据库里就是加锁顺序不一致 + 长事务导致死锁,所以要统一加锁顺序、缩短事务"。
银行家算法:避免死锁的理论方案(分配前先模拟,看是否处于安全状态)。 实际系统很少用(需要预知最大需求),检测 + 回滚更常见(数据库画等待图,选一个 victim 回滚)。
活锁与饥饿(追问加分):
| 现象 | 例子 | |
|---|---|---|
| 活锁 | 没阻塞,但一直重试、一直失败,什么也没干成 | 两个人都让路,结果来回让 |
| 饥饿 | 永远轮不到自己 | 读写锁下的写饥饿;低优先级任务被高优先级持续抢占 |
三、内存管理
3.1 为什么必须有虚拟内存
四个理由,缺一不可:
| 作用 | 说明 |
|---|---|
| 隔离与安全 | 每个进程看到独立的连续地址空间,踩别人的内存会 Segmentation fault 而不是悄悄改掉别人 |
| 简化链接与加载 | 每个程序都可以从同一个固定地址开始链接,不用管物理内存到底放在哪 |
| 突破物理内存 | 暂时不用的页换出到磁盘,工作集能大于物理内存(代价见抖动) |
| 共享 | 共享库、mmap 文件、进程间共享内存——同一份物理页映射到多个进程 |
3.2 分页 vs 分段
| 分页 | 分段 | |
|---|---|---|
| 划分依据 | 物理视角,固定大小(x86-64 通常 4KB) | 逻辑视角,按语义(代码段/数据段/堆/栈)变长 |
| 碎片 | 有内部碎片(最后一页用不满),无外部碎片 | 有外部碎片(段之间留下空洞) |
| 对程序可见 | 不可见(透明) | 可见(段是程序员/编译器能感知的单位) |
| 现代 OS | ✅ 主流 | 基本只保留做权限/兼容,实际靠分页 |
多级页表 + TLB:页表本身很大(64 位下 4 级页表),所以 CPU 里有个 TLB(快表) 缓存最近的地址翻译。 命中就免了多次内存访问;进程切换会换页表 → TLB 失效 → 这是进程切换比线程切换贵的直接原因之一。 还有大页(Huge Page,2MB/1GB)减少 TLB miss。
3.3 地址翻译:页表 → TLB → 缺页
CPU 给出虚拟地址 → MMU 查 TLB
├─ 命中 → 直接得到物理地址(快)
└─ 未命中 → 查多级页表
├─ 页在内存且已映射 → 填 TLB,继续(minor fault,成本较低)
└─ 不在内存 / 未映射 → 【缺页中断】陷入内核
├─ 合法(换出到 swap 了 / 首次访问的匿名页 / mmap 的文件)→ 调页进来,重跑指令
└─ 非法(野指针、越界)→ SIGSEGVmajor fault vs minor fault:要不要读磁盘。major fault 慢几个数量级。 (前端能观察的:容器/服务监控里的 page fault 指标异常,往往是内存不够或 swap 开着。)
3.4 页面置换与抖动
| 算法 | 思路 | 评价 |
|---|---|---|
| OPT | 换出最远的将来才用的页 | 理论最优,不可实现(要预知未来),只作基准 |
| FIFO | 先进先出 | 简单,但有 Belady 异常(给的页框越多,缺页反而越多) |
| LRU | 最久未使用 | 接近 OPT,实现成本高(要维护顺序) |
| Clock / 二次机会 | 环形扫描 + 访问位 | LRU 的近似实现,工程上用它 |
| LFU | 用得最少 | 有"历史包袱",旧热点赖着不走 |
抖动(thrashing):进程工作集 > 可用物理内存 → 刚换出的页马上又要换进来 → CPU 时间全花在换页上,吞吐塌方。 解法:降低并发度(把部分进程挂起)、加内存、关 swap(生产数据库/低延迟服务常见做法)。
3.5 分配与回收:从 OS 到 GC
应用 malloc/new → glibc/jemalloc/tcmalloc(用户态分配器:空闲链表 / size class / 线程本地缓存)
→ 不够就 brk(扩堆)或 mmap(大块)
内核 → 伙伴系统(按页,2 的幂,解决外部碎片)+ slab/slub(内核小对象)GC 算法(与 JS/Flutter 直接相关):
| 算法 | 问题 |
|---|---|
| 引用计数 | 循环引用回收不了(所以还需要标记清除兜底,如 C++ 的 weak_ptr 破环) |
| 标记-清除 | 有内存碎片 |
| 复制(半空间) | 无碎片,但浪费一半空间 |
| 标记-整理 | 无碎片、不浪费,但要移动对象(更慢) |
| 分代 | 基于弱分代假说(大多数对象朝生夕死)→ 新生代用复制(便宜),老生代用标记清除/整理 |
→ V8 的分代回收、内存泄漏排查清单见 A09 · JavaScript 运行时; 线上内存问题的观测方法见 A12。
四、I/O 模型(本章最高频)
4.1 最容易答混的一组概念
阻塞/非阻塞 看的是:调用后,当前线程有没有被挂起。 同步/异步 看的是:结果是谁、以什么方式给你的。
| 阻塞 | 非阻塞 | |
|---|---|---|
| 同步 | read():等到数据来才返回 | read() + O_NONBLOCK:没数据立刻返回 EAGAIN,你自己去问 |
| 异步 | (概念上不常见) | io_submit / io_uring:你提交就走,内核完成后通知你 |
一句话记忆:同步 = 我自己取;异步 = 送上门。阻塞 = 我一直等;非阻塞 = 先走,回头再问。
4.2 五种 I/O 模型(Stevens 分类)
一次 I/O 其实是两步:① 等数据准备好(内核缓冲区)② 把数据从内核拷到用户空间。
| 模型 | 第①步 | 第②步 | 评价 |
|---|---|---|---|
| 阻塞 I/O | 阻塞等待 | 阻塞拷贝 | 最简单,一个线程只能处理一个 fd |
| 非阻塞 I/O | 立刻返回,轮询 | 阻塞拷贝 | 轮询烧 CPU |
| I/O 多路复用 | select/poll/epoll 阻塞等多个 fd | 阻塞拷贝(对就绪的 fd) | 高并发网络的事实标准 |
| 信号驱动 | 信号通知 | 阻塞拷贝 | 用得少 |
| 异步 I/O(AIO) | 不阻塞 | 内核拷完再通知 | 真正的异步;Linux io_uring、Windows IOCP |
4.3 三代多路复用:select → poll → epoll
| select | poll | epoll | |
|---|---|---|---|
| 数据结构 | fd_set 位图 | 数组(链表) | 内核红黑树 + 就绪链表 |
| 数量限制 | FD_SETSIZE 通常 1024 | 无硬限制 | 无硬限制(只受内存/打开句柄数) |
| 每次调用 | 全量拷贝 fd 集合到内核 | 同左 | 一次 epoll_ctl 注册,之后不用重复传 |
| 找就绪 fd | O(n) 轮询 | O(n) 轮询 | O(1) 直接拿就绪链表 |
| 触发方式 | LT | LT | LT(默认)+ ET |
LT vs ET(高频追问):
- LT 水平触发:只要缓冲区还有数据,就一直通知你 → 你可以分几次读,不容易漏,但可能重复唤醒。
- ET 边缘触发:只在状态变化时通知一次 → 必须一次把数据读完(循环
read到EAGAIN), 而且 fd 必须设为非阻塞,否则最后一次read会卡死。epoll + ET + 非阻塞是 Nginx/Redis 的经典组合,性能更好但更易写错。
还有 EPOLLONESHOT(一次事件后不再通知,需要重新
epoll_ctl MOD)用于多线程处理同一 fd、避免竞态。
4.4 两种模式:Reactor vs Proactor
| Reactor(反应式) | Proactor(前摄式) | |
|---|---|---|
| 谁做拷贝 | 应用程序自己 read | 内核拷完再通知 |
| 代表 | epoll / kqueue、Node、Netty、Redis | Windows IOCP、Linux io_uring、.NET |
| 归类 | 同步 I/O(多路复用) | 异步 I/O |
面试点:epoll 不是异步 I/O。它只是"帮你等",数据还是你自己读。 现在 Linux 上真正大规模落地的异步 I/O 是
io_uring(环形队列,还能省掉大量系统调用), 新一代数据库/代理(如部分 MySQL/TiDB、Netty 的 io_uring 分支)在跟进。
4.5 零拷贝:为什么 Kafka/Nginx 这么快
传统 read() + write() 发一个文件:
磁盘 --DMA--> 内核缓冲 --CPU拷贝--> 用户缓冲 --CPU拷贝--> socket 缓冲 --DMA--> 网卡
4 次上下文切换(2 次 read + 2 次 write),2 次 CPU 拷贝| 优化 | 做法 | 省掉什么 |
|---|---|---|
mmap | 把内核缓冲映射到用户空间 | 省 1 次 CPU 拷贝(用户态直接访问) |
sendfile | 内核里直接把文件缓冲推给 socket | 省 2 次 CPU 拷贝 + 上下文切换减到 2 次(零系统调用拷贝) |
splice / tee | 两个 fd 之间在内核里搬 | 不需要用户态介入,也不要求一端是 socket |
io_uring | 提交/完成用共享环形队列 | 省掉大量系统调用本身的开销 |
前端的直观对应:静态文件服务器、CDN、大文件下载几乎都走 sendfile/mmap, 所以"用 Nginx 发静态文件"比"用 Node 读文件再写响应"快得多——Node 每发一个字节都要过用户空间。
4.6 Node 的"单线程"到底是什么意思
┌─────────────── JS 主线程(单线程执行,事件循环)───────────────┐
│ timers → pending callbacks → poll → check → close → microtask │
└──────────────────────────┬─────────────────────────────────────┘
│ 阻塞型工作交给 libuv
┌───────────────────────────┴────────────────────────────┐
│ 网络 I/O:epoll / kqueue / IOCP(多路复用,内核态) │
│ 文件 I/O、DNS(getaddrinfo)、crypto、zlib:线程池 │
│ 线程池大小:UV_THREADPOOL_SIZE,默认 4 │
└────────────────────────────────────────────────────────┘| 说法 | 对错 | 解释 |
|---|---|---|
| "Node 是单线程" | 半对 | JS 执行单线程;I/O 由 libuv 的多路复用 + 线程池完成 |
| "Node 适合 I/O 密集" | ✅ | 高并发长连接的等待不占线程 |
| "Node 不适合 CPU 密集" | ✅ | 同步长任务会卡住整个事件循环(所有请求一起变慢) |
| "多开线程能解决 CPU 密集" | 部分 | worker_threads 可以,但不共享内存(靠消息/ArrayBuffer 转移);cluster 是多进程 |
CPU 密集的正确解法:worker_threads / cluster 多进程 / 用 Rust、Go 写扩展 / 把计算丢给队列。
五、CPU、缓存与调度
5.1 用户态 / 内核态
| 用户态 | 内核态 | |
|---|---|---|
| 权限 | 受限,不能直接访问硬件/其他进程内存 | 全权 |
| 切换方式 | 系统调用(read/write/fork)、中断、异常 | |
| 代价 | 一次系统调用约 微秒级(保存现场 → 切栈 → 权限检查 → 执行 → 恢复) |
实践意义:系统调用不是免费的。所以才有"批量读写""缓冲写""io_uring 减少系统调用"这些优化。
5.2 缓存局部性:前端能真实感知的一条
CPU → L1(~1ns) → L2(~4ns) → L3(~10ns) → 内存(~100ns) → SSD(~100μs) → 网络/磁盘(ms)cache line 是 64 字节——CPU 不是按"一个变量"取内存,而是一次取一整行。
推论:
| 写法 | 结果 |
|---|---|
| 顺序遍历数组 | 一次 cache line 带进来后面几个元素 → 快 |
| 跳着访问链表 / 随机访问大数组 | 每次都 miss → 慢得多 |
| 数据紧凑(SoA / 定长结构) | 缓存命中高 |
| 对象里塞一堆指针到处指 | 每次解引用都可能 miss |
伪共享(false sharing):两个线程改同一个 cache line 里的不同变量 → 缓存一致性协议(MESI)会让两个核来回作废对方的缓存行 → 比分开在两个 cache line 慢得多。 解法:填充对齐(padding)。
前端能观察到的对应现象: 排序后再遍历更快(分支预测 + 预取友好); TypedArray 比普通 Array 快(连续内存 + 固定类型); 结构化克隆大对象很慢(遍历 + 拷贝,纯内存带宽与 cache miss 问题)。
5.3 分支预测
CPU 会猜测分支走向并提前执行,猜错要回滚流水线。 所以对已排序数据做 if 判断,命中率极高 → 明显快于乱序数据(经典 Stack Overflow 问题 "Why is processing a sorted array faster")。
5.4 调度
| 概念 | 说明 |
|---|---|
| 进程状态 | 就绪 / 运行 / 阻塞(+ 挂起) |
| 调度算法 | 时间片轮转 RR、优先级调度、CFS(Linux 完全公平调度,按 vruntime 用红黑树挑最小的)、多级反馈队列 |
| 优先级反转 | 低优先级持锁 → 高优先级等锁 → 被中优先级抢占,导致高优先级反而最慢(火星探路者号的真实故障)→ 解法:优先级继承 / 优先级天花板 |
前端的映射:浏览器的"调度"也有同样的思路——
| 机制 | 对应 |
|---|---|
| 长任务 > 50ms | 等价于"独占 CPU 不让出" → INP 变差 |
| 时间切片(React Scheduler) | 手动实现"协作式调度":每 5ms 让出一次,用 MessageChannel 而非 setTimeout(后者有 4ms 嵌套限制) |
requestIdleCallback | 利用空闲时段,等价于"低优先级任务" |
scheduler.yield() / isInputPending | 主动让出、判断是否有更高优先级的输入要处理 |
六、进程间通信(IPC)
| 方式 | 特点 | 适用 |
|---|---|---|
| 管道 pipe | 半双工、字节流、有内核缓冲 | 父子进程,shell | |
| 命名管道 FIFO | 有名字,无关进程也能用 | 本机简单通信 |
| 信号 signal | 只传一个编号,不能带数据 | 通知(终止、重载配置) |
| 消息队列 | 有格式、内核中排队 | 小消息,逐渐被替代 |
| 共享内存 | 最快(无拷贝),但要自己同步(信号量/锁) | 大吞吐,数据库、Chrome 的部分场景 |
| 信号量 | 计数器,做同步与互斥 | 配合共享内存 |
| socket / Unix domain socket | 可跨机;UDS 本机走内核,比 TCP 快 | 通用;Docker/Nginx/php-fpm 常用 UDS |
| Binder | Android 的 IPC(一次拷贝 + 能力校验) | 移动端 |
性能排序(直觉):共享内存 > UDS/管道 > socket(TCP) > 消息队列 > 信号(只当通知用)。 但共享内存换来的是"你得自己处理同步"——复杂度和性能的经典交换。
前端侧对应的通信机制:
| Web/Node | 类比 | 说明 |
|---|---|---|
postMessage | 消息传递 | 结构化克隆(拷贝),大对象用 Transferable 转移所有权(零拷贝) |
SharedArrayBuffer + Atomics | 共享内存 | 真共享,要自己同步;需 COOP/COEP 响应头(安全隔离要求) |
MessageChannel | 双向管道 | 比 postMessage 更适合持续通信;React Scheduler 就靠它做时间切片 |
BroadcastChannel | 同域广播 | 多标签页同步 |
| Web Locks API | 锁 | 多标签页互斥 |
Node cluster / child_process | IPC | 主进程与子进程用内置 IPC 通道 |
七、存储与文件系统(简版)
前端不必深挖,但存储选型题会用到这几条。
| 概念 | 要点 |
|---|---|
| 页缓存 page cache | 内核用空闲内存缓存磁盘数据;读写基本都先落缓存,再异步回写(writeback) |
fsync | 强制把缓存刷到磁盘。不做 fsync 掉电就可能丢数据——代价是慢,所以数据库要调 fsync 策略(每条/每秒/每事务) |
| 顺序 vs 随机 | 顺序读写远快于随机(HDD 差百倍,SSD 也有差距与写放大、GC 压力)→ 所以有 LSM-Tree(把随机写变顺序写,Kafka/RocksDB/ClickHouse 的思路) |
| inode / 硬链接 / 软链接 | inode 存元数据;硬链接共享 inode(删除只是减引用计数),软链接是路径(原文件删了就失效) |
mmap | 把文件映射成内存,省一次拷贝;适合大文件只读/随机访问 |
| 零拷贝 | 见 4.5 |
浏览器侧的对应:
| 存储 | 类比 | 特点 |
|---|---|---|
localStorage | 小、同步、字符串 | 同步 API 会阻塞主线程,别存大东西 |
IndexedDB | 事务型 KV(异步) | 容量大;API 啰嗦,通常包一层 |
| OPFS(File System Access) | 真实文件 + 可 createSyncAccessHandle 在 Worker 里同步读写 | 做本地大文件/媒体/数据库引擎的底座 |
| Cache Storage | 请求-响应缓存 | 配合 Service Worker 做离线 |
八、自检清单
并发模型
- [ ] 说清进程/线程/协程在"谁调度、切换代价、是否共享内存"上的差异
- [ ] 说清协作式的风险(一个不让出,全队卡死),并映射到 Node 长任务 / Dart Isolate
- [ ] 能指出"JS 单线程所以没并发问题"这句话错在哪(至少给两个例子)
- [ ] 死锁四条件 + 至少两种破法,能说出工程上最常用的是"按序加锁"
内存
- [ ] 说清虚拟内存的四个作用,以及 TLB 存在的原因
- [ ] 说清分页 vs 分段、内部碎片 vs 外部碎片
- [ ] 说清缺页中断的完整流程与 major/minor 的区别
- [ ] 说清抖动是什么、怎么解;GC 各算法的取舍(并链到 A09)
I/O 与性能
- [ ] 能区分"阻塞/非阻塞"与"同步/异步"两组概念
- [ ] 说清 select/poll/epoll 三代差异,以及 LT/ET 的实践要求(ET 必须配非阻塞)
- [ ] 说清 epoll 为什么属于同步 I/O,真正的异步是什么
- [ ] 说清零拷贝省了什么(拷贝次数 + 上下文切换次数)
- [ ] 说清 Node 单线程的真实含义与 CPU 密集的解法
- [ ] 能用 cache line 解释"为什么顺序遍历更快",并说出伪共享
九、常见误区
| ❌ 误区 | ✅ 正解 |
|---|---|
| "多开线程就更快" | 超过核数后增加的是切换开销;CPU 密集应≈核数 |
| "JS 单线程,所以没有并发问题" | await 之间有让出点,单线程也有竞态(check-then-act、响应乱序) |
| "epoll 是异步 I/O" | 它是同步 I/O 的多路复用(Reactor),拷贝还得自己做 |
| "虚拟内存就是拿磁盘当内存" | 那只是功能之一;隔离与简化链接同样重要 |
| "分页和分段差不多" | 分页定长、物理视角、无外部碎片;分段变长、逻辑视角、有外部碎片 |
| "LRU 是真实实现" | 工程上多用 Clock/二次机会 这类近似算法 |
| "死锁只要加锁小心点就行" | 系统性解法是统一加锁顺序(破坏循环等待) |
| "IO 多路复用就是异步" | 多路复用解决的是"一个线程等很多 fd",异步解决的是"谁来完成拷贝" |
| "零拷贝是一次都不拷" | 是不用 CPU 拷(DMA 仍在工作),且省的是上下文切换 + CPU 拷贝 |
| "共享内存最快所以该用" | 它把复杂度转嫁给你:同步、崩溃一致性、泄漏都得自己扛 |
| "内存不够就加 swap" | 高负载服务开 swap 会抖动,宁可限流/扩容/关 swap |
| "操作系统离前端很远" | 浏览器多进程、事件循环、时间切片、Worker、OPFS——全是这些概念的变体 |
相关文档
- 面试题总览 → I00 总览
- 网络(RTT 分解、排障)→ IN · 网络面试题
- JS 运行时:事件循环、分代 GC、内存泄漏 → A09 · JavaScript 运行时
- 性能度量与长任务 → A25 / 优化清单 → A10
- 线上排障与可观测性 → A12
- 场景设计题(并发/高可用)→ IX · 综合场景题
- 后端视角(未写)→ 见 docs/plan/backend.md