CYCHENME · STUDYLINUX / SOCKET / 2026
学习笔记 · 网络编程

Linux 内核看 socket 底层的本质:I/O 模型

先把一次读 socket 拆成两步,再去看阻塞、非阻塞、I/O 复用、信号驱动和异步 I/O。很多“同步 / 异步”的困惑,会在这张地图里消失。

2026-07-27LinuxSocketI/O 模型epoll

00所有差异,都发生在两个阶段

socket 输入并不是“调用一次 read 就完成”的单一动作。网络数据先到达 Linux 内核,进入内核缓冲区;随后内核再把数据复制到应用程序传入的用户缓冲区。五种 I/O 模型,本质上是在重新分配:谁等待数据,谁等待复制完成。

一句话结论:前四种都是同步 I/O,因为第二阶段仍需要应用调用 recvfrom/read 并参与复制;异步 I/O 的通知意味着数据已经被复制到用户缓冲区。

recvfrom() 在表达什么?

ssize_t recvfrom(int sockfd, void *buf, size_t len,
                 int flags, struct sockaddr *src_addr,
                 socklen_t *addrlen);

这里最重要的是三个角色:sockfd 表明“读哪个连接”,buf 表明“把数据放到哪里”,len 限制“最多复制多少”。调用模型不同,改变的是调用返回的时机;数据最终仍需从内核空间进入这个 buf

01五种 I/O 模型:把“等待”放在哪里

模型等待数据复制数据一句话理解
阻塞 I/O阻塞阻塞一直等到数据读完。
非阻塞 I/O不阻塞,没数据即返回阻塞不等,但需要反复问。
I/O 复用阻塞在 select/poll/epoll阻塞一个线程等多个 socket。
信号驱动 I/O内核等待,数据到达时发信号阻塞通知我“可以读了”。
异步 I/O不阻塞不阻塞内核完成后才通知。
01 / 阻塞 I/O · 直接,但一个连接会占住一个等待线程

调用 recvfrom() 后,若数据未到,当前线程睡眠。数据到达并复制到 buf 后函数才返回。阻塞的是当前线程,不是整个系统;简单的代价是连接数多时,不能让每个连接都各自等待。

阻塞 I/O 时序图
原笔记配图:阻塞 I/O 在等待与复制阶段都不返回应用。
02 / 非阻塞 I/O · 不等,但会反复探问

数据尚未准备好时,内核立即以 EAGAINEWOULDBLOCK 返回。应用可继续做别的事,稍后再次调用;代价是轮询带来更多系统调用,也可能浪费 CPU。

非阻塞 I/O 时序图
原笔记配图:第一次阶段不等待;真正读取时仍要完成复制。
03 / I/O 复用 · 复用的是等待能力,不是复制

应用把多个 socket 交给 selectpollepoll,阻塞等待“哪一个已就绪”。返回后,应用再对就绪 socket 调用 recvfrom/read。它适合一个线程管理许多连接,但本质仍是同步 I/O。

I/O 复用时序图
原笔记配图:先等就绪事件,再读取具体 socket。
04 / 信号驱动 I/O · 信号表示“可以开始读”

应用注册 SIGIO 后继续运行;数据到达时内核发送信号,应用随后调用 recvfrom()。它避免了持续轮询,但信号不是“读完了”的通知,复制阶段仍由应用触发。

信号驱动 I/O 时序图
原笔记配图:内核以信号提示 socket 可读。
05 / 异步 I/O · 通知到达时,I/O 已经完成

应用提交 aio_read 后立即返回。内核负责等待数据、完成复制,再通知应用。这是它与信号驱动最关键的分界:通知不是“现在能读”,而是“数据已经到了你的 buf”。

异步 I/O 时序图
原笔记配图:等待和复制均由内核完成后才通知。
五种 I/O 模型比较图
原笔记配图:横向是等待数据与复制数据两个阶段;纵向比较五种模型。

最容易混淆的是 I/O 复用、信号驱动和异步 I/O。I/O 复用返回“某些 fd 可读”;信号驱动通知“这个 fd 可以读”;异步 I/O 通知“数据已经读完”。只要追问第二阶段由谁完成,判断就会稳定。

