高性能服务器编程笔记一

高性能服务器编程笔记一

时间:2026/04/10

关键词:epoll、LT / ET、Reactor、非阻塞 socket、timerfdeventfdsignalfd、线程池、HTTP、网络调优
核心目标:把 Linux 服务端里最常见的一条工程主线串起来,即“事件循环 + 非阻塞 I/O + 协议解析 + 任务分发 + 排障调优”。


1. epoll 的 LT / ET 模式与 Reactor

1.1 Reactor 到底在做什么

Reactor 的核心不是“某个库”,而是一种组织方式:

  1. epoll_wait() 等待一批就绪事件
  2. 根据 fd 和事件类型找到对应处理器
  3. 执行 accept/read/write/timer/signal 等处理逻辑
  4. 业务计算如果比较重,就把它移交给工作线程
  5. 结果回到 I/O 线程后,再负责发包

可以把它理解成:

epoll 负责告诉你“谁准备好了”,Reactor 负责定义“准备好之后该怎么处理”。

一个典型 TCP Reactor 流程:

1
2
3
4
5
listen fd -> accept -> conn fd
conn fd readable -> read into buffer
buffer -> parse protocol -> dispatch business task
business result -> enqueue response
conn fd writable -> flush output buffer

1.2 LT 和 ET 的区别

模式 含义 行为特点 优点 风险
LT Level Triggered,水平触发 只要 fd 仍可读/可写,就会反复通知 语义直观,代码更稳 重复通知较多
ET Edge Triggered,边沿触发 只在状态从“不可读”变“可读”时通知一次 通知更少,适合高并发 容易因为没读干净而丢事件

LT 是默认模式,更像“提醒式”:

  • 套接字里还有数据没读完,下一轮 epoll_wait() 还会继续通知
  • 对初学和排查问题更友好

ET 更像“状态变化通知”:

  • 一次从无到有的变化,只提醒一次
  • 如果你这次没把数据读到 EAGAIN,内核通常不会再额外提醒你

所以 ET 的两个前提几乎是硬规则:

  • fd 必须设成非阻塞
  • read/write/accept 都要循环到 EAGAINEWOULDBLOCK

1.3 ET 下为什么必须“读空 / 写尽”

ET 的常见错误写法是:

1
2
// 错误示意:只读一次
ssize_t n = recv(fd, buf, sizeof(buf), 0);

如果内核缓冲区里其实还有数据没读完,那么这批“剩余数据”不会再触发新边沿,连接就可能卡住。

正确思路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for (;;) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
// 处理数据
continue;
}
if (n == 0) {
// 对端关闭
close(fd);
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 真的读空了
break;
}
// 其他错误
close(fd);
break;
}

accept() 也是同样逻辑。监听 fd 用 ET 时,应该把已完成连接队列里的连接一次性 accept 干净。

1.4 epoll 中常见的事件组合

1
ev.events = EPOLLIN | EPOLLRDHUP | EPOLLERR;

常见关注点:

  • EPOLLIN:可读
  • EPOLLOUT:可写
  • EPOLLRDHUP:对端半关闭,常用于尽早发现连接关闭
  • EPOLLERR:错误事件
  • EPOLLHUP:挂起
  • EPOLLET:启用 ET
  • EPOLLONESHOT:触发一次后自动失活,适合多线程下避免同一连接被并发处理

工程上常见搭配:

  • 单线程 Reactor:EPOLLIN | EPOLLRDHUP
  • ET Reactor:EPOLLIN | EPOLLRDHUP | EPOLLET
  • 多线程连接处理:EPOLLIN | EPOLLRDHUP | EPOLLONESHOT

还有一个常见实践:

  • EPOLLOUT 往往只在“发送缓冲里还有没发完的数据”时才临时关注
  • 如果平时一直常驻监听 EPOLLOUT,很多 socket 会长期表现为“可写”,导致无意义唤醒

1.5 LT / ET 怎么选

