Skip to content

第 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 实际有两个阶段

  1. 数据准备阶段:等数据从硬件到达内核缓冲区(可能很久)
  2. 数据拷贝阶段:把数据从内核缓冲区拷贝到用户内存(很快)

不同 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 是否 -1open/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 模型,多路复用就是其中之一。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