主题
第 1 章 · I/O 基础知识
学完本章你将知道:什么是用户态/内核态?文件描述符到底是个啥?为什么
read()一个文件会"卡住"?这些是看懂多路复用的「前置词汇表」。
1. 一句话开场
I/O = Input/Output(输入/输出):程序与外界(磁盘、网络、键盘、屏幕……)之间「搬数据」的过程。
生活类比:你坐在办公室里写报告(CPU 在干活),但要的数据在档案室(磁盘)或者快递员手上(网络)。
- 「自己去拿」 = 用户态调用系统接口
- 「让前台帮你拿」 = 切换到内核态,由内核代为操作
- 「拿到的资料编号」 = 文件描述符
整个 I/O 流程,本质就是「程序找内核要数据 → 内核找硬件 → 数据回流到程序」这一条链路。
2. 用户态 vs 内核态:CPU 的两副面孔
2.1 是什么
现代 CPU(x86_64、ARM)都有**特权级(Privilege Level)**的概念。Linux 用了其中两级:
| 特权级 | 别名 | 谁运行在这里 | 能做什么 |
|---|---|---|---|
| Ring 0 | 内核态(Kernel Mode) | Linux 内核、设备驱动 | 任何事:操作硬件、访问全部内存、修改页表 |
| Ring 3 | 用户态(User Mode) | 你的程序、Nginx、MySQL | 受限:只能访问自己的内存,不能直接碰硬件 |
生活类比:
- 内核态 = 银行的「金库管理员」——能进金库、能搬钱、能改账本
- 用户态 = 银行的「普通客户」——只能去窗口排队,让管理员帮你存取钱
2.2 为什么要分两态?
一句话:安全。
如果所有程序都能直接读写磁盘、修改任意内存,那任何一个 bug 都能搞崩整个系统。比如:
- 你写了个程序,循环往内存随机地址写数据 → 把别的程序甚至内核搞挂
- 你的程序访问磁盘扇区,改写了引导区 → 系统再也启动不起来
通过用户/内核分态,用户程序的"杀伤力"被严格圈在自己的沙盒里。要做敏感操作?只能通过**系统调用(System Call)**这扇门,求内核帮忙。
2.3 切换的代价
图解说明:用户程序通过
syscall指令进入内核,内核处理完后返回。两次"切换"都需要保存/恢复一堆寄存器和栈状态,一次系统调用大约消耗 100~1000 纳秒——比函数调用慢 100 倍以上。
这就是为什么 epoll 比 select 快:select 每次调用都要把上千个 fd 从用户态拷贝到内核态,开销巨大;epoll 通过 epoll_ctl 一次性注册,后续 epoll_wait 只传回就绪的 fd——省下了海量的拷贝。
3. 文件描述符(File Descriptor,简称 fd)
3.1 是什么
文件描述符 = 内核给每个"打开的资源"分配的非负整数标识。 你拿这个数字就能操作对应的资源。
生活类比:去医院看病时领的「就诊号」。
- 你不需要知道医生的名字、坐在哪个房间、用什么仪器——只要拿着就诊号去窗口,护士就知道该带你去哪
- 内核也一样——你不用知道这个 socket 的内核结构在哪、绑了什么端口——拿着 fd 调
read/write就够了
3.2 谁会有 fd
Linux 哲学:一切皆文件。所以 fd 的种类远不止"普通文件":
| fd 指向 | 来源 |
|---|---|
| 普通文件 | open("/etc/passwd") |
| 标准输入/输出/错误 | 进程启动时自动有,分别是 0/1/2 |
| 网络连接 | socket() 创建,accept() 接收 |
| 管道 | pipe() 创建 |
| 设备文件 | open("/dev/tty") |
| 信号事件 | signalfd() |
| 定时器 | timerfd_create() |
| epoll 实例本身 | epoll_create1() ←—— epoll 自己也是一个 fd! |
3.3 fd 的内部结构(重点)
每个进程都有一张文件描述符表(fd table),下标就是 fd 数字:
text
进程 P 的 fd 表(位于内核空间)
┌─────┬──────────────────────────────────┐
│ 0 │ → struct file (stdin) │
│ 1 │ → struct file (stdout) │
│ 2 │ → struct file (stderr) │
│ 3 │ → struct file (/etc/passwd) │
│ 4 │ → struct file (socket to 8.8.8.8)│
│ 5 │ → struct file (epoll instance) │
│ ... │ │
└─────┴──────────────────────────────────┘
│
▼
struct file(系统级表)
├─ 文件偏移量
├─ 访问模式(O_RDONLY 等)
└─ → struct inode(物理文件元信息)图解说明:fd 数字本身只是个下标,背后真正的"打开文件"信息存在
struct file里。所以同一个进程里,3 和 4 完全是两个独立的资源;而不同进程的 fd 3,互相之间毫无关系。
3.4 默认上限
bash
$ ulimit -n # 单进程最大 fd 数(软限制)
1024
$ ulimit -Hn # 硬限制
4096
$ cat /proc/sys/fs/file-max # 系统级上限
9223372036854775807小坑提醒:Linux 默认每个进程只能开 1024 个 fd——这就是为什么早期的 select 用 1024 大小的位图。做高并发服务器前一定要先
ulimit -n 65535,否则连 5000 个连接都接不住。
4. 系统调用:用户程序求内核帮忙的方式
4.1 常见 I/O 系统调用一览
| 系统调用 | 作用 | 对应章节 |
|---|---|---|
open() | 打开文件,返回 fd | 第 1 章 |
read() / write() | 从 fd 读/写数据 | 第 2 章 |
close() | 关闭 fd | 第 1 章 |
socket() | 创建网络 socket,返回 fd | 全系列 |
bind() / listen() / accept() / connect() | TCP 服务端/客户端 4 步 | 全系列 |
select() / poll() / epoll_*() | 多路复用核心 | 第 3-5 章 |
fcntl() | 修改 fd 属性,设置非阻塞靠它 | 第 2 章 |
4.2 read() 系统调用底层发生了什么?
这是理解 I/O 模型的关键剧本:
图解说明:一次 read 实际有两个阶段:
- 数据准备阶段:等数据从硬件到达内核缓冲区(可能很久)
- 数据拷贝阶段:把数据从内核缓冲区拷贝到用户内存(很快)
不同 I/O 模型的差异,就在「这两个阶段如何处理」上——这是下一章的核心内容。
5. 网络 I/O 中的关键概念
5.1 socket:网络通信的"插座"
socket 就是一种特殊的文件描述符——它把"网络连接"抽象成可以 read/write 的对象。
c
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // sockfd 是 fd
read(sockfd, buf, 1024); // 像读文件一样读网络数据
write(sockfd, buf, 1024); // 像写文件一样发网络数据5.2 阻塞 vs 非阻塞 socket
socket 默认是阻塞的,但可以通过 fcntl 设置为非阻塞:
c
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // 设置非阻塞设置后,read/write/accept 在没准备好时会立即返回 -1,errno = EAGAIN(或 EWOULDBLOCK),而不是傻等。
重要前提:epoll 的 ET 模式必须搭配非阻塞 socket 使用,否则会卡死。第 5 章会详细解释。
5.3 内核缓冲区
每个 TCP socket 在内核里都有两块缓冲区:
text
网络 ───────► [接收缓冲区] ──read()──► 用户程序
网络 ◄────── [发送缓冲区] ◄─write()── 用户程序- read() 是从「接收缓冲区」往用户内存拷贝
- write() 是把数据塞进「发送缓冲区」(不一定真发送出去)
多路复用的 EPOLLIN 事件 = 接收缓冲区里有数据可读 EPOLLOUT 事件 = 发送缓冲区有空闲位置可写
6. 动手实践
实践 1:观察一个进程的所有 fd
bash
# 启动一个简单的 HTTP 服务(如果没有 python3 可以用 python2)
python3 -m http.server 8000 &
PID=$!
# 查看它打开了哪些 fd
ls -l /proc/$PID/fd/
# 输出示例:
# lrwx------ 1 root root 64 May 10 16:30 0 -> /dev/pts/0
# lrwx------ 1 root root 64 May 10 16:30 1 -> /dev/pts/0
# lrwx------ 1 root root 64 May 10 16:30 2 -> /dev/pts/0
# lrwx------ 1 root root 64 May 10 16:30 3 -> 'socket:[123456]'
# lrwx------ 1 root root 64 May 10 16:30 4 -> 'anon_inode:[eventpoll]' ← epoll 实例
kill $PID解读:fd 0/1/2 是 stdin/stdout/stderr;fd 3 是监听 socket;fd 4 居然是个
eventpoll——说明 Python 的 http.server 内部用了 epoll 来管理连接。
实践 2:strace 偷看系统调用
bash
# 看 cat 命令一共做了哪些系统调用
strace cat /etc/hostname
# 关键输出(已简化):
# openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3 ← 拿到 fd=3
# read(3, "myhost\n", 131072) = 7 ← 读 7 字节
# write(1, "myhost\n", 7) = 7 ← 写到 stdout(fd=1)
# close(3) = 0解读:连
cat这种最简单的命令,本质都是open → read → write → close的系统调用序列。多路复用,就是优化这个流程在"高并发"下的效率。
实践 3:测一次系统调用要多久
c
#include <stdio.h>
#include <time.h>
#include <unistd.h>
int main() {
struct timespec t1, t2;
int N = 1000000;
clock_gettime(CLOCK_MONOTONIC, &t1);
for (int i = 0; i < N; i++) {
getpid(); // 最便宜的系统调用
}
clock_gettime(CLOCK_MONOTONIC, &t2);
long ns = (t2.tv_sec - t1.tv_sec) * 1000000000L + (t2.tv_nsec - t1.tv_nsec);
printf("avg: %ld ns/call\n", ns / N);
return 0;
}bash
gcc -O2 syscall_bench.c -o b && ./b
# 典型输出:avg: 230 ns/call解读:一次最便宜的系统调用都要 200+ 纳秒——意味着每秒最多 500 万次。复杂调用(如 read 一个有数据的 socket)通常 1-10 微秒。所以减少系统调用次数,是高性能服务器的重要优化方向——这就是 epoll 设计的核心动机。
7. 常见陷阱与最佳实践
7.1 陷阱
🪤 陷阱 1:fd 泄漏
c
int fd = open("file.txt", O_RDONLY);
if (some_error) return -1; // ❌ 忘了 close(fd)!
// fd 数量持续增长,最终 EMFILE: too many open files用
valgrind --track-fds=yes可以检测 fd 泄漏。
🪤 陷阱 2:fd 重用
close(3) 后,下次 open 可能又拿到 3。如果你的代码缓存了 fd,就可能误操作另一个文件。
🪤 陷阱 3:把"短读"当作错误
read(fd, buf, 1024) 返回 7 不代表错误——TCP 是字节流,可能要分多次读。正确做法是循环读直到 EAGAIN 或 0。
7.2 最佳实践
| 实践 | 说明 |
|---|---|
| 永远检查 fd 是否 -1 | open/socket/accept 失败都返回 -1 |
| 永远 close 不再用的 fd | 用完即关,配合 RAII 或 defer |
| 生产环境拉高 ulimit -n | 至少 65535,否则高并发服务跑不起来 |
| 理解 EAGAIN ≠ 错误 | 非阻塞 fd 的"暂时没数据",要继续等下一次 epoll_wait |
| 熟悉 strace | 排查 I/O 问题神器 |
8. 本章小结
text
关键概念清单(自测:你能复述吗?)
─────────────────────────────────
✅ 用户态 vs 内核态:为什么要分?切换代价是多少?
✅ 文件描述符:是个什么类型?同一个进程最多多少?
✅ 一切皆文件:fd 都能指向哪些资源?
✅ 系统调用 read 的两个阶段
✅ socket 的内核缓冲区
✅ 阻塞 vs 非阻塞 socket:怎么设置?返回值差异?9. 真实面试题
Q1:什么是文件描述符?同一个进程里,两个 fd 数字相同的可能性是多少?
A:fd 是内核给打开的资源分配的非负整数标识。同一个进程里同一时刻不可能有两个相同的 fd——因为 fd 是 fd 表的下标。但 close 后下次 open 可能拿到相同的数字(fd 重用)。
Q2:用户态切换到内核态有几种方式?
A:3 种——① 系统调用(主动);② 异常(除零、缺页等);③ 中断(硬件中断如时钟、网卡)。多路复用主要走的是①和③。
Q3:为什么说一次 read 实际有两个阶段?
A:等数据(数据从硬件到内核缓冲区)+ 拷数据(内核缓冲区到用户内存)。不同 I/O 模型的差异就在「等」这一步是不是阻塞。
Q4:Linux 中一个进程最多能打开多少个 fd?
A:受三层限制——进程级软限制(
ulimit -n,默认 1024)、进程级硬限制(ulimit -Hn)、系统级上限(/proc/sys/fs/file-max)。生产环境要把软限制调高。
Q5:epoll 实例本身算不算 fd?为什么?
A:算!
epoll_create1()返回的就是 fd。这正体现了 Linux「一切皆文件」哲学——epoll 实例可以被 close,可以被另一个 epoll 监听(嵌套)。
下一站 → 第 2 章:五大 I/O 模型,把"两个阶段"分别"阻塞 / 非阻塞 / 通知",就组合出了 5 种 I/O 模型,多路复用就是其中之一。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