建议顺序很明确:

  1. 先用 LT 把协议、状态机、错误处理做对
  2. 确认瓶颈真的在事件通知频率,再考虑 ET
  3. 如果用了 ET,先检查“是否所有 I/O 路径都循环到 EAGAIN

不要把 ET 当成“默认更高级”的模式。很多线上服务最后依然选择 LT,因为:

  • 业务瓶颈常常不在 epoll
  • LT 更容易做正确
  • 排障成本明显更低

2. 非阻塞 socket 的错误处理

2.1 非阻塞不是“没有阻塞”,而是“阻塞变成返回码”

设置非阻塞通常是:

1
2
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

设置后,很多原本会阻塞的操作不再睡眠,而是直接返回:

  • EAGAIN
  • EWOULDBLOCK
  • EINPROGRESS

这意味着:

非阻塞编程的关键不是“调用成功”,而是“正确解释暂时失败”。

2.2 connect() 的处理

非阻塞 connect() 最常见的返回是:

  • 0:立即连接成功
  • -1 + errno == EINPROGRESS:连接正在进行中,这是正常路径
  • -1 + errno == EINTR:被信号打断,通常继续按“连接中”处理或重试检查
  • -1 + ECONNREFUSED / ENETUNREACH / ETIMEDOUT:明确失败

EINPROGRESS 之后,不能仅凭“fd 可写”就认定连接成功。正确做法是:

1
2
3
4
5
6
7
8
int err = 0;
socklen_t len = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len);
if (err == 0) {
// connect 成功
} else {
// connect 失败,err 才是真正错误码
}

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 不是“没数据”,而是 EOF
  • EAGAIN 不是错误,而是“暂时读不到”

2.5 send() / write() 的处理

非阻塞写最容易踩的坑有两个:

  1. 只写了一部分
  2. 对已关闭连接写,触发 EPIPE / SIGPIPE

正确思路:

  • 维护用户态发送缓冲区
  • send() 返回部分字节时,把未发送部分保留到下次继续
  • EAGAIN 时注册 EPOLLOUT,等可写后继续刷
  • MSG_NOSIGNAL 或忽略 SIGPIPE

一个典型发送循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (;;) {
ssize_t n = send(fd, out + sent, len - sent, MSG_NOSIGNAL);
if (n > 0) {
sent += (size_t)n;
if (sent == len) {
break;
}
continue;
}
if (n < 0 && errno == EINTR) {
continue;
}
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 还没发完,等 EPOLLOUT
break;
}
// EPIPE / ECONNRESET / 其他错误
close(fd);
break;
}

2.6 一些工程上必须记住的点

  • EINTR 通常不是失败,而是“重来一次”
  • EAGAIN 通常不是失败,而是“现在先停一下”
  • EPOLLERR 到来时,要用 getsockopt(... SO_ERROR ...) 取真实错误
  • 半关闭连接要配合 EPOLLRDHUP、协议状态和发送缓冲综合判断
  • 非阻塞只是避免线程睡死,不会替你处理半包、乱序状态和应用层背压

3. timerfd / eventfd / signalfd

3.1 为什么这三个 fd 很重要

它们的共同价值是:

把“时间、线程通知、信号”也统一成 fd,交给 epoll 一起处理。

这样事件循环里就不需要:

  • 到处插异步回调
  • 自己维护复杂的管道唤醒逻辑
  • 在信号处理函数里做太多不安全操作

3.2 timerfd

timerfd 用于把定时器变成可读 fd。

常见用途:

  • 心跳检测
  • 超时连接清理
  • 定时重传
  • 周期性统计采样

基本用法:

1
2
3
4
5
6
7
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

struct itimerspec its;
memset(&its, 0, sizeof(its));
its.it_value.tv_sec = 1; // 1 秒后首次触发
its.it_interval.tv_sec = 1; // 之后每秒触发一次
timerfd_settime(tfd, 0, &its, NULL);

事件到来后要 read() 一个 uint64_t

1
2
uint64_t expirations = 0;
read(tfd, &expirations, sizeof(expirations));

