跳转至

07 - I/O 系统

I/O 系统分层与数据流示意图

建议学习时间: 2.5 小时 🎯 难度等级:⭐⭐⭐⭐ 📋 前置知识:进程与线程、内存管理基础、计算机组成原理


📋 本章目录


I/O 硬件原理

I/O 设备分类

I/O 设备按数据传输单位可分为两大类:

分类 特点 典型设备 数据访问方式
块设备( Block Device ) 以固定大小的块为单位传输数据 硬盘、 SSD 、 U 盘、光驱 可随机访问
字符设备( Character Device ) 以字符(字节流)为单位传输数据 键盘、鼠标、打印机、串口 顺序访问,不可寻址

其他分类维度: - 按使用特性:存储设备(磁盘)、传输设备(网卡)、人机交互设备(显示器) - 按传输速率:低速设备(键盘,几百字节/秒)、中速设备(打印机)、高速设备(磁盘,数百 MB/s ) - 按共享属性:独占设备(打印机)、共享设备(磁盘)

设备控制器与设备驱动

设备控制器( Device Controller ):

设备控制器是 CPU 与 I/O 设备之间的接口,是一个可编址的硬件设备。主要功能:

  1. 接收和识别 CPU 命令:控制器中的控制寄存器用于接收命令
  2. 数据交换:通过数据寄存器在 CPU 与设备间传输数据
  3. 标识和报告设备状态:通过状态寄存器反馈设备状态
  4. 地址识别:识别其控制的设备的地址
  5. 数据缓冲:解决 CPU 与设备的速度差异
  6. 差错控制:对传输数据进行校验
Text Only
设备控制器结构:
┌─────────────────────────────────────┐
│          设备控制器                   │
│  ┌──────────┐  ┌──────────┐         │
│  │ 控制寄存器│  │ 状态寄存器│         │
│  └──────────┘  └──────────┘         │
│  ┌──────────┐  ┌──────────┐         │
│  │ 数据寄存器│  │ 数据缓冲区│         │
│  └──────────┘  └──────────┘         │
│           │           │              │
│    ───────┴───────────┴──────       │
│       与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 命令后,不断轮询设备状态寄存器,直到设备就绪,然后进行数据传输。

Text Only
流程:
CPU发出读命令 → 读状态寄存器 → 设备忙?
                    ↑              │
                    │  是           │ 否
                    └──────────────┘
                              读取数据到CPU寄存器
                              写入内存

问题: CPU 在等待设备就绪期间, 100%被占用,无法执行其他任务(忙等待/Busy Waiting )。

2. 中断驱动 I/O ( Interrupt-driven I/O )

原理: CPU 发出 I/O 命令后,转去做其他工作。当设备完成 I/O 操作后,通过中断通知 CPU 。

Text Only
流程:
CPU发出读命令 → CPU转去执行其他进程
                         ...
设备完成数据准备 → 发出中断请求
               CPU响应中断,切换到中断处理程序
               从设备控制器读取数据到CPU寄存器
               写入内存 → 返回被中断的进程

改进: CPU 不再忙等,利用率大幅提高。 不足:数据传输仍需 CPU 逐字节搬运,高速设备会产生大量中断。

3. DMA ( Direct Memory Access ,直接内存访问)

原理:设置专用的 DMA 控制器,在 I/O 设备和内存之间直接传输数据块,无需 CPU 逐字节参与。

Text Only
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 软件通常组织成四个层次,每层有明确的功能和接口:

Text Only
┌─────────────────────────────────┐
│       用户层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 (先来先服务)

原理:按请求到达的先后顺序进行服务。

Text Only
移动序列: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 (最短寻道时间优先)

原理:每次选择离当前磁头位置最近的请求。

Text Only
移动序列:53 → 65 → 67 → 37 → 14 → 98 → 122 → 124 → 183
寻道距离:12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 = 236 磁道
  • 优点:平均寻道时间短
  • 缺点:可能导致饥饿(远处请求长期得不到服务)

