操作系统核心原理 — I/O 与文件系统
用生活化的比喻,让你理解五种 I/O 模型、epoll、inode、日志文件系统——这些是理解 Node.js/Netty 事件循环的底层机制
前置知识:第03章 进程与线程 + 第04章 内存管理(理解进程和虚拟内存)
阅读指南(初学者必看)
为什么你需要学习 I/O 与文件系统?
你每天都在用 I/O 模型——Node.js 的事件循环基于 epoll,Netty 基于 I/O 多路复用,但你不理解底层机制:
- Node.js 怎么做到"单线程处理万级并发"?epoll 是什么?
- 阻塞 I/O 和非阻塞 I/O 的区别是什么?
- 为什么游戏服务器用 epoll 而不是 select?
学完本章,你能回答:
- 五种 I/O 模型的区别是什么?为什么 I/O 多路复用最重要?
- select、poll、epoll 的区别是什么?为什么 epoll 性能最好?
- 文件系统的 inode 是什么?日志文件系统为什么能防断电损坏?
本文结构
第一部分:五种 I/O 模型(理解 I/O 的本质)
第二部分:多路复用详解(select/poll/epoll)
第三部分:异步 I/O 与完成端口
第四部分:文件系统原理(理解文件是怎么存储的)
第五部分:缓存与优化
5.1 五种 I/O 模型
生活类比:去餐厅点餐。
1. 阻塞 I/O(Blocking I/O)
点餐后站在柜台等 → 啥也不干直到餐好了
→ 简单但浪费等待时间
2. 非阻塞 I/O(Non-blocking I/O)
点餐后不断问"好了吗?" → 反复轮询
→ 不阻塞但浪费 CPU
3. I/O 多路复用(I/O Multiplexing)⭐ 最重要
点餐后拿个叫号器,同时可以干别的 → 叫号器响了再去取
→ select/poll/epoll,一个线程管理多个连接
→ Netty、Node.js、Nginx 都用这种方式
4. 信号驱动 I/O(Signal-driven I/O)
点餐后留电话 → 餐好了打电话通知
→ 内核发信号通知,适合 UDP
5. 异步 I/O(Asynchronous I/O)
点外卖 → 骑手直接送到家 → 你全程不用管
→ 真正的异步,Windows IOCP 支持,Linux 不完善
完整的 I/O 操作分两步
1. 等待数据准备好(从磁盘/网络读取到内核缓冲区)
2. 将数据从内核拷贝到用户空间
不同 I/O 模型的区别主要在这两步的处理方式上
5.2 I/O 多路复用详解
select
class SelectModel {
constructor(maxFds = 1024) {
this.maxFds = maxFds;
this.readFds = new Set();
this.writeFds = new Set();
this.callbacks = new Map();
}
addReadFd(fd, callback) {
this.readFds.add(fd);
this.callbacks.set(`read_${fd}`, callback);
}
select(timeout = null) {
let readyRead = [];
for (let fd of this.readFds) {
if (this.isReady(fd, 'read')) {
readyRead.push(fd);
}
}
return { read: readyRead };
}
}
poll
class PollModel {
constructor() {
this.fds = new Map();
}
add(fd, events, callback) {
this.fds.set(fd, { events, callback, revents: 0 });
}
poll(timeout = 0) {
let ready = [];
for (let [fd, info] of this.fds) {
if (this.checkRead(fd)) {
ready.push({ fd, callback: info.callback });
}
}
return ready;
}
}
epoll(Linux 最高效的 I/O 多路复用)
// epoll(Linux 最高效的 I/O 多路复用)
// 1. 创建 epoll 实例
int epfd = epoll_create1(0);
// 2. 注册感兴趣的 fd
struct epoll_event ev;
ev.events = EPOLLIN; // 关注可读事件
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
// 3. 等待事件
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epfd, events, MAX_EVENTS, timeout);
// 4. 处理就绪的 fd
for (int i = 0; i < n; i++) {
if (events[i].data.fd == server_fd) {
// 新连接
int client_fd = accept(server_fd, ...);
// 注册 client_fd
} else {
// 已有连接的数据
read(events[i].data.fd, buffer, ...);
}
}
select vs poll vs epoll:
| | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024 | 无限制 | 无限制 |
| 性能 | O(n) | O(n) | O(1) |
| 每次调用 | 传入所有 fd | 传入所有 fd | 只返回就绪的 fd |
| 适用场景 | 少量连接 | 中等连接 | 大量连接(游戏服务器)|
游戏服务器为什么用 epoll?
- 10万连接,select 每次要遍历10万个fd
- epoll 只返回就绪的fd,不遍历
- Netty 底层在 Linux 上用 epoll
epoll 的 O(1) 是怎么实现的? epoll 用红黑树管理所有注册的 fd,用双向链表管理就绪的 fd。当 fd 有事件时,内核通过回调函数把 fd 加入就绪链表。epoll_wait 只需返回就绪链表中的 fd,不需要遍历所有 fd。所以性能和连接数无关,只和活跃连接数有关。
5.3 异步 I/O 与完成端口
Proactor 模式
class Proactor {
constructor() {
this.operations = new Map();
this.opId = 0;
}
asyncRead(fd, buffer, callback) {
let opId = this.opId++;
this.operations.set(opId, {
type: 'read', fd, buffer, callback, status: 'pending'
});
this.simulateAsyncRead(opId);
return opId;
}
simulateAsyncRead(opId) {
setTimeout(() => {
let op = this.operations.get(opId);
if (op) {
op.status = 'completed';
op.bytesRead = Math.floor(Math.random() * 1024) + 1;
op.callback(op);
this.operations.delete(opId);
}
}, Math.random() * 500);
}
}
完成端口(Windows IOCP)
class CompletionPort {
constructor(numWorkers = 4) {
this.workers = [];
this.taskQueue = [];
this.completionQueue = [];
this.running = true;
for (let i = 0; i < numWorkers; i++) {
this.workers.push(this.createWorker(i));
}
}
postTask(task) {
return new Promise((resolve, reject) => {
this.taskQueue.push({ ...task, resolve, reject });
this.dispatchTasks();
});
}
dispatchTasks() {
while (this.taskQueue.length > 0) {
let worker = this.workers.find(w => !w.busy);
if (!worker) break;
let task = this.taskQueue.shift();
worker.busy = true;
this.executeTask(worker, task);
}
}
async executeTask(worker, task) {
try {
let result = await task.execute();
task.resolve(result);
} catch (error) {
task.reject(error);
} finally {
worker.busy = false;
this.dispatchTasks();
}
}
}
5.4 文件系统原理
inode(索引节点)── 文件的"身份证"
├── 文件大小
├── 所有者(uid/gid)
├── 权限(rwx)
├── 时间戳(创建/修改/访问)
├── 数据块位置指针(直接 + 间接)
└── 链接计数
目录 = inode 号 → 文件名的映射表
读取文件的流程:
文件名 → 查目录项 → 得到 inode 号 → 读 inode → 找到数据块 → 读取数据
文件分配方式
// 连续分配
class ContiguousAllocation {
constructor(totalBlocks) {
this.disk = new Array(totalBlocks).fill(null);
this.files = new Map();
}
createFile(name, size) {
let startBlock = this.findFreeBlocks(size);
if (startBlock === -1) throw new Error('Not enough space');
this.files.set(name, { name, startBlock, size });
for (let i = startBlock; i < startBlock + size; i++) {
this.disk[i] = name;
}
return startBlock;
}
}
// 索引分配(现代文件系统常用)
class IndexedAllocation {
constructor(totalBlocks, blockSize = 1024) {
this.disk = new Array(totalBlocks).fill(null);
this.files = new Map();
this.freeBlocks = new Set();
for (let i = 0; i < totalBlocks; i++) this.freeBlocks.add(i);
}
createFile(name, data) {
let numBlocks = Math.ceil(data.length / this.blockSize);
let blocks = this.allocateBlocks(numBlocks);
this.files.set(name, { name, indexBlock: blocks, size: data.length });
return blocks;
}
}
日志文件系统
为什么需要日志?
- 突然断电可能导致文件系统不一致
- 比如删除文件:1) 删除目录项 2) 释放 inode 3) 释放数据块
- 如果在步骤2断电,inode 被删但数据块没释放 → 空间泄漏
日志文件系统:
- 先把要做的操作写入日志(Write-Ahead Log)
- 然后执行实际操作
- 崩溃后重放日志恢复一致性
→ 这和数据库的 WAL(Write-Ahead Log)原理完全一样!
→ MySQL 的 InnoDB redo log 也是这个原理
5.5 缓存与优化
文件缓存
class FileCache {
constructor(maxSize = 100 * 1024 * 1024) {
this.cache = new Map();
this.maxSize = maxSize;
this.currentSize = 0;
this.lruList = [];
}
get(path) {
if (!this.cache.has(path)) return null;
this.updateLRU(path);
return this.cache.get(path).data;
}
set(path, data) {
let size = this.getSize(data);
while (this.currentSize + size > this.maxSize && this.lruList.length > 0) {
this.evict();
}
if (this.cache.has(path)) {
this.currentSize -= this.cache.get(path).size;
}
this.cache.set(path, { data, size, timestamp: Date.now() });
this.currentSize += size;
this.updateLRU(path);
}
updateLRU(path) {
let index = this.lruList.indexOf(path);
if (index !== -1) this.lruList.splice(index, 1);
this.lruList.push(path);
}
evict() {
let oldest = this.lruList.shift();
if (oldest && this.cache.has(oldest)) {
this.currentSize -= this.cache.get(oldest).size;
this.cache.delete(oldest);
}
}
}
mmap
Q:mmap 是什么?为什么能提升 I/O 性能? A:mmap 把文件映射到进程的虚拟地址空间,读写内存 = 读写文件。优势:1)避免 read/write 的内核空间↔用户空间拷贝(零拷贝);2)由操作系统的页面置换管理文件缓存,不需要应用层自己管理。游戏常用 mmap 加载大文件(如资源包、地图数据)。
自问自答
Q:为什么 I/O 多路复用比阻塞 I/O 高效? A:阻塞 I/O 的问题在于"一个线程只能等一个 I/O"——如果有 1 万个连接,就需要 1 万个线程,每个线程占 8MB 栈 = 80GB 内存!I/O 多路复用一个线程就能管理所有连接,epoll_wait 只返回就绪的 fd,不需要遍历。核心区别:阻塞 I/O 是"一客一服务员",I/O 多路复用是"一个服务员管所有桌"。
Q:为什么 Linux 的异步 I/O(AIO)不完善? A:Linux 的 AIO 只支持 O_DIRECT 模式(绕过页缓存),普通文件 I/O 不支持。而且 AIO 的 API 设计不够优雅(需要用 io_submit/io_getevents)。Windows 的 IOCP 是真正的完成端口模型,更成熟。所以 Linux 上通常用 epoll + 非阻塞 I/O 模拟异步。
Q:日志文件系统和数据库的 WAL 有什么关系? A:原理完全一样!都是"先写日志,再执行操作,崩溃后重放日志恢复"。ext4/XFS 的日志保护文件系统一致性,MySQL InnoDB 的 redo log 保护数据库事务的持久性。本质上都是 Write-Ahead Log 思想。
实践任务
- 任务1:用
strace跟踪一个 Node.js 程序的系统调用,观察 epoll_wait 的调用模式 - 任务2:编写一个简单的 epoll 服务器,处理多客户端连接
- 任务3:对比阻塞 I/O 和 epoll 的并发性能(用 ab 或 wrk 压测)
- 任务4:用
iostat观察 I/O 性能,理解 IOPS 和吞吐量的关系 - 任务5:用
stat命令查看文件的 inode 信息,理解 inode 的结构
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| I/O 多路复用 | 第03章 进程与线程 | 事件循环基于 I/O 多路复用,避免多线程 |
| 内存映射文件 | 第04章 内存管理 | mmap 基于分页机制,页面置换管理文件缓存 |
| epoll | 第06章 TCP/IP | epoll 管理 TCP 连接的读写事件 |
| 日志文件系统 | 3_1_java-backend-deep-dive | WAL 在数据库和文件系统中的原理相同 |
上一章:04-操作系统内存管理 | 下一章:06-TCPIP协议栈深度