这个值表示自上次读取以来一共超时了多少次,避免丢 tick。

3.3 eventfd

eventfd 本质是一个 64 位计数器,特别适合做线程间唤醒。

常见用途:

  • 工作线程把结果投递给 I/O 线程后,唤醒它
  • 多生产者向事件循环提交异步任务
  • 替代“自建 pipe + 一个字节通知”的老办法
1
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);

写端:

1
2
uint64_t one = 1;
write(efd, &one, sizeof(one));

读端:

1
2
uint64_t cnt = 0;
read(efd, &cnt, sizeof(cnt));

读出来的是累计值,所以常见模式是:

  • 读一次把计数清掉
  • 顺手把任务队列里的任务批量取完

3.4 signalfd

signalfd 用来把信号也纳入事件循环。

典型场景:

  • 收到 SIGTERM 时优雅退出
  • 收到 SIGHUP 时重载配置
  • 不想把复杂逻辑塞进传统信号处理函数

用法关键点:

  1. 先用 sigprocmask() 阻塞这些信号
  2. 再创建 signalfd
  3. epoll 里监听它
1
2
3
4
5
6
7
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);

int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

读取后可得到:

1
2
struct signalfd_siginfo si;
read(sfd, &si, sizeof(si));

然后根据 si.ssi_signo 做统一处理。

3.5 三者如何和 Reactor 配合

一个典型事件循环会把它们全放进 epoll

  • 连接 fd:读写事件
  • timerfd:超时检查、定时任务
  • eventfd:跨线程任务投递
  • signalfd:退出 / 重载 / 信号治理

这样主循环就更像一个统一调度器,而不是“网络逻辑 + 一堆额外异步机制拼起来的东西”。


4. 线程池与 I/O 线程分层

4.1 一个更稳的分层模型

高性能服务器里,一个常见的稳定结构是:

1
2
3
4
5
6
7
8
9
10
11
12
主线程/Acceptor
-> 接连接并分发到某个 I/O 线程

I/O 线程
-> 跑 event loop
-> 管 socket、收发包、协议解析、连接状态机

Worker 线程池
-> 跑 CPU 密集或潜在阻塞的业务逻辑

回到 I/O 线程
-> 序列化响应并发送

核心原则是:

socket 的生命周期和输出顺序,最好由固定 I/O 线程拥有。

4.2 为什么不让工作线程直接操作 socket

因为这样很容易引入:

  • 一个连接被多个线程同时读写
  • 发送顺序被打乱
  • 锁粒度变大,缓存局部性变差
  • 排障时很难定位“这个连接到底归谁管”

更稳的做法是:

  • I/O 线程负责网络层和协议层
  • Worker 线程只处理业务输入,生成业务结果
  • 结果通过无锁队列或锁队列回投给归属 I/O 线程
  • eventfd 唤醒 I/O 线程继续发送

4.3 一个常见的任务流

  1. I/O 线程收到请求,完成协议解析
  2. 组装一个“业务任务”对象,投递到线程池
  3. Worker 线程执行业务逻辑,得到响应对象
  4. 把响应对象投递回对应 I/O 线程的待发送队列
  5. 向该 I/O 线程的 eventfd 写入计数
  6. 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 处理流程

  1. I/O 线程把字节流追加到连接输入缓冲
  2. 解析器尝试从当前缓冲推进状态机
  3. 如果请求还不完整,就停下来等更多数据
  4. 如果请求完整,就生成请求对象
  5. 业务层处理后,再序列化成 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

更稳的方法是先回答三个问题:

  1. 瓶颈在应用、内核、网卡,还是对端?
  2. 问题是吞吐不够、延迟过高,还是抖动严重?
  3. 是连接建立阶段有问题,还是已建立连接的收发有问题?

6.2 常见调优项

下面这些参数最常见,但不应该脱离场景硬改。

