高性能服务器编程笔记一
高性能服务器编程笔记一
时间:2026/04/10
关键词:
epoll、LT / ET、Reactor、非阻塞 socket、timerfd、eventfd、signalfd、线程池、HTTP、网络调优
核心目标:把 Linux 服务端里最常见的一条工程主线串起来,即“事件循环 + 非阻塞 I/O + 协议解析 + 任务分发 + 排障调优”。
1. epoll 的 LT / ET 模式与 Reactor
1.1 Reactor 到底在做什么
Reactor 的核心不是“某个库”,而是一种组织方式:
- 用
epoll_wait()等待一批就绪事件 - 根据 fd 和事件类型找到对应处理器
- 执行
accept/read/write/timer/signal等处理逻辑 - 业务计算如果比较重,就把它移交给工作线程
- 结果回到 I/O 线程后,再负责发包
可以把它理解成:
epoll负责告诉你“谁准备好了”,Reactor 负责定义“准备好之后该怎么处理”。
一个典型 TCP Reactor 流程:
1 | listen fd -> accept -> conn fd |
1.2 LT 和 ET 的区别
| 模式 | 含义 | 行为特点 | 优点 | 风险 |
|---|---|---|---|---|
| LT | Level Triggered,水平触发 | 只要 fd 仍可读/可写,就会反复通知 | 语义直观,代码更稳 | 重复通知较多 |
| ET | Edge Triggered,边沿触发 | 只在状态从“不可读”变“可读”时通知一次 | 通知更少,适合高并发 | 容易因为没读干净而丢事件 |
LT 是默认模式,更像“提醒式”:
- 套接字里还有数据没读完,下一轮
epoll_wait()还会继续通知 - 对初学和排查问题更友好
ET 更像“状态变化通知”:
- 一次从无到有的变化,只提醒一次
- 如果你这次没把数据读到
EAGAIN,内核通常不会再额外提醒你
所以 ET 的两个前提几乎是硬规则:
- fd 必须设成非阻塞
read/write/accept都要循环到EAGAIN或EWOULDBLOCK
1.3 ET 下为什么必须“读空 / 写尽”
ET 的常见错误写法是:
1 | // 错误示意:只读一次 |
如果内核缓冲区里其实还有数据没读完,那么这批“剩余数据”不会再触发新边沿,连接就可能卡住。
正确思路是:
1 | for (;;) { |
accept() 也是同样逻辑。监听 fd 用 ET 时,应该把已完成连接队列里的连接一次性 accept 干净。
1.4 epoll 中常见的事件组合
1 | ev.events = EPOLLIN | EPOLLRDHUP | EPOLLERR; |
常见关注点:
EPOLLIN:可读EPOLLOUT:可写EPOLLRDHUP:对端半关闭,常用于尽早发现连接关闭EPOLLERR:错误事件EPOLLHUP:挂起EPOLLET:启用 ETEPOLLONESHOT:触发一次后自动失活,适合多线程下避免同一连接被并发处理
工程上常见搭配:
- 单线程 Reactor:
EPOLLIN | EPOLLRDHUP - ET Reactor:
EPOLLIN | EPOLLRDHUP | EPOLLET - 多线程连接处理:
EPOLLIN | EPOLLRDHUP | EPOLLONESHOT
还有一个常见实践:
EPOLLOUT往往只在“发送缓冲里还有没发完的数据”时才临时关注- 如果平时一直常驻监听
EPOLLOUT,很多 socket 会长期表现为“可写”,导致无意义唤醒
1.5 LT / ET 怎么选
建议顺序很明确:
- 先用 LT 把协议、状态机、错误处理做对
- 确认瓶颈真的在事件通知频率,再考虑 ET
- 如果用了 ET,先检查“是否所有 I/O 路径都循环到
EAGAIN”
不要把 ET 当成“默认更高级”的模式。很多线上服务最后依然选择 LT,因为:
- 业务瓶颈常常不在
epoll - LT 更容易做正确
- 排障成本明显更低
2. 非阻塞 socket 的错误处理
2.1 非阻塞不是“没有阻塞”,而是“阻塞变成返回码”
设置非阻塞通常是:
1 | int flags = fcntl(fd, F_GETFL, 0); |
设置后,很多原本会阻塞的操作不再睡眠,而是直接返回:
EAGAINEWOULDBLOCKEINPROGRESS
这意味着:
非阻塞编程的关键不是“调用成功”,而是“正确解释暂时失败”。
2.2 connect() 的处理
非阻塞 connect() 最常见的返回是:
0:立即连接成功-1 + errno == EINPROGRESS:连接正在进行中,这是正常路径-1 + errno == EINTR:被信号打断,通常继续按“连接中”处理或重试检查-1 + ECONNREFUSED / ENETUNREACH / ETIMEDOUT:明确失败
EINPROGRESS 之后,不能仅凭“fd 可写”就认定连接成功。正确做法是:
1 | int err = 0; |
2.3 accept() 的处理
非阻塞监听 fd 常见返回:
EAGAIN / EWOULDBLOCK:当前已没有新连接,退出循环EINTR:被信号打断,继续ECONNABORTED:连接在accept前就中止了,记录后继续接下一个EMFILE / ENFILE:进程或系统 fd 用尽,这是高优先级故障
EMFILE 的工程应对通常包括:
- 提前把
ulimit -n和系统文件句柄上限调够 - 预留一个“空闲 fd”作为应急位
- 日志里把错误打出来,不要静默丢掉
2.4 recv() / read() 的处理
| 返回值 / 错误码 | 含义 | 处理建议 |
|---|---|---|
n > 0 |
收到数据 | 进入协议解析 |
n == 0 |
对端正常关闭 | 回收连接 |
EAGAIN/EWOULDBLOCK |
当前读空了 | 等下次读事件 |
EINTR |
被信号打断 | 继续读 |
ECONNRESET |
对端复位 | 记录并关闭 |
| 其他错误 | 异常 | 记录并关闭 |
注意:
n == 0不是“没数据”,而是 EOFEAGAIN不是错误,而是“暂时读不到”
2.5 send() / write() 的处理
非阻塞写最容易踩的坑有两个:
- 只写了一部分
- 对已关闭连接写,触发
EPIPE/SIGPIPE
正确思路:
- 维护用户态发送缓冲区
send()返回部分字节时,把未发送部分保留到下次继续EAGAIN时注册EPOLLOUT,等可写后继续刷- 用
MSG_NOSIGNAL或忽略SIGPIPE
一个典型发送循环:
1 | for (;;) { |
2.6 一些工程上必须记住的点
EINTR通常不是失败,而是“重来一次”EAGAIN通常不是失败,而是“现在先停一下”EPOLLERR到来时,要用getsockopt(... SO_ERROR ...)取真实错误- 半关闭连接要配合
EPOLLRDHUP、协议状态和发送缓冲综合判断 - 非阻塞只是避免线程睡死,不会替你处理半包、乱序状态和应用层背压
3. timerfd / eventfd / signalfd
3.1 为什么这三个 fd 很重要
它们的共同价值是:
把“时间、线程通知、信号”也统一成 fd,交给
epoll一起处理。
这样事件循环里就不需要:
- 到处插异步回调
- 自己维护复杂的管道唤醒逻辑
- 在信号处理函数里做太多不安全操作
3.2 timerfd
timerfd 用于把定时器变成可读 fd。
常见用途:
- 心跳检测
- 超时连接清理
- 定时重传
- 周期性统计采样
基本用法:
1 | int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); |
事件到来后要 read() 一个 uint64_t:
1 | uint64_t expirations = 0; |
这个值表示自上次读取以来一共超时了多少次,避免丢 tick。
3.3 eventfd
eventfd 本质是一个 64 位计数器,特别适合做线程间唤醒。
常见用途:
- 工作线程把结果投递给 I/O 线程后,唤醒它
- 多生产者向事件循环提交异步任务
- 替代“自建 pipe + 一个字节通知”的老办法
1 | int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); |
写端:
1 | uint64_t one = 1; |
读端:
1 | uint64_t cnt = 0; |
读出来的是累计值,所以常见模式是:
- 读一次把计数清掉
- 顺手把任务队列里的任务批量取完
3.4 signalfd
signalfd 用来把信号也纳入事件循环。
典型场景:
- 收到
SIGTERM时优雅退出 - 收到
SIGHUP时重载配置 - 不想把复杂逻辑塞进传统信号处理函数
用法关键点:
- 先用
sigprocmask()阻塞这些信号 - 再创建
signalfd - 在
epoll里监听它
1 | sigset_t mask; |
读取后可得到:
1 | struct signalfd_siginfo si; |
然后根据 si.ssi_signo 做统一处理。
3.5 三者如何和 Reactor 配合
一个典型事件循环会把它们全放进 epoll:
- 连接 fd:读写事件
timerfd:超时检查、定时任务eventfd:跨线程任务投递signalfd:退出 / 重载 / 信号治理
这样主循环就更像一个统一调度器,而不是“网络逻辑 + 一堆额外异步机制拼起来的东西”。
4. 线程池与 I/O 线程分层
4.1 一个更稳的分层模型
高性能服务器里,一个常见的稳定结构是:
1 | 主线程/Acceptor |
核心原则是:
socket 的生命周期和输出顺序,最好由固定 I/O 线程拥有。
4.2 为什么不让工作线程直接操作 socket
因为这样很容易引入:
- 一个连接被多个线程同时读写
- 发送顺序被打乱
- 锁粒度变大,缓存局部性变差
- 排障时很难定位“这个连接到底归谁管”
更稳的做法是:
- I/O 线程负责网络层和协议层
- Worker 线程只处理业务输入,生成业务结果
- 结果通过无锁队列或锁队列回投给归属 I/O 线程
- 用
eventfd唤醒 I/O 线程继续发送
4.3 一个常见的任务流
- I/O 线程收到请求,完成协议解析
- 组装一个“业务任务”对象,投递到线程池
- Worker 线程执行业务逻辑,得到响应对象
- 把响应对象投递回对应 I/O 线程的待发送队列
- 向该 I/O 线程的
eventfd写入计数 - I/O 线程被唤醒,刷新待发送队列并
send
这个结构有两个直接好处:
- 连接状态不跨线程乱跑
- 网络收发和业务执行都能独立扩缩
4.4 线程数怎么定
经验上可以先这样起步:
- I/O 线程数:先从
1 ~ CPU 核数之间选,小步压测 - 纯 CPU 业务线程池:通常接近 CPU 核数
- 会阻塞的业务:单独拆出阻塞线程池,不要和 CPU 任务混用
不要一开始就把线程开得很多。线程越多,不一定吞吐越高,反而可能带来:
- 调度开销
- 锁竞争
- cache miss
- 尾延迟抖动
4.5 线程模型里的常见坑
- 把数据库、磁盘、RPC 阻塞调用放进 I/O 线程
- 一个大线程池同时跑 CPU 任务和慢 I/O 任务
- 没有限制任务队列长度,导致内存膨胀
- 没做背压,读得太快、处理太慢、写缓冲越积越大
- 让 worker 直接
close(fd)或直接修改连接对象状态
更稳的工程实践通常包括:
- 每个连接有明确 owner I/O 线程
- 回投结果时只传消息,不直接抢 socket
- 对线程池队列、连接输入缓冲、输出缓冲都设上限
- 把“超时、丢弃、降级”当成正式设计,而不是补丁
5. HTTP 解析与应用层协议设计
5.1 HTTP 解析不要靠“字符串碰运气”
HTTP/1.1 在服务端最常见的解析路径是增量状态机:
1 | REQUEST_LINE -> HEADERS -> BODY -> DONE |
原因很简单,TCP 是字节流,所以:
- 一次
recv()可能只收到半个请求行 - 也可能一次收到“一个半请求”
- keep-alive 连接上还可能连续收到多个请求
所以解析器一般要面对的是“缓冲区里的不完整字节流”,而不是“一次 recv() 就是一条完整 HTTP 请求”。
5.2 一个更合理的 HTTP 处理流程
- I/O 线程把字节流追加到连接输入缓冲
- 解析器尝试从当前缓冲推进状态机
- 如果请求还不完整,就停下来等更多数据
- 如果请求完整,就生成请求对象
- 业务层处理后,再序列化成 HTTP 响应
最少要覆盖这些点:
- 请求行解析:方法、URL、版本
- 头部解析:按
\r\n切分 - 空行判定:头部结束
- body 长度:
Content-Length - 分块传输:
Transfer-Encoding: chunked - keep-alive:一条连接上连续请求
- 大包限制:头部长度、body 长度都要有限制
5.3 HTTP 工程上常见坑
- 只支持
Content-Length,完全忽略 chunked - 没有限制 header 大小,容易被慢请求或畸形请求拖死
- 把一整个大 body 一次性读入内存,导致峰值过高
- 没处理 pipeline/连续请求,缓冲区里残留数据被误丢
- 业务处理过慢但连接持续可读,导致输入缓冲无限增长
如果是自己练习写 HTTP server,建议至少加上:
- header 大小上限
- body 大小上限
- header 读取超时
- 空闲连接超时
- 非法请求快速返回
400
5.4 应用层协议设计,比“能传输”更重要
如果不是做 HTTP,而是做自定义 RPC / 游戏 / 推送协议,最重要的是先把“帧边界”定义清楚。
最常见的做法是长度字段协议:
1 | | magic | version | type | request_id | body_len | body... | |
这样解析时可以先读固定头,再按 body_len 判断包体是否完整。
比起纯文本协议,它的优势是:
- 包边界明确
- 解析开销更稳定
- 扩展字段更容易版本化
5.5 自定义协议建议至少包含这些元素
magic:快速识别协议,防止串流或脏数据version:协议演进时兼容旧客户端type:请求类型 / 响应类型 / 心跳 / 控制消息request_id:做请求响应关联,便于并发与排障body_len:确定包体边界- 可选
flags:压缩、序列化格式、错误码等
还要配套以下约束:
- 最大包长限制
- 超时与重试语义
- 幂等语义
- 错误响应格式
- 压缩/加密是否协商
5.6 协议设计和线程模型其实是连在一起的
如果协议天然支持:
- 明确包边界
- 快速校验合法性
- 带
request_id - 支持分片/流式 body
那么线程池和 I/O 线程的协作会简单很多。
反过来,如果协议定义含糊,后面就容易出现:
- 半包难处理
- 多请求复用难关联
- 排障日志定位困难
- 一旦扩协议版本就全链路改动
6. Linux 网络调优参数与排障方法
6.1 先记住一个原则:先观察,再调参
网络调优里最常见的误区是:
- 一看到超时就改
sysctl - 一看到连接多就盲目加线程
- 一看到吞吐不高就切 ET
更稳的方法是先回答三个问题:
- 瓶颈在应用、内核、网卡,还是对端?
- 问题是吞吐不够、延迟过高,还是抖动严重?
- 是连接建立阶段有问题,还是已建立连接的收发有问题?
6.2 常见调优项
下面这些参数最常见,但不应该脱离场景硬改。
| 方向 | 参数/项 | 作用 |
|---|---|---|
| fd 上限 | ulimit -n、fs.file-max |
控制可打开文件句柄数量 |
| 监听队列 | net.core.somaxconn |
限制 listen backlog 上限 |
| SYN 队列 | net.ipv4.tcp_max_syn_backlog |
半连接队列容量 |
| 网卡收包积压 | net.core.netdev_max_backlog |
网卡到协议栈的积压队列 |
| Socket 缓冲 | net.core.rmem_max、net.core.wmem_max |
接收/发送缓冲上限 |
| TCP 缓冲 | net.ipv4.tcp_rmem、net.ipv4.tcp_wmem |
TCP 自适应缓冲范围 |
| 端口范围 | net.ipv4.ip_local_port_range |
客户端侧临时端口范围 |
| TIME_WAIT | net.ipv4.tcp_tw_reuse |
特定场景下重用 TIME_WAIT,需谨慎 |
| SYN 防护 | net.ipv4.tcp_syncookies |
SYN flood 防护 |
还要配合关注:
SO_REUSEADDRSO_REUSEPORTTCP_NODELAYTCP_QUICKACK- 进程实际打开 fd 数量
- 网卡多队列、RSS、RPS/XPS 是否匹配
6.3 几类典型现象怎么查
1. 新连接进不来
先看:
ss -lntss -scat /proc/sys/net/core/somaxconncat /proc/sys/net/ipv4/tcp_max_syn_backlog
再判断是:
- 应用根本没及时
accept - accept 队列太小
- SYN 队列顶满
- 进程 fd 已耗尽
2. 连接很多,但吞吐很低
优先排查:
- I/O 线程是否被业务阻塞
- 发送缓冲是否长期积压
- 是否出现大量重传
- 对端是否慢读
常用命令:
ss -tinsar -n DEV 1ethtool -S eth0ip -s linkperf top -p <pid>
3. 延迟抖动大、尾延迟高
常见方向:
- 线程太多导致调度抖动
- GC / 内存分配 / 大对象复制
- 某些慢任务卡住 worker
- 日志同步刷盘
- I/O 线程偶发做了阻塞操作
这时经常要结合:
pidstat -t 1 -p <pid>perf record/reportstrace -tt -p <pid>
4. 连接莫名被断开
需要区分:
- 对端主动关闭
- 中间网络设备超时清理
- 写关闭触发
EPIPE ECONNRESET- 自己应用层超时策略太激进
这时 tcpdump 很有价值:
tcpdump -nn -i any tcp port 8080
抓包后重点看:
- 谁先发
FIN - 是否有
RST - 是否反复重传
- 三次握手是否完整
6.4 排障时建议按这条线走
- 先看应用日志和连接状态
- 再看
ss、sar、pidstat、perf - 再看
/proc、sysctl、网卡统计 - 最后再决定是否抓包
不要一上来就抓包,也不要一上来就调内核参数。
6.5 一些很实用的排障清单
看到“连接接不进来”时,先检查:
- 进程是否在监听正确地址和端口
- backlog 是否足够
- fd 是否打满
- 是否被防火墙或安全组拦截
看到“CPU 很高”时,先检查:
- 热点在业务代码、锁、内存分配,还是系统调用
epoll_wait是否其实不忙,真正忙的是工作线程- 是否有大量空转 wakeup
看到“内存一直涨”时,先检查:
- 输入缓冲 / 输出缓冲是否无上限
- 请求队列是否积压
- 是否慢连接太多
- 是否大包被整包缓存
7. 小结
把这些主题串起来,其实就是一条完整的服务端工程路径:
- 用
epoll搭 Reactor,先 LT 做对,再考虑 ET - 用非阻塞 socket 正确处理
EINPROGRESS、EAGAIN、部分读写和连接关闭 - 用
timerfd、eventfd、signalfd把“时间 / 线程通知 / 信号”并入统一事件循环 - 让 I/O 线程管连接,让线程池管业务,结果再回到 I/O 线程发出
- 用状态机解析 HTTP 或自定义协议,把包边界、版本和错误语义设计清楚
- 出问题时先观察现象和链路,再做内核调优
如果把这些基础打稳,后面再看:
sendfile/splice零拷贝SO_REUSEPORT+ 多队列io_uring- 更复杂的 RPC 框架
就会轻松很多。