03.09 I/O 与事件通知:从长连接就绪到完整消息
返回第三卷 · FD 前置:03.03 · 调度前置:03.04 · TCP 字节流前置:04.04 · 有限队列前置:05.03
03.09 I/O 与事件通知:从长连接就绪到完整消息
DeepTutor 初稿经技术与教学审阅后的静态课程。
fd11/fd12、字节序列和队列状态是纸上模型;没有执行 Go、网络服务、epoll、基准或站点。自定义的 2 字节长度前缀仅为讲解 TCP 字节流而设,不是 HTTP、WebSocket 或 OpenIM 的实际线上帧,也不改变现有教学 HTTP 正文上限与确认语义。
一、两条空闲连接为什么不是两个一直在跑的线程
03.03 认识了文件描述符(FD),03.04 区分“可运行、正在用 CPU、等待事件”;04.04 解释 TCP 传的是有序字节流,04.05 说明连接可以存活而没有新数据。现在设虚构 IM 网关有来自 u-a 和 u-b 设备的两个连接,分别用纸上 fd11、fd12 标记。多数时间它们没有输入。若每个处理线程都阻塞等待,也可能工作,但线程/栈及调度有成本;若在循环里不断试读,会浪费 CPU。事件通知让等待者先睡眠,由内核在描述符可能进行 I/O 时提示它。
要立刻区分三个事实:有 TCP 字节到达、描述符当前可能可读、应用已经收齐并验证一条消息。它们发生在不同层次,不能由第一个自动推出第三个。即使一条完整消息已被本地解析,也不等于接收设备已展示或发送者收到了业务确认。
| 第一遍 | 第二遍 | 交付物 |
|---|---|---|
| 阻塞/非阻塞、就绪、短读写、每连接缓冲 | epoll、水平/边沿触发、有限发送队列与公平性 | 两连接接收状态表、部分写状态表、LT/ET 反例 |
本章先把操作系统 I/O 机制讲透;Go net.Conn 如何提供较方便的调用方式在后面连接。它不声称固定 OpenIM 源码按本章的一个事件循环实现,也不要求学习者本次编写可运行网关。
二、阻塞、非阻塞与 EOF:同一个“还没有数据”有不同结果
对一条 TCP 流,阻塞读在当前没有可返回数据时可等待后续到达;等待期间任务不等于占用 CPU 执行槽。非阻塞读在没有可读数据时可返回 EAGAIN/EWOULDBLOCK,意思是“现在做不成,稍后再试”,并非“连接已断”或“应用消息非法”。如果程序立刻不间断重复调用,它又变成忙轮询;正确的下一步通常是等待事件或期限,再继续。
| 纸上返回 | 表示什么 | 应用层应做什么 |
|---|---|---|
| n>0 | 本次收到 n 个字节,可能少于请求量 | 先处理实际 n 个字节,保留未完整帧 |
EAGAIN/EWOULDBLOCK | 非阻塞操作此刻无进展空间 | 保留状态,等待下一次可读/可写机会 |
| n=0,读取非零长度的已连接 TCP 流 | 对端有序关闭后的 EOF 情形 | 检查是否留下半帧,清理连接状态 |
| 其他错误/超时 | 系统调用或期限失败 | 记录边界、关闭或按策略处理,不能当作完整消息 |
一次请求读取 7 字节,也可只返回 1、3 或其他正数;TCP 不替应用保存“哪次发送是一条消息”的边界。读取接口还可能同时给出 n>0 和错误,Go 读者应先处理这 n 个字节,再判断错误。写入同理:返回 n 只代表本次接收的前缀长度,不保证其余字节已发送,也不表示接收方应用处理完成。03.07 已用文件短读写建立相同的“检查 n”习惯,这里把它迁移到流套接字。
三、epoll:登记关心什么,再领取当前就绪线索
Linux epoll 实例维护关心的描述符集合与就绪集合。程序注册 fd11/fd12 的可读事件,调用 epoll_wait 等待;它返回的是当前有相关 I/O 事件的描述符,调用方再尝试 read/recv。若无事件,等待本身可阻塞;一个等待者可以关注多个 FD,不需要为每个 FD 单独做一次忙轮询。
兴趣集合:{fd11: 可读, fd12: 可读}
某时刻内核发现 fd11 可读
epoll_wait 返回 fd11 的就绪线索
应用调用非阻塞 read(fd11),按实际 n 更新 fd11 的解码状态“就绪”是线索,不是预约:它说明现在尝试 I/O 有意义,不保证随后读到固定字节数;其他处理者可能先消耗数据,状态也可改变,所以非阻塞调用仍要处理 EAGAIN。可读事件可能伴随对端关闭或错误,必须检查实际 I/O 结果和事件标志。epoll_wait 返回 0 也可能只是等待期限到;被信号打断而返回 EINTR 是等待过程的另一个结果,都不是一条应用消息。可写事件表示当前发送缓冲可能有空间,不能解释成对端已经消费了输出。
Go 的 net.Conn 给出 Read/Write/Deadline/Close 等跨平台接口,Go 标准工具链内部也有网络轮询机制;使用 net.Conn 的业务代码一般不必手写 Linux epoll,但仍要理解短读、期限、连接关闭和业务消息边界。反过来,知道 Linux epoll 也不能推断某个 IM 项目、某个 Go 版本具体使用怎样的线程和 goroutine 布局。
四、纸上 TCP 帧:三次可读才拼成一条 hello
为了只讲流解析机制,发明一条与真实 IM 协议无关的玩具帧:前 2 字节为大端无符号正文长度,随后是正文;本章假设允许的正文长度为 1–8 字节。hello 的长度是 5,帧的 7 字节依次为 00 05 h e l l o,其中 00 05 是十六进制长度字段,h..o 是 ASCII 字节。解析器按连接保存独立缓冲与状态,先收满 2 字节、校验长度,再收满 5 字节正文。
假定 fd11 的到达被拆成三次,而每次纸上 read 都完整返回当时标示的这段可读字节:
| 次序 | 本次读到 | fd11 累积缓冲 | 解码状态 |
|---|---|---|---|
| t0 | 00 | 00 | 长度头仅 1/2 字节,继续等 |
| t1 | 05 h e | 00 05 h e | 长度=5 合法;正文已有 2/5 字节 |
| t2 | l l o | 00 05 h e l l o | 正文满 5/5,交出 hello,消费这 7 字节 |
这段期间,fd12 即使同时读到自己的 00 02 o k,也应在它自己的缓冲中解出 ok;不能把两个 FD 的字节拼在一起。t1 的 h e 不能被当成完整消息处理。若长度头给出 0 或超过本章的 8 字节教学上限,应在分配大缓冲前拒绝;本章单帧的纸上存储上限因此是 2 字节头加 8 字节正文,即 10 字节。连接还需有限排队条数和超时/关闭规则,防止对端只发半个头或持续宣称长正文而占住内存。
这里的 1–8 字节限制是玩具帧的独立练习约定,不能替代 09.02 已固定的 S2 HTTP 正文合同;实际 WebSocket 已有自己的帧规则,应用消息边界仍取决于产品协议。这个例子只证明:TCP 字节到达需要状态化地解析,可读事件不等于完整业务消息。
五、部分写与慢设备:发送队列必须有边界
再看输出方向。假设应用要向 fd12 写 5 字节 hello,此处只看正文片段,不把玩具长度头算进这次写。第一次非阻塞 write 仅接受 2 字节 he,程序必须保存待发偏移 2 和剩余 llo;下一次可写机会再尝试剩余 3 字节。若第二次只接受 1 字节 l,待发偏移变成 3,还剩 lo。每次都按返回的 n 推进,不能从头重发而重复 he,也不能把 EAGAIN 当成已发送。
| 尝试 | 本次接受 | 累计偏移 | 尚待提交给内核 |
|---|---|---|---|
| 第一次 | he,2 字节 | 2 | llo |
| 第二次(纸上另一分支) | l,1 字节 | 3 | lo |
| 以后 | 取决于实际 n | 最多 5 | 直到剩余为空或失败 |
可写不表示一次能送完整片段,更不表示远端 TCP 已确认、客户端应用已处理或设备已展示。若慢设备一直不读,输出可能积压;必须为每连接或每类任务设有限队列、容量与满队列策略(拒绝、断开、背压或业务允许的丢弃),并让调用方看到真实状态。05.03 已解释有限队列与取消,04.06 已解释慢接收方与流控;本章把它们接到可写事件和偏移保存上。
六、水平与边沿触发:剩余字节会不会再次提醒你
epoll 默认的水平触发(LT)可理解为:只要读条件仍成立、数据还留在接收缓冲,后续等待仍可返回可读线索。边沿触发(ET)主要在状态发生相关变化时通知。假设某 FD 已有 6 字节在缓冲里,应用收到一次 ET 事件后只读 2 字节,然后直接再次等待;剩余 4 字节即使还在,未有新变化时也可能不会再得到提醒,于是程序以为自己在等数据,实际数据已在本机。
| 做法 | 6 字节到达、只读 2 字节后的问题 |
|---|---|
| LT | 剩余可读条件仍在,后续可再次报告;仍要避免只接事件却不处理而反复唤醒 |
| ET | 不能依赖“剩余 4 字节”再触发新边;需要非阻塞读到 EAGAIN,或在自己的待办表中继续处理 |
学习者第一遍可先理解 LT 的条件提醒;第二遍再处理 ET 的非阻塞、一直读/写到无进展和重新安排工作。若一次只允许处理有限字节以照顾其他连接,ET 处理者必须记住这个 FD 仍有待办,不能丢掉状态后只等新内核事件。EPOLLONESHOT 还要求处理后主动重新启用,属于更高一层的事件所有权管理。无论 LT 还是 ET,都只报告 I/O 条件,不替应用解析 00 05 h e l l o。
七、Go 抽象、成本与公平:事件循环也会被业务拖慢
Go 初学者使用 net.Conn 时,可让调用方看到阻塞式风格的 Read,用期限与 Close 控制等待。对固定长度头,io.ReadFull 能帮助“读满两个字节或得到 EOF/错误”;读到长度 5 后再按上限读正文。但即便有这个便利 API,解析器仍须区分半帧、长度越界、超时和业务权限,不能把标准库调用当成完整 IM 协议。若直接实现非阻塞事件循环,需要自行维护每个 FD 的输入/输出状态和就绪所有权,复杂度更高。
事件通知的价值是避免为大量空闲连接持续忙轮询;它不会让连接免费:每条连接仍涉及 FD、内核 socket 状态、输入/输出缓冲、可能的 TLS 状态和应用对象。一个循环若在处理 fd11 时长时间做数据库查询或复杂群聊扇出,其他已就绪 FD 仍可能排队等待;应把处理时长、每次工作预算和有限队列纳入方案。03.04 的 CPU 调度、04.06 的流控与 11 卷的真实测量以后会给这种取舍提供证据。本章没有运行基准,也没有宣称某种事件模型在固定连接数下一定更快。
八、交付两连接 I/O 状态机与分层练习
交付四张纸:fd11 的 00 | 05 h e | l l o 解析表、fd12 独立状态、5 字节部分写偏移表,以及“6 字节可读只取 2 字节”在 LT/ET 下的继续条件。再为 EOF、EAGAIN、半帧超时、队列满和业务确认分别写出处理边界。纸上状态机与真实网络运行、性能、设备交付证据必须分开。
分层练习:先答,再展开反馈
1. 空闲连接存在就意味着同数线程正在占 CPU 吗?
不意味着。FD 与缓冲仍占资源,但等待事件的任务不持续运行。
2. 非阻塞读返回 `EAGAIN` 是对端关闭吗?
不是。它表示此刻无可用进展,之后可等就绪再试。
3. 对已连接 TCP 流请求非零字节却读到 n=0,应想到什么?
通常是对端有序关闭的 EOF;还要检查本地是否留有半帧。
4. `read(7)` 返回 1 字节能当成完整帧吗?
不能。TCP 只返回本次实际可得字节,帧由应用累计解析。
5. `epoll` 的兴趣集合保存什么?
应用注册要关注哪些 FD 的哪些 I/O 事件。
6. 就绪事件会把 7 字节完整帧“预留”给当前处理者吗?
不会。它只是当前可尝试 I/O 的线索,仍要检查返回 n/错误。
7. 玩具帧 `00 05 h e l l o` 总共几字节?
2 字节长度头加 5 字节正文,共 7 字节。
8. 头部 `00 05` 表示正文长度多少?
按本章的大端无符号约定,是 5 字节。
9. t0 只收到 `00` 时能分配 5 字节正文吗?
还不能。长度头只有 1/2 字节,尚不知道完整长度。
10. t1 又收到 `05 h e` 后能交出消息吗?
不能。长度 5 已知,但正文只有 2/5 字节。
11. t2 收到 `l l o` 后应消费多少累计字节?
7 字节:2 字节头和 5 字节正文,得到 hello。
12. fd12 的 `00 02 o k` 可以并到 fd11 的缓冲里吗?
不能。每条连接有自己的字节流和解码状态。
13. 长度头声明 9 字节,而本章上限为 8,应怎么做?
在分配大正文缓冲前拒绝该玩具帧,并按协议处理连接错误。
14. 第一次非阻塞写 `hello` 只接受 `he`,剩余是什么?
偏移 2,剩余 llo;不能从头重发 he。
15. 可写事件证明客户端应用已收到消息吗?
不能,只表示本地写入可能取得进展。
16. LT 下缓冲里剩余字节,之后仍可能收到什么?
可读条件仍成立时,后续仍可得到可读提醒。
17. ET 下 6 字节只读 2 字节后直接等待有什么风险?
剩余 4 字节可能没有新边沿,等待者可能错过现成数据。
18. ET 常用什么方式确定当前已无可读数据?
在非阻塞模式下继续读,直到 EAGAIN;有限预算时保留自己的待办状态。
19. 慢设备输出队列可以无限增长吗?
不应如此。需要有限容量及满队列/背压/断开等明确策略。
20. Go 的 `io.ReadFull` 能替应用决定消息权限吗?
不能。它只帮助读够指定字节数,权限和业务合同由应用检查。
21. 单个事件循环处理 fd11 时长时间计算,会影响谁?
其他已就绪 FD 可能排队等待,需考虑工作预算与转交策略。
22. 这套两字节长度前缀就是 WebSocket 或 OpenIM 的帧吗?
不是。它只用于纸上解释 TCP 字节流的应用边界。
来源与下一步
- Linux
epoll(7)、epoll_wait(2)与recv(2):兴趣/就绪集合、LT/ET、非阻塞结果与流读取。 - Linux
write(2)与TCP RFC 9293:实际写入字节数与可靠字节流背景。 - Go
net.Conn和io.ReadFull:Go 的连接、期限和固定长度读取 API;业务解析仍需独立完成。
按学习路线,下一章03.10 系统诊断把进程、内存、磁盘和 socket 的只读观测放进同一条 IM 故障证据链。离开本章前,应能说出可读事件、实际 n 字节、完整帧和业务确认之间每一道边界,并手算慢设备输出偏移与 LT/ET 的剩余数据反例。