方向 参数/项 作用
fd 上限 ulimit -nfs.file-max 控制可打开文件句柄数量
监听队列 net.core.somaxconn 限制 listen backlog 上限
SYN 队列 net.ipv4.tcp_max_syn_backlog 半连接队列容量
网卡收包积压 net.core.netdev_max_backlog 网卡到协议栈的积压队列
Socket 缓冲 net.core.rmem_maxnet.core.wmem_max 接收/发送缓冲上限
TCP 缓冲 net.ipv4.tcp_rmemnet.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_REUSEADDR
  • SO_REUSEPORT
  • TCP_NODELAY
  • TCP_QUICKACK
  • 进程实际打开 fd 数量
  • 网卡多队列、RSS、RPS/XPS 是否匹配

6.3 几类典型现象怎么查

1. 新连接进不来

先看:

  • ss -lnt
  • ss -s
  • cat /proc/sys/net/core/somaxconn
  • cat /proc/sys/net/ipv4/tcp_max_syn_backlog

再判断是:

  • 应用根本没及时 accept
  • accept 队列太小
  • SYN 队列顶满
  • 进程 fd 已耗尽

2. 连接很多,但吞吐很低

优先排查:

  • I/O 线程是否被业务阻塞
  • 发送缓冲是否长期积压
  • 是否出现大量重传
  • 对端是否慢读

常用命令:

  • ss -tin
  • sar -n DEV 1
  • ethtool -S eth0
  • ip -s link
  • perf top -p <pid>

3. 延迟抖动大、尾延迟高

常见方向:

  • 线程太多导致调度抖动
  • GC / 内存分配 / 大对象复制
  • 某些慢任务卡住 worker
  • 日志同步刷盘
  • I/O 线程偶发做了阻塞操作

这时经常要结合:

  • pidstat -t 1 -p <pid>
  • perf record/report
  • strace -tt -p <pid>

4. 连接莫名被断开

需要区分:

  • 对端主动关闭
  • 中间网络设备超时清理
  • 写关闭触发 EPIPE
  • ECONNRESET
  • 自己应用层超时策略太激进

这时 tcpdump 很有价值:

  • tcpdump -nn -i any tcp port 8080

抓包后重点看:

  • 谁先发 FIN
  • 是否有 RST
  • 是否反复重传
  • 三次握手是否完整

6.4 排障时建议按这条线走

  1. 先看应用日志和连接状态
  2. 再看 sssarpidstatperf
  3. 再看 /procsysctl、网卡统计
  4. 最后再决定是否抓包

不要一上来就抓包,也不要一上来就调内核参数。

6.5 一些很实用的排障清单

看到“连接接不进来”时,先检查:

  • 进程是否在监听正确地址和端口
  • backlog 是否足够
  • fd 是否打满
  • 是否被防火墙或安全组拦截

看到“CPU 很高”时,先检查:

  • 热点在业务代码、锁、内存分配,还是系统调用
  • epoll_wait 是否其实不忙,真正忙的是工作线程
  • 是否有大量空转 wakeup

看到“内存一直涨”时,先检查:

  • 输入缓冲 / 输出缓冲是否无上限
  • 请求队列是否积压
  • 是否慢连接太多
  • 是否大包被整包缓存

7. 小结

把这些主题串起来,其实就是一条完整的服务端工程路径:

  1. epoll 搭 Reactor,先 LT 做对,再考虑 ET
  2. 用非阻塞 socket 正确处理 EINPROGRESSEAGAIN、部分读写和连接关闭
  3. timerfdeventfdsignalfd 把“时间 / 线程通知 / 信号”并入统一事件循环
  4. 让 I/O 线程管连接,让线程池管业务,结果再回到 I/O 线程发出
  5. 用状态机解析 HTTP 或自定义协议,把包边界、版本和错误语义设计清楚
  6. 出问题时先观察现象和链路,再做内核调优

如果把这些基础打稳,后面再看:

  • sendfile/splice 零拷贝
  • SO_REUSEPORT + 多队列
  • io_uring
  • 更复杂的 RPC 框架

就会轻松很多。