SCAN (电梯算法)

原理:磁头沿一个方向移动,服务沿途请求,到达边界后反方向移动。

Text Only
假设初始向右移动:
移动序列: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 (循环扫描)

原理:磁头只在一个方向上提供服务,到达末端后立即返回起点(不服务),再沿同方向移动。

Text Only
假设向右移动:
移动序列: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 的改进。

Text Only
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 均匀且高效 实现稍复杂 实际系统常用

代码实现

Python
# 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 (条带化):

Text Only
数据块分布:
磁盘0: [A1] [A3] [A5] [A7]
磁盘1: [A2] [A4] [A6] [A8]
特点: 数据交替存储在各磁盘上,无冗余

RAID 1 (镜像):

Text Only
数据块分布:
磁盘0: [A1] [A2] [A3] [A4]   ← 主盘
磁盘1: [A1] [A2] [A3] [A4]   ← 镜像盘
特点: 完全复制,100%冗余

RAID 5 (分布式奇偶校验):

Text Only
数据块分布:
磁盘0: [A1] [B1] [C1] [Dp]
磁盘1: [A2] [B2] [Cp] [D1]
磁盘2: [A3] [Bp] [C2] [D2]
磁盘3: [Ap] [B3] [C3] [D3]
特点: 校验数据分布在所有磁盘上(p = parity)

RAID 6 (双重奇偶校验):

Text Only
类似RAID 5,但每组数据有两个校验块(P + Q)
可同时容忍两块磁盘故障

RAID 10 ( RAID 1+0 ):

Text Only
先镜像再条带化:
组1:  磁盘0 [A1][A3]  ←镜像→  磁盘1 [A1][A3]
组2:  磁盘2 [A2][A4]  ←镜像→  磁盘3 [A2][A4]
             └── 条带化 ──┘

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 处理一块数据的时间。

单缓冲

Text Only
设备 ──T──→ [缓冲区] ──M──→ 用户区 ──C──→ CPU处理

处理每块数据的时间:max(T, C) + M

因为设备输入和 CPU 计算可以并行,但传入用户区需要独占缓冲区。

双缓冲

Text Only
设备 ──T──→ [缓冲区1] ──M──→ 用户区 ──C──→ CPU处理
设备 ──T──→ [缓冲区2] ──M──→ (交替使用)

处理每块数据的时间:max(T, C+M)

两个缓冲区交替使用,设备传输和 CPU 处理可以更充分地并行。

缓冲池( Buffer Pool )

由多个缓冲区组成的公共缓冲池,按使用状态分为: - 空缓冲队列( emq ) - 装满输入数据的缓冲队列( inq ) - 装满输出数据的缓冲队列( outq )

多个进程可共享缓冲池,提高缓冲区利用率和系统吞吐量。


Linux I/O 模型(重点)

Linux 下有 5 种 I/O 模型,它们的区别在于等待数据准备数据从内核拷贝到用户空间两个阶段的处理方式不同。

1. 阻塞 I/O ( Blocking I/O )

Text Only
应用进程                    内核
   │                        │
   │─── recvfrom() ────────→│
   │        (阻塞)          │ 等待数据准备...
   │                        │ 数据准备好
   │                        │ 将数据从内核拷贝到用户空间
   │←── 返回数据 ───────────│
   │                        │

特点:两个阶段(等待数据 + 拷贝数据)都阻塞。这是最常见、最简单的模型。 缺点:进程在 I/O 期间完全阻塞,无法处理其他任务。

2. 非阻塞 I/O ( Non-blocking I/O )

Text Only
应用进程                    内核
   │                        │
   │─── recvfrom() ────────→│  数据未准备好
   │←── EWOULDBLOCK ───────│
   │                        │
   │─── recvfrom() ────────→│  数据未准备好
   │←── EWOULDBLOCK ───────│
   │        (轮询)          │
   │─── recvfrom() ────────→│  数据准备好
   │        (阻塞)          │  将数据从内核拷贝到用户空间
   │←── 返回数据 ───────────│
   │                        │