02I/O 复用:select、poll、epoll

三者都用于“等待多个 fd 的事件”,区别主要在于 fd 集合如何交给内核,以及就绪结果如何取回。

selectpollepoll
监听集合fd_setpollfd 数组先用 epoll_ctl 注册
每次调用传入并扫描集合传入并扫描数组等待已注册事件
限制常见 FD_SETSIZE 限制无该集合位图限制Linux 特有
适用少量 fd、可移植性中等数量 fd大量长连接、低活跃比例

select:集合小、移植性优先

select 将读、写、异常 fd 集合交给内核,在指定超时内等待。返回后应用需要检查哪些位被置位。它的主要限制是 fd_set 的规模,且每轮都要处理整组描述符。

poll:摆脱固定集合位图

poll 使用 struct pollfd 数组记录 fd 和关注事件,没有 FD_SETSIZE 这种位图限制。但它仍要让内核和应用在每轮扫描这份数组。

epoll:注册一次,等待就绪

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

epoll_create() 创建实例;epoll_ctl() 添加、删除或修改监听 fd;epoll_wait() 取回就绪事件。它不是每次调用都重新提交整组 fd,而是先注册、后等待,因此很适合大量、长期存在而活跃比例较低的连接。

struct epoll_event ev = {0};
ev.data.ptr = connection;
ev.events = EPOLLIN | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_ADD, connection->getSocket(), &ev);

int ready = epoll_wait(epfd, events, 20, 10000);
for (int i = 0; i < ready; ++i) {
  if (events[i].events & EPOLLIN) {
    ((Connection *)events[i].data.ptr)->handleReadEvent();
  }
}
代码审阅提示:原笔记示例里创建变量写作 pollingfd,后续却使用 epollfd;等待结果也出现 retready 混用。概念不受影响,但正式代码必须统一变量名并处理错误返回。

03epoll 的 LT 与 ET

LT:Level Trigger,水平触发

LT 是默认模式。只要 fd 仍处于可读状态,下一次 epoll_wait() 仍会通知。例如 socket 有 100 字节、应用只读走 50 字节,剩下的 50 字节仍会使这个 fd 保持可读。它更稳,也不容易漏处理。

ET:Edge Trigger,边缘触发

ET 只在状态从“不可读”变为“可读”时通知一次。收到事件后必须持续 read/recv,直到返回 EAGAIN;否则数据尚未读尽却没有新的状态变化,后续可能不再被提醒。

收到 EPOLLIN
  → 循环 read / recv
  → 直到返回 EAGAIN
  → 当前缓冲区读尽,回到事件循环

ET 必须配合非阻塞 fd。否则一次读取可能阻塞住事件循环,其他连接便失去处理机会。

04如何选择:不要只问“哪个更快”

情况更合适的选择原因
fd 少、强调跨平台select接口普遍可用,理解成本低。
fd 可能超过 select 默认限制poll不依赖 fd_set 位图大小。
Linux 上大量长连接,活跃 fd 比例低epoll注册与就绪等待分离,避免反复提交整组 fd。
fd 很少或状态剧烈变化未必需要 epollepoll_ctl 本身也有维护成本。

选择并非纯粹的性能题。平台、连接生命周期、活跃连接比例、事件循环复杂度与团队维护成本,都比“听说 epoll 很快”更值得先问。

05读完要记住什么

  1. socket 输入先等待数据、再复制数据;以这两步为坐标系理解模型。
  2. 非阻塞不等于异步;前者只是没数据就立即返回。
  3. I/O 复用复用的是等待多个 socket 的能力,读取与复制仍由应用参与。
  4. 信号驱动通知“可以读”,异步 I/O 通知“已完成”。
  5. epoll 适合 Linux 的大量长连接;ET 需要非阻塞 fd,并读到 EAGAIN

本文基于 OpenClaw 阅读笔记重新编辑与排版。原始资料:知乎专栏《Linux 内核看 socket 底层的本质》。配图来自原笔记对应引用,已保存为本文本地资源。