一、阻塞式网络编程的传统思路
在传统的阻塞I/O模型 中,网络交互流程是"谁调用,谁等待":
-
accept():主线程调用后阻塞,直到有客户端连接; -
recv():线程调用后阻塞,直到有数据到达; -
send():线程调用后阻塞,直到内核缓冲区有空间。
这种模式直观、易理解,但存在明显缺点:
-
每个连接需要一个独立线程;
-
大量连接时,线程切换和内存开销巨大;
-
CPU 大部分时间在等待 I/O,而不是计算。
二、事件驱动的非阻塞思想
事件驱动模式(Reactor 模式)引入了一个事件循环(event loop)和回调机制(callback):
-
程序不再主动调用
recv()、accept()等系统调用; -
而是通过注册事件告诉系统:"当有新连接或数据到达时,请通知我"。
当内核检测到对应的 socket 可读或可写时,会通知事件循环,事件循环再调用你注册的回调函数 onMessage() 或 onWriteComplete()。
这就是"不主动拉取(pull)数据,而是等待事件推送(push)"的思维转变。
总结:思维模式的转换
| 阻塞模型 | 事件驱动模型 |
|---|---|
主动调用 accept/recv/send |
注册回调函数 |
| 每个连接一个线程 | 单线程+事件循环 |
| 线程被I/O阻塞 | 事件驱动、无阻塞 |
| 简单但低效 | 思维复杂但高性能 |
TCP网络编程的核心抽象模型 ------即"三个半事件"
三个半事件的整体关系
| 阶段 | 事件 | 系统调用 | 回调函数 | 功能 |
|---|---|---|---|---|
| 1 | 连接建立 | accept / connect | onConnection | 创建连接对象 |
| 2 | 消息到达 | read | onMessage | 数据接收与处理 |
| 2.5 | 消息发送完毕 | write | onWriteComplete | 输出缓冲清空 |
| 3 | 连接断开 | close / read=0 | onClose | 清理资源 |
一、连接的建立(Connection Establishment)
1. 服务端:accept()
-
当客户端发起
connect()请求时,内核会完成 TCP 的三次握手。 -
服务器监听 socket(listenfd)会变为可读 (即
EPOLLIN),这意味着有新连接请求等待被accept()。 -
调用
accept()取出连接后,得到一个新的 socket fd(连接套接字)。
注意:
-
accept()必须在非阻塞模式下使用。 -
新的连接 fd 才用于后续通信。
二、连接的断开(Connection Teardown)
TCP连接的关闭也有两个方向,因此分为:
1. 主动关闭(主动调用 close() 或 shutdown())
-
应用层调用
shutdown(fd, SHUT_WR)表示不再发送数据; -
之后若对方也关闭其写端,
read()会返回 0。
2. 被动关闭(检测到对方关闭)
-
当本端调用
read()返回 0,表示对方关闭了连接; -
此时应关闭该 fd 并清理资源。
三、消息到达(Message Arrived)
这是最核心的事件:
-
对应文件描述符可读事件(
EPOLLIN) -
本质上是:socket 缓冲区中有数据可读
-
触发时需要:
-
从内核缓冲区读取数据
-
处理"粘包/拆包"问题
-
投递给上层协议解析或业务逻辑
-
设计要点:
-
非阻塞 I/O + 应用层缓冲区
-
避免阻塞 read;
-
设计环形缓冲或动态 buffer;
-
-
消息边界处理
- 对于流式协议(TCP),需要在应用层解决分包、粘包;
-
异步处理模型
- 可以通过线程池、任务队列解耦读写与计算。
消息发送完毕(Write Complete)
之所以称为"半个事件",是因为它并非所有应用都需要关心。
-
对应文件描述符可写事件 (
EPOLLOUT); -
意味着:
-
内核发送缓冲区有空间;
-
应用层之前未写完的数据现在可以继续写;
-
或者此前的异步发送任务已完成。
-
注意:
-
"写完"只表示数据进入内核发送缓冲区;
-
真正送达对方需要 TCP 协议层保证(由内核自动重传);
-
高流量服务必须关注写事件(防止 write EAGAIN / 发送阻塞);
-
低流量服务可以直接假设一次写完,无需复杂逻辑。
细节注意
1. 主动关闭如何确保数据已发完?
目标:先把应用层输出缓冲(output buffer)完全写入内核,再"半关闭"写端,等待对端确认关闭或超时回收。
要点
-
绝不 在输出缓冲未清空时直接
close(fd)。 -
采用**"写完再关写端"**:当
outputBuffer.empty()时执行shutdown(fd, SHUT_WR)。 -
可选:在进入半关闭后设置定时器(如 30--120s)防止对端不发 FIN 导致长时间占用。
-
若业务需要"对端确收"语义,需应用层 ACK 协议配合,TCP 本身只能保证"已入对端内核/重传可靠",无法保证"对端应用已处理"。
2. 主动发起连接被拒绝,如何带退避重试?
要点
-
非阻塞
connect()之后以可写事件 判定完成;失败错误码如ECONNREFUSED、ETIMEDOUT。 -
使用指数退避 + 抖动(jitter) :
delay = min(base * 2^k + rand(0, δ), maxDelay),避免雪崩同步。 -
通过事件循环的定时器重试;失败次数过多时进入熔断窗口。
3. EPOLL 的 LT vs ET:如何避免忙轮询或漏读饥饿?
电平触发(LT, level-triggered)
-
适合大多数场景,简单稳妥。
-
EPOLLOUT 订阅策略 :仅当
outputBuffer非空时启用;写空后立刻关闭EPOLLOUT。否则会busy-loop。 -
读回调:循环
read()直到EAGAIN,虽是 LT,但这样能减少唤醒与系统调用次数。
边沿触发(ET, edge-triggered)
-
每次就绪仅通知一次;必须在回调里把能读的全部读完 、能写的尽量写完 ,直到返回
EAGAIN。 -
若未"榨干"内核缓冲,本次边沿已消费,下次无事件→漏读饥饿。
-
ET 更高效但更容易出错,建议只有在压测证明必要时采用。
4. epoll 一定比 poll 快吗?
-
大规模 fd(上万) :
epoll通常优于poll/select(就绪集合输出、避免每次传大数组、O(ready) 语义)。 -
小规模 fd(几十/几百) :两者差距可能不明显,性能由内存局部性、锁、回调开销等决定。
-
结论:在高并发长连接 服务中优先
epoll;在低并发/短生命周期工具程序中差异可忽略。
5. 为什么需要应用层发送缓冲区(output buffer)?
场景:一次要发 40 KB,但 OS 发送缓冲还剩 25 KB。
-
直接
write()可能只写 25 KB,剩余 15 KB必须在应用层缓存 ,等待EPOLLOUT再续写。 -
若上层接着又发 50 KB,而 output 中仍有残留数据,必须先排队再写 ,保证应用层顺序与 TCP 字节流顺序一致。
-
不得在 output 未清空时"抢写"(另起一次
write()),否则乱序(应用层语义层面的乱序)风险上升。
关键规则:
-
只要 output 非空,就不直写用户新数据,先 append 再由写事件一次性排空;
-
只在 output 从空变非空时打开 EPOLLOUT ,从非空变空时关闭 EPOLLOUT。
6. 为什么需要应用层接收缓冲区(input buffer)?
原因
-
TCP 是字节流 ,无消息边界:一次
read()既可能读到 0.5 个包,也可能 1.5 个包。 -
必须把零散数据存进 input buffer,由增量解析器 (state machine / frame decoder)从中提取完整帧。
-
处理"每字节触发"极端情况:哪怕每 10 ms 到一个字节,也应靠 input buffer 聚合、靠解析器状态机拼包。
实践
-
固定头部(含长度)、分隔符(如
\r\n\r\n)、TLV、变长 varint......都应有鲁棒的增量解析 与读超时。 -
历史教训:对分隔符处理不当会触发安全漏洞 (过度读、注入、阻塞等)。严格做边界检查 与超时丢弃
7. 缓冲区如何设计,既减少系统调用又节省内存?
目标:高吞吐、低内存、低分配次数。
-
读路径 :
readv()+ 双缓冲策略 。先把数据读入 input buffer 剩余可写区;若不足,再把多余数据暂存到栈上临时大块 (如 64 KB)iovec,读完后一次性 append 到 input(避免二次read()与频繁扩容)。 -
延迟分配 :连接建立时只给极小缓冲;根据实际数据量按需扩容 ,并在空闲时收缩。
-
上限控制 :对 input/output 设置软/硬上限,防御异常客户端。
-
对象池/arena:高频分配的 buffer、连接对象可池化(注意碎片与生命周期管理)。
8. 防止发送缓冲失控(背压 & 流量控制)
问题 :对端处理慢,output 越积越多,导致服务端内存暴涨。
措施
-
高水位线回调(high-water mark) :当
output.size()超过阈值(如 512 KB/连接),触发回调:-
暂停上游生产(应用或业务管道);
-
或对该连接停读 (
disable EPOLLIN)以触发 TCP 端到端背压(对端滑窗耗尽)。
-
-
硬限制 :超过硬上限(如 2--8 MB/连接)直接断开或丢弃低优先级数据,避免拖垮进程。
-
队列分级 :区分控制/心跳/关键业务与大体量数据的优先级,必要时丢弃次要流量。
-
全局保护 :总输出缓冲达到进程级阈值时,进入限流/降级/拒绝新连接。
9. 定时器设计并与网络 I/O 共线程
原则 :单线程事件循环 同时处理 I/O 与计时任务,避免锁。
实现选型
-
时间轮(hierarchical timing wheel):海量短时定时器,近似 O(1) Tick;
-
小根堆 / 红黑树:支持多样期限与取消操作,操作 O(log N);
-
Linux
timerfd:把定时器变成一个可读 fd,直接并入 epoll; -
事件循环每次在
epoll_wait()前设置超时为最近到期定时器的剩余时间。
典型用途
-
连接空闲超时(idle timeout);
-
读/写超时(防粘死);
-
重连退避;
-
优雅关闭守护;
-
心跳与健康检查。
推荐的事件与状态管理"黄金法则"
-
只在需要时订阅 EPOLLOUT ;读事件长期打开但要循环读到 EAGAIN。
-
ET 模式必须 读/写到
EAGAIN;LT 模式也建议尽量"榨干"。 -
所有系统调用都要处理 EAGAIN/EINTR,并在错误码上做明确分支。
-
连接状态机 :
Connecting → Connected → HalfClosing(Write) → Closed,明确可转移边与回调触发点。 -
统一的 Buffer 与 Parser 抽象:复用代码路径,降低边界错误。
-
可观测性:为每条连接暴露关键指标(in/out buffer 长度、读写频率、阻塞时长、重连次数、退避级别、定时器活跃数)。