特点:第一阶段不阻塞(轮询),第二阶段仍阻塞。 缺点: CPU 轮询浪费资源。

3. I/O 多路复用( I/O Multiplexing )

Text Only
应用进程                    内核
   │                        │
   │── select/poll/epoll ──→│  监视多个fd
   │      (阻塞在select)    │  等待任一fd就绪...
   │←── 返回就绪的fd ───────│
   │                        │
   │─── recvfrom() ────────→│  数据准备好
   │      (阻塞)            │  将数据从内核拷贝到用户空间
   │←── 返回数据 ───────────│

特点:一个进程可以同时监视多个文件描述符。是高并发网络编程的核心技术。 核心优势:单线程处理多连接。

4. 信号驱动 I/O ( Signal-driven I/O )

Text Only
应用进程                    内核
   │                        │
   │── sigaction(SIGIO) ───→│  注册信号处理函数
   │←── 返回 ───────────────│
   │  (不阻塞,做其他事)     │  等待数据准备...
   │                        │  数据准备好
   │←── SIGIO信号 ──────────│
   │                        │
   │─── recvfrom() ────────→│  将数据从内核拷贝到用户空间
   │←── 返回数据 ───────────│

特点:第一阶段通过信号通知,不阻塞也不轮询。 缺点:信号处理复杂,实际使用较少。

5. 异步 I/O ( Asynchronous I/O / AIO )

Text Only
应用进程                    内核
   │                        │
   │── 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

C
// 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 )
C
// 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 )

Text Only
用户进程       内核空间       硬件
   │              │            │
   │── read() ───→│            │
   │  (上下文切换1) │            │
   │              │←─ DMA拷贝 ─│  磁盘→内核缓冲区(拷贝1)
   │              │            │
   │←─ CPU拷贝 ──│            │  内核缓冲区→用户缓冲区(拷贝2)
   │  (上下文切换2) │            │
   │              │            │
   │── write() ──→│            │
   │  (上下文切换3) │            │
   │── CPU拷贝 ──→│            │  用户缓冲区→Socket缓冲区(拷贝3)
   │              │── DMA拷贝 ─→│  Socket缓冲区→网卡(拷贝4)
   │←─ 返回 ──────│            │
   │  (上下文切换4) │            │

总计: 4次拷贝 + 4次上下文切换

mmap + write (内存映射)

Text Only
用户进程       内核空间       硬件
   │              │            │
   │── mmap() ───→│            │ 将内核缓冲区映射到用户空间
   │              │←─ DMA拷贝 ─│  磁盘→内核缓冲区(拷贝1)
   │              │            │
   │── write() ──→│            │
   │── CPU拷贝 ──→│            │  内核缓冲区→Socket缓冲区(拷贝2)
   │              │── DMA拷贝 ─→│  Socket缓冲区→网卡(拷贝3)

总计: 3次拷贝 + 4次上下文切换
优化: 减少了1次CPU拷贝(用户缓冲区到内核缓冲区)

sendfile

Text Only
用户进程       内核空间       硬件
   │              │            │
   │─ sendfile() ─→│            │
   │  (上下文切换1) │            │
   │              │←─ DMA拷贝 ─│  磁盘→内核缓冲区(拷贝1)
   │              │            │
   │              │── CPU拷贝 ─→│  内核缓冲区→Socket缓冲区(拷贝2)
   │              │── DMA拷贝 ─→│  Socket缓冲区→网卡(拷贝3)
   │←─ 返回 ──────│            │
   │  (上下文切换2) │            │

总计: 3次拷贝(1次CPU + 2次DMA)+ 2次上下文切换
优化: 数据不经过用户空间

sendfile + DMA gather copy (最优方案)

Text Only
用户进程       内核空间       硬件
   │              │            │
   │─ 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 中的零拷贝

Java
// 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 epollman 2 sendfile
  • The C10K Problem
  • Linux io_uring 介绍