07 - I/O系统¶
⏰ 建议学习时间:2.5 小时 🎯 难度等级:⭐⭐⭐⭐ 📋 前置知识:进程与线程、内存管理基础、计算机组成原理
📋 本章目录¶
I/O硬件原理¶
I/O设备分类¶
I/O设备按数据传输单位可分为两大类:
| 分类 | 特点 | 典型设备 | 数据访问方式 |
|---|---|---|---|
| 块设备(Block Device) | 以固定大小的块为单位传输数据 | 硬盘、SSD、U盘、光驱 | 可随机访问 |
| 字符设备(Character Device) | 以字符(字节流)为单位传输数据 | 键盘、鼠标、打印机、串口 | 顺序访问,不可寻址 |
其他分类维度: - 按使用特性:存储设备(磁盘)、传输设备(网卡)、人机交互设备(显示器) - 按传输速率:低速设备(键盘,几百字节/秒)、中速设备(打印机)、高速设备(磁盘,数百MB/s) - 按共享属性:独占设备(打印机)、共享设备(磁盘)
设备控制器与设备驱动¶
设备控制器(Device Controller):
设备控制器是CPU与I/O设备之间的接口,是一个可编址的硬件设备。主要功能:
- 接收和识别CPU命令:控制器中的控制寄存器用于接收命令
- 数据交换:通过数据寄存器在CPU与设备间传输数据
- 标识和报告设备状态:通过状态寄存器反馈设备状态
- 地址识别:识别其控制的设备的地址
- 数据缓冲:解决CPU与设备的速度差异
- 差错控制:对传输数据进行校验
设备控制器结构:
┌─────────────────────────────────────┐
│ 设备控制器 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 控制寄存器│ │ 状态寄存器│ │
│ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 数据寄存器│ │ 数据缓冲区│ │
│ └──────────┘ └──────────┘ │
│ │ │ │
│ ───────┴───────────┴────── │
│ 与CPU的接口(系统总线) │
└─────────────────────────────────────┘
│
与设备的接口
│
┌─────┴─────┐
│ I/O设备 │
└───────────┘
设备驱动程序(Device Driver):
设备驱动程序是操作系统内核的一部分,是直接与硬件控制器打交道的软件模块。每类设备都有对应的驱动程序。
- 将上层的抽象I/O请求转换为具体的硬件操作命令
- 初始化设备、管理设备电源
- 处理设备中断
- 提供统一接口,屏蔽硬件差异
I/O控制方式¶
四种I/O控制方式对比¶
| 控制方式 | CPU参与程度 | 数据传输单位 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 程序控制I/O(轮询) | 极高(持续占用) | 字/字节 | 实现简单 | CPU利用率极低 | 简单嵌入式系统 |
| 中断驱动I/O | 较高(每次传输参与) | 字/字节 | CPU可做其他工作 | 频繁中断开销大 | 低速设备 |
| DMA | 低(仅开始/结束参与) | 块 | CPU基本不参与传输 | 需要DMA控制器 | 高速块设备 |
| 通道控制 | 极低 | 一组块 | 一次处理多块传输 | 硬件成本高 | 大型机系统 |
1. 程序控制I/O(轮询 / Programmed I/O)¶
原理:CPU发出I/O命令后,不断轮询设备状态寄存器,直到设备就绪,然后进行数据传输。
问题:CPU在等待设备就绪期间,100%被占用,无法执行其他任务(忙等待/Busy Waiting)。
2. 中断驱动I/O(Interrupt-driven I/O)¶
原理:CPU发出I/O命令后,转去做其他工作。当设备完成I/O操作后,通过中断通知CPU。
流程:
CPU发出读命令 → CPU转去执行其他进程
...
设备完成数据准备 → 发出中断请求
↓
CPU响应中断,切换到中断处理程序
↓
从设备控制器读取数据到CPU寄存器
↓
写入内存 → 返回被中断的进程
改进:CPU不再忙等,利用率大幅提高。 不足:数据传输仍需CPU逐字节搬运,高速设备会产生大量中断。
3. DMA(Direct Memory Access,直接内存访问)¶
原理:设置专用的DMA控制器,在I/O设备和内存之间直接传输数据块,无需CPU逐字节参与。
DMA控制器寄存器:
┌─────────────────────────────────┐
│ 命令/状态寄存器(CR/SR) │ ← CPU写入操作命令
│ 内存地址寄存器(MAR) │ ← 数据传输的内存起始地址
│ 数据计数器(DC) │ ← 要传输的字节数
│ 数据缓冲寄存器(DR) │ ← 暂存数据
└─────────────────────────────────┘
流程:
1. CPU设置DMA控制器(源地址、目的地址、传输长度)
2. CPU发出传输命令,然后去做其他工作
3. DMA控制器控制总线,逐块传输数据(设备↔内存)
4. 传输完成后,DMA控制器向CPU发出中断
5. CPU处理中断,完成后续操作
特点:以"块"为单位传输,仅在传输开始和结束时需要CPU参与。
4. 通道控制(Channel Control)¶
原理:通道是独立的I/O处理器,有自己的指令集(通道程序)。CPU只需发出I/O指令,通道自行执行通道程序完成一组数据块传输。
通道类型: - 字节多路通道:连接多个低速设备,以字节为单位交叉工作 - 数组选择通道:连接高速设备,一次只为一个设备服务 - 数组多路通道:结合以上两者优点
I/O软件层次¶
I/O软件通常组织成四个层次,每层有明确的功能和接口:
┌─────────────────────────────────┐
│ 用户层I/O软件 │ ← 提供库函数(printf、scanf、read、write)
├─────────────────────────────────┤
│ 设备无关的操作系统软件 │ ← 统一接口、缓冲管理、错误处理、设备分配
├─────────────────────────────────┤
│ 设备驱动程序 │ ← 将抽象请求转换为具体硬件操作
├─────────────────────────────────┤
│ 中断处理程序 │ ← 处理设备中断,唤醒等待进程
├─────────────────────────────────┤
│ 硬件 │ ← 执行实际I/O操作
└─────────────────────────────────┘
调用方向:自上而下(请求)| 自下而上(中断响应)
各层功能详解:
| 层次 | 主要功能 | 示例 |
|---|---|---|
| 用户层I/O软件 | 提供系统调用接口的封装 | C语言的fread()、fwrite() |
| 设备无关软件 | 统一命名、保护、缓冲、分配、错误报告 | VFS(虚拟文件系统) |
| 设备驱动程序 | 接收上层请求,操作硬件寄存器 | 网卡驱动、磁盘驱动 |
| 中断处理程序 | 保存现场、处理中断、恢复现场 | 磁盘中断处理 |
磁盘调度算法¶
基本概念¶
磁盘访问时间 = 寻道时间 + 旋转延迟 + 传输时间
其中寻道时间最长,磁盘调度算法主要优化寻道时间。
示例请求序列:98, 183, 37, 122, 14, 124, 65, 67 磁头初始位置:53 磁盘范围:0-199
FCFS(先来先服务)¶
原理:按请求到达的先后顺序进行服务。
移动序列:53 → 98 → 183 → 37 → 122 → 14 → 124 → 65 → 67
寻道距离:|53-98| + |98-183| + |183-37| + |37-122| + |122-14| + |14-124| + |124-65| + |65-67|
= 45 + 85 + 146 + 85 + 108 + 110 + 59 + 2
= 640 磁道
- 优点:简单公平
- 缺点:寻道距离大,效率低
SSTF(最短寻道时间优先)¶
原理:每次选择离当前磁头位置最近的请求。
移动序列:53 → 65 → 67 → 37 → 14 → 98 → 122 → 124 → 183
寻道距离:12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 = 236 磁道
- 优点:平均寻道时间短
- 缺点:可能导致饥饿(远处请求长期得不到服务)
SCAN(电梯算法)¶
原理:磁头沿一个方向移动,服务沿途请求,到达边界后反方向移动。
假设初始向右移动:
移动序列:53 → 65 → 67 → 98 → 122 → 124 → 183 → (199) → 37 → 14
寻道距离:12 + 2 + 31 + 24 + 2 + 59 + 16 + 162 + 23 = 331 磁道
(到达最右端199后反向)
简化计算(如果不需要到达边界):
53 → 65 → 67 → 98 → 122 → 124 → 183 → 37 → 14
- 优点:避免饥饿
- 缺点:刚扫过的区域等待时间最长
C-SCAN(循环扫描)¶
原理:磁头只在一个方向上提供服务,到达末端后立即返回起点(不服务),再沿同方向移动。
假设向右移动:
移动序列:53 → 65 → 67 → 98 → 122 → 124 → 183 → (199) → (0) → 14 → 37
寻道距离:12 + 2 + 31 + 24 + 2 + 59 + 16 + 199 + 14 + 23 = 382 磁道
- 优点:等待时间更均匀
- 缺点:总寻道距离可能更大
LOOK / C-LOOK¶
原理:LOOK是SCAN的改进,不需要到达磁盘边界;C-LOOK是C-SCAN的改进。
C-LOOK示例(向右):
移动序列:53 → 65 → 67 → 98 → 122 → 124 → 183 → 14 → 37
寻道距离:12 + 2 + 31 + 24 + 2 + 59 + 169 + 23 = 322 磁道
(到达最右请求183后直接跳到最左请求14)
磁盘调度算法对比¶
| 算法 | 寻道距离(示例) | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| FCFS | 640 | 简单公平 | 效率低 | 请求较少时 |
| SSTF | 236 | 效率高 | 可能饥饿 | 交互式系统 |
| SCAN | 331 | 无饥饿 | 中间更有利 | 大负载系统 |
| C-SCAN | 382 | 等待均匀 | 距离较大 | 大负载系统 |
| C-LOOK | 322 | 均匀且高效 | 实现稍复杂 | 实际系统常用 |
代码实现¶
# LOOK磁盘调度算法示例(不到达磁盘边界的SCAN变体)
# 注意:LOOK只访问最远请求位置即折返,不像SCAN需要到达磁盘边界
def look(requests, head, direction='right', disk_size=200):
seek_sequence = []
left = sorted([r for r in requests if r < head])
right = sorted([r for r in requests if r >= head])
if direction == 'right':
seek_sequence = right + left[::-1] # 切片操作:[start:end:step]提取子序列
else:
seek_sequence = left[::-1] + right
total_seek = 0
current = head
for pos in seek_sequence:
total_seek += abs(pos - current)
current = pos
return seek_sequence, total_seek
# 测试
requests = [98, 183, 37, 122, 14, 124, 65, 67]
head = 53
sequence, total = look(requests, head, 'right')
print(f"LOOK调度序列: {sequence}")
print(f"总寻道距离: {total}")
# SSTF算法实现
def sstf(requests, head):
remaining = list(requests)
seek_sequence = []
current = head
total_seek = 0
while remaining:
# 找最近的请求
nearest = min(remaining, key=lambda x: abs(x - current)) # lambda匿名函数:简洁的单行函数
seek_sequence.append(nearest)
total_seek += abs(nearest - current)
current = nearest
remaining.remove(nearest)
return seek_sequence, total_seek
sequence, total = sstf(requests, head)
print(f"\nSSTF调度序列: {sequence}")
print(f"总寻道距离: {total}")
RAID技术¶
RAID(Redundant Array of Independent Disks,独立磁盘冗余阵列)¶
RAID通过将多块磁盘组合使用,实现数据冗余和/或性能提升。
RAID级别结构¶
RAID 0(条带化):
RAID 1(镜像):
RAID 5(分布式奇偶校验):
数据块分布:
磁盘0: [A1] [B1] [C1] [Dp]
磁盘1: [A2] [B2] [Cp] [D1]
磁盘2: [A3] [Bp] [C2] [D2]
磁盘3: [Ap] [B3] [C3] [D3]
特点: 校验数据分布在所有磁盘上(p = parity)
RAID 6(双重奇偶校验):
RAID 10(RAID 1+0):
RAID特性对比¶
| 特性 | RAID 0 | RAID 1 | RAID 5 | RAID 6 | RAID 10 |
|---|---|---|---|---|---|
| 最少磁盘数 | 2 | 2 | 3 | 4 | 4 |
| 容量利用率 | 100% | 50% | (n-1)/n | (n-2)/n | 50% |
| 读性能 | 极好 | 好 | 好 | 好 | 极好 |
| 写性能 | 极好 | 一般 | 一般(写惩罚) | 较差 | 好 |
| 容错能力 | 无 | 1块磁盘 | 1块磁盘 | 2块磁盘 | 每组1块 |
| 可靠性 | 最低 | 高 | 较高 | 很高 | 很高 |
| 适用场景 | 临时数据/高性能 | 关键数据 | 兼顾性能和安全 | 高可用性 | 数据库/关键应用 |
缓冲技术¶
缓冲区用于缓和CPU与I/O设备之间速度不匹配的矛盾。
设 T = 设备传输一块数据到缓冲区的时间,M = 将缓冲区数据传送到用户区的时间,C = CPU处理一块数据的时间。
单缓冲¶
处理每块数据的时间:max(T, C) + M
因为设备输入和CPU计算可以并行,但传入用户区需要独占缓冲区。
双缓冲¶
处理每块数据的时间:max(T, C+M)
两个缓冲区交替使用,设备传输和CPU处理可以更充分地并行。
缓冲池(Buffer Pool)¶
由多个缓冲区组成的公共缓冲池,按使用状态分为: - 空缓冲队列(emq) - 装满输入数据的缓冲队列(inq) - 装满输出数据的缓冲队列(outq)
多个进程可共享缓冲池,提高缓冲区利用率和系统吞吐量。
Linux I/O模型(重点)¶
Linux下有5种I/O模型,它们的区别在于等待数据准备和数据从内核拷贝到用户空间两个阶段的处理方式不同。
1. 阻塞I/O(Blocking I/O)¶
应用进程 内核
│ │
│─── recvfrom() ────────→│
│ (阻塞) │ 等待数据准备...
│ │ 数据准备好
│ │ 将数据从内核拷贝到用户空间
│←── 返回数据 ───────────│
│ │
特点:两个阶段(等待数据 + 拷贝数据)都阻塞。这是最常见、最简单的模型。 缺点:进程在I/O期间完全阻塞,无法处理其他任务。
2. 非阻塞I/O(Non-blocking I/O)¶
应用进程 内核
│ │
│─── recvfrom() ────────→│ 数据未准备好
│←── EWOULDBLOCK ───────│
│ │
│─── recvfrom() ────────→│ 数据未准备好
│←── EWOULDBLOCK ───────│
│ (轮询) │
│─── recvfrom() ────────→│ 数据准备好
│ (阻塞) │ 将数据从内核拷贝到用户空间
│←── 返回数据 ───────────│
│ │
特点:第一阶段不阻塞(轮询),第二阶段仍阻塞。 缺点:CPU轮询浪费资源。
3. I/O多路复用(I/O Multiplexing)¶
应用进程 内核
│ │
│── select/poll/epoll ──→│ 监视多个fd
│ (阻塞在select) │ 等待任一fd就绪...
│←── 返回就绪的fd ───────│
│ │
│─── recvfrom() ────────→│ 数据准备好
│ (阻塞) │ 将数据从内核拷贝到用户空间
│←── 返回数据 ───────────│
特点:一个进程可以同时监视多个文件描述符。是高并发网络编程的核心技术。 核心优势:单线程处理多连接。
4. 信号驱动I/O(Signal-driven I/O)¶
应用进程 内核
│ │
│── sigaction(SIGIO) ───→│ 注册信号处理函数
│←── 返回 ───────────────│
│ (不阻塞,做其他事) │ 等待数据准备...
│ │ 数据准备好
│←── SIGIO信号 ──────────│
│ │
│─── recvfrom() ────────→│ 将数据从内核拷贝到用户空间
│←── 返回数据 ───────────│
特点:第一阶段通过信号通知,不阻塞也不轮询。 缺点:信号处理复杂,实际使用较少。
5. 异步I/O(Asynchronous I/O / AIO)¶
应用进程 内核
│ │
│── aio_read() ─────────→│ 注册异步读请求
│←── 立即返回 ───────────│
│ (不阻塞,做其他事) │ 等待数据准备...
│ │ 数据准备好
│ │ 将数据从内核拷贝到用户空间
│←── 信号/回调通知 ──────│ 两个阶段全部完成
│ │
特点:两个阶段都不阻塞,是真正的异步I/O。 实现:Linux的io_uring(5.1+内核)提供了高性能异步I/O支持。
5种I/O模型对比¶
| 模型 | 等待数据阶段 | 拷贝数据阶段 | 是否真正异步 | 复杂度 |
|---|---|---|---|---|
| 阻塞I/O | 阻塞 | 阻塞 | 否 | 低 |
| 非阻塞I/O | 非阻塞(轮询) | 阻塞 | 否 | 较低 |
| I/O多路复用 | 阻塞在select | 阻塞 | 否 | 中 |
| 信号驱动I/O | 非阻塞(信号) | 阻塞 | 否 | 较高 |
| 异步I/O | 非阻塞 | 非阻塞 | 是 | 高 |
前4种本质上都是同步I/O,因为数据从内核拷贝到用户空间的第二阶段都需要进程参与(阻塞)。只有异步I/O是真正的异步。
select/poll/epoll详细对比¶
这三者都是I/O多路复用的实现,但性能和使用方式差异显著。
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(FD_SETSIZE) | 无上限(链表) | 无上限(红黑树) |
| 效率 | O(n),每次全量遍历 | O(n),每次全量遍历 | O(1),基于回调 |
| 底层数据结构 | bitmap | 链表 | 红黑树 + 就绪链表 |
| fd拷贝 | 每次调用需全量拷贝fd集合 | 每次调用需全量拷贝 | fd只需拷贝一次(epoll_ctl) |
| 触发模式 | 水平触发(LT) | 水平触发(LT) | 支持LT和ET |
| 内核实现 | 轮询遍历 | 轮询遍历 | 事件驱动回调 |
| 适用场景 | 跨平台、连接数少 | 跨平台、连接数中等 | Linux高并发 |
epoll的三个核心API¶
// 1. 创建epoll实例,返回epoll文件描述符
int epoll_create(int size); // size: 期望监听的fd数量(提示值)
int epoll_create1(int flags); // 推荐使用,flags可设为EPOLL_CLOEXEC
// 2. 添加/修改/删除监听的fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // struct结构体:自定义复合数据类型
// epfd: epoll_create返回的fd
// op: EPOLL_CTL_ADD(添加) / EPOLL_CTL_MOD(修改) / EPOLL_CTL_DEL(删除)
// fd: 要监听的文件描述符
// event: 监听的事件类型(EPOLLIN/EPOLLOUT/EPOLLET等)
// 3. 等待事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// events: 输出参数,就绪事件数组
// maxevents: events数组大小
// timeout: 超时时间(-1永久等待,0立即返回)
// 返回值: 就绪的fd数量
水平触发(LT)vs 边缘触发(ET)¶
| 特性 | 水平触发(Level Triggered) | 边缘触发(Edge Triggered) |
|---|---|---|
| 通知条件 | 只要fd就绪就持续通知 | 仅在fd状态变化时通知一次 |
| 读取要求 | 可以不一次读完 | 必须一次读完(循环读到EAGAIN) |
| 编程难度 | 简单,select/poll默认 | 较难,需配合非阻塞I/O |
| 性能 | 可能重复通知 | 更高效,减少系统调用 |
| 使用建议 | 通用场景 | 高性能服务器(Nginx使用ET) |
// LT模式使用(默认)
event.events = EPOLLIN;
// ET模式使用(需要明确设置)
event.events = EPOLLIN | EPOLLET;
// 必须配合非阻塞fd和循环读取:
while (1) {
n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) break; // 读完了
// 处理数据...
}
零拷贝技术¶
传统I/O流程(read + write)¶
用户进程 内核空间 硬件
│ │ │
│── read() ───→│ │
│ (上下文切换1) │ │
│ │←─ DMA拷贝 ─│ 磁盘→内核缓冲区(拷贝1)
│ │ │
│←─ CPU拷贝 ──│ │ 内核缓冲区→用户缓冲区(拷贝2)
│ (上下文切换2) │ │
│ │ │
│── write() ──→│ │
│ (上下文切换3) │ │
│── CPU拷贝 ──→│ │ 用户缓冲区→Socket缓冲区(拷贝3)
│ │── DMA拷贝 ─→│ Socket缓冲区→网卡(拷贝4)
│←─ 返回 ──────│ │
│ (上下文切换4) │ │
总计: 4次拷贝 + 4次上下文切换
mmap + write(内存映射)¶
用户进程 内核空间 硬件
│ │ │
│── mmap() ───→│ │ 将内核缓冲区映射到用户空间
│ │←─ DMA拷贝 ─│ 磁盘→内核缓冲区(拷贝1)
│ │ │
│── write() ──→│ │
│── CPU拷贝 ──→│ │ 内核缓冲区→Socket缓冲区(拷贝2)
│ │── DMA拷贝 ─→│ Socket缓冲区→网卡(拷贝3)
总计: 3次拷贝 + 4次上下文切换
优化: 减少了1次CPU拷贝(用户缓冲区到内核缓冲区)
sendfile¶
用户进程 内核空间 硬件
│ │ │
│─ sendfile() ─→│ │
│ (上下文切换1) │ │
│ │←─ DMA拷贝 ─│ 磁盘→内核缓冲区(拷贝1)
│ │ │
│ │── CPU拷贝 ─→│ 内核缓冲区→Socket缓冲区(拷贝2)
│ │── DMA拷贝 ─→│ Socket缓冲区→网卡(拷贝3)
│←─ 返回 ──────│ │
│ (上下文切换2) │ │
总计: 3次拷贝(1次CPU + 2次DMA)+ 2次上下文切换
优化: 数据不经过用户空间
sendfile + DMA gather copy(最优方案)¶
用户进程 内核空间 硬件
│ │ │
│─ sendfile() ─→│ │
│ │←─ DMA拷贝 ─│ 磁盘→内核缓冲区(拷贝1)
│ │ │
│ │ (只拷贝fd、offset、length等描述符到Socket缓冲区)
│ │ │
│ │── DMA gather─→│ 直接从内核缓冲区→网卡(拷贝2)
│←─ 返回 ──────│ │
总计: 2次DMA拷贝 + 2次上下文切换(零CPU拷贝!)
要求: 需要硬件支持DMA gather(聚集)操作
零拷贝技术对比¶
| 方案 | CPU拷贝次数 | DMA拷贝次数 | 上下文切换 | 备注 |
|---|---|---|---|---|
| read + write | 2 | 2 | 4 | 传统方式 |
| mmap + write | 1 | 2 | 4 | 减少1次CPU拷贝 |
| sendfile | 1 | 2 | 2 | 减少上下文切换 |
| sendfile + gather | 0 | 2 | 2 | 最优,需硬件支持 |
| splice | 0 | 2 | 2 | Linux 2.6.17+,管道中转 |
Java NIO中的零拷贝¶
// 1. FileChannel.transferTo() —— 对应Linux sendfile
FileChannel sourceChannel = new FileInputStream("source.txt").getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("host", 8080));
// 将文件直接传输到Socket,不经过用户空间
sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);
// 2. MappedByteBuffer —— 对应mmap
FileChannel channel = new RandomAccessFile("data.bin", "rw").getChannel();
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
// 直接在映射的内存区域读写,无需read/write系统调用
buffer.put(0, (byte) 'H');
byte b = buffer.get(0);
应用场景: - Kafka:使用sendfile将消息从磁盘直接传输到网络 - Nginx:使用sendfile发送静态文件 - RocketMQ:使用mmap进行消息读写
面试高频题¶
1. 什么是DMA?它如何工作?¶
答:DMA(Direct Memory Access,直接内存访问)允许外设直接与内存进行数据传输,无需CPU逐字节搬运。工作流程:CPU设置DMA控制器的源地址、目的地址和传输长度,启动传输后CPU转去做其他工作,DMA控制器接管总线完成数据传输,传输完成后向CPU发出中断。DMA减轻了CPU负担,特别适合大块数据传输。
2. select、poll、epoll的区别?¶
答:三者都是I/O多路复用机制。select有1024个fd限制,每次调用需重新拷贝fd集合到内核,内核线性遍历fd,时间复杂度O(n)。poll使用链表存储fd,无数量限制,但仍是O(n)遍历。epoll使用红黑树管理fd(仅需注册一次),就绪事件通过回调加入就绪链表,epoll_wait只返回就绪fd,时间复杂度O(1)。epoll还支持边缘触发模式,性能更优。在高并发场景下epoll远优于select/poll。
3. 阻塞I/O和非阻塞I/O的区别?¶
答:阻塞I/O在数据未准备好时,进程会被挂起进入睡眠状态,直到数据准备好并拷贝完成才返回。非阻塞I/O在数据未准备好时会立即返回错误(EWOULDBLOCK),不会挂起进程,但需要不断轮询检查。两者在数据拷贝阶段都是阻塞的,所以都属于同步I/O。
4. 什么是零拷贝?有哪些实现方式?¶
答:零拷贝指在数据传输过程中减少或消除CPU参与的数据拷贝次数。传统read+write有4次拷贝。实现方式包括:mmap+write(3次拷贝,用户虚拟地址映射到内核缓冲区),sendfile(3次拷贝,数据不经过用户空间),sendfile+DMA gather(2次DMA拷贝,零CPU拷贝)。典型应用如Kafka使用sendfile快速传输消息。
5. epoll的水平触发和边缘触发有什么区别?¶
答:水平触发(LT)在fd可读/可写时会持续通知,即使未处理完也会再次通知;边缘触发(ET)只在fd状态从不可读变为可读时通知一次。ET要求一次要将数据读完(循环read直到返回EAGAIN),必须使用非阻塞fd。ET减少了epoll_wait返回次数,性能更高,但编程更复杂。Nginx使用ET模式。
6. RAID 5的工作原理是什么?¶
答:RAID 5使用3块以上磁盘,数据按条带分布在各磁盘上,校验信息(Parity)也分布在所有磁盘上(而非集中在一块盘)。任何一块磁盘故障时,可通过其他磁盘的数据和校验值重建丢失数据。容量利用率为(n-1)/n,兼顾了性能、容量和容错。写入需要计算校验值,存在写惩罚(小写需额外读-改-写)。
7. 什么是I/O多路复用?为什么需要它?¶
答:I/O多路复用允许单个进程/线程同时监听多个文件描述符的I/O事件。在网络编程中,传统做法是为每个连接创建一个线程,连接数多时线程开销巨大。I/O多路复用通过select/poll/epoll等系统调用,让一个线程就能处理成千上万的并发连接,是C10K问题的关键解决方案。
8. 磁盘调度算法SCAN和C-SCAN的区别?¶
答:SCAN(电梯算法)磁头沿一个方向移动服务请求,到达末端后反向移动继续服务。C-SCAN(循环扫描)磁头只在一个方向上服务,到达末端后立即返回起点(返回时不服务),保证各位置等待时间更均匀。SCAN中刚被扫过方向的请求等待时间较长,C-SCAN解决了这个问题。
9. Linux的5种I/O模型分别是什么?¶
答:(1)阻塞I/O:最常见,进程完全阻塞直到I/O完成;(2)非阻塞I/O:轮询检查,数据未就绪立即返回;(3)I/O多路复用:select/poll/epoll监控多个fd;(4)信号驱动I/O:注册SIGIO信号,数据就绪时信号通知;(5)异步I/O:发起请求后立即返回,两个阶段都不阻塞。前四种本质上都是同步I/O,只有第五种是真正异步的。
10. 为什么Kafka性能那么高?和I/O有什么关系?¶
答:Kafka高性能与多种I/O优化密切相关:①使用sendfile零拷贝将消息从磁盘直接发送到网络,避免数据在用户态和内核态之间拷贝;②顺序读写磁盘,充分利用操作系统页缓存(Page Cache),减少磁盘寻道;③使用mmap进行索引文件读取;④批量发送和压缩减少I/O次数;⑤利用操作系统的预读(Read-ahead)和延迟写(Write-behind)机制。
练习题¶
1. 假设磁盘请求队列为 55, 58, 39, 18, 90, 160, 150, 38, 184,磁头初始位置50,请分别使用FCFS、SSTF和SCAN(初始向右)算法计算总寻道距离。
2. 解释为什么epoll在高并发场景下性能远优于select,从底层数据结构、fd拷贝、遍历方式三个维度分析。
3. 画出使用sendfile进行文件传输的完整流程图,标注每次拷贝和上下文切换的位置。
4. 某系统使用双缓冲,设备输入一块数据时间T=100μs,数据从缓冲区传送到用户区时间M=10μs,CPU处理一块数据时间C=50μs,处理10块数据需要多长时间?
5. 比较RAID 5和RAID 10,在写密集型负载、读密集型负载、容错能力三个维度分析,哪种更适合数据库应用?
延伸阅读¶
- 《UNIX网络编程 卷1》—— 第6章 I/O多路复用
- 《Linux高性能服务器编程》—— I/O复用与零拷贝
- 《深入理解Linux内核》—— I/O体系结构与设备驱动
- Linux man page:
man 7 epoll、man 2 sendfile - The C10K Problem
- Linux io_uring 介绍