网络IO多路复用
# 07.网络IO多路复用
嵌入式设备不是孤岛——OTA、MQTT、Modbus TCP、远程调试都依赖网络。本节从 socket API 到 epoll Reactor 模式,打通嵌入式网络编程的全路径。
# 目录介绍
# 7.1 案例引入
# 7.1.1 并发 50 路 Modbus TCP 连接 CPU 占用 100%
某工业网关需要同时采集 50 台 PLC 数据,开发用 select + 每个连接一个线程。CPU 单核 100% 满载,且 select 的 1024 fd 上限导致无法扩展。
切换到 单线程 epoll + ET 模式 + 非阻塞 socket:CPU 从 100% 降到 8%,连接数无上限。核心差异:select 每次要传入整个 fd_set 并 O(N) 扫描,epoll 用红黑树+就绪队列只需 O(1) 获取就绪 fd。
select 版的开销拆分(50 个连接):
select(52, &rset, NULL, NULL, NULL) // 每次调用:
├─ 内核:遍历 fd 0~52 → 检查每个 fd 是否可读 → 53 次检查
├─ 用户态→内核态:拷贝整个 fd_set(128 字节)
└─ 返回后:应用层遍历 fd 0~52 → 找哪些 fd 在 rset 中 → 53 次 FD_ISSET
每轮 epoll_wait 后:
└─ 内核:从就绪队列直接取(O(1))→ 只返回已就绪的 fd
└─ 应用层:遍历返回的 events 数组 → 只处理就绪的那几个
# 7.1.2 核心问题
- epoll ET 模式为什么必须配合非阻塞 socket?
select→poll→epoll三代进化的核心差异是什么?为什么 epoll 用红黑树 + 就绪队列?- 嵌入式 4G 网络下的 socket 有什么特殊行为(NAT 超时、IP 切换)?
# 7.2 Socket 基础回顾
# 7.2.1 socket/bind/listen/accept/connect 全景
TCP 服务端标准六步:
int fd = socket(AF_INET, SOCK_STREAM, 0); // 1. 创建 socket
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 2. 端口复用
struct sockaddr_in addr = { // 3. bind
.sin_family = AF_INET,
.sin_port = htons(502), // Modbus TCP 默认端口
.sin_addr.s_addr = INADDR_ANY
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, 128); // 4. listen(backlog=128)
while (1) {
int client = accept(fd, NULL, NULL); // 5. accept
handle_client(client); // 6. 处理
close(client);
}
TCP 客户端三步:
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(502)};
inet_pton(AF_INET, "192.168.1.100", &addr.sin_addr);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
}
listen 的 backlog 参数——不是连接上限! 它是已完成三次握手但尚未 accept 的连接队列长度。Linux 上实际上限是 min(backlog, /proc/sys/net/core/somaxconn)。
# 7.2.2 TCP 三次握手与 Socket API 的对应关系
客户端 服务端
│ │
│ socket() │ socket() + bind() + listen()
│ │
│ connect() ─── SYN ──────→ │ 进入 SYN_RECV 队列(半连接)
│ │
│ ←── SYN+ACK ──── │
│ │
│ connect()返回 ── ACK ───→ │ 移入 accept 队列(全连接)
│ │
│ │ accept() 从全连接队列取一个
accept 成功前,连接已在"全连接队列"中等待!
这就是为什么 fast-open (TFO) 能在第三个包就带数据——
内核在收到 SYN 时就把连接放入队列,accept 后第一个 read 能立刻拿到数据。
SYN Flood 攻击防护——半连接队列满后,新 SYN 被丢弃或采 SYN cookies 替代保存状态。
# 7.2.3 SO_REUSEADDR 与 TIME_WAIT
SO_REUSEADDR 解决的问题——服务端重启后端口仍被占用:
# 没有 SO_REUSEADDR 的后果:
$ ./server &
$ killall server
$ ./server # 立即重启 → bind 失败:Address already in use
TIME_WAIT 状态:主动关闭连接的一方进入 TIME_WAIT,持续 2 × MSL(Linux 上 MSL=30s,所以 TIME_WAIT=60s)。这期间端口被占用。
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// bind 允许重用处于 TIME_WAIT 的端口
SO_REUSEPORT(Linux 3.9+)——更强的端口复用,允许多个 socket 同时 bind 同一端口,内核在各 socket 间负载均衡分发连接:
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
// 多个进程/线程各自 accept,内核自动分发——无需手动多线程 accept
这在多进程 nginx 中实现 "多个 worker 同时 accept 同一端口" 的关键选项。
# 7.2.4 TCP_NODELAY 与 Nagle 算法
Nagle 算法——在收到对端 ACK 之前,积攒小包直到凑满一个 MSS(或超时 200ms),减少网络上的小包。对 Modbus / MQTT 等实时协议是灾难。
// Nagle 算法(默认开启):
write(fd, buf, 10); // 立即发
write(fd, buf, 10); // 等第一个包的 ACK 或攒到 MSS → 延迟 40ms!
write(fd, buf, 10); // 同上
// 关闭 Nagle:
int opt = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
// 每次 write 立即发
TCP_NODELAY 对嵌入式场景的影响:
| 协议 | 是否开 TCP_NODELAY | 原因 |
|---|---|---|
| Modbus TCP | ✅ 必须开 | 请求-响应模式,每个包都是独立的 |
| MQTT QoS 0/1 | ✅ 强烈建议 | 低延迟发布 |
| HTTP REST | ⚠️ 视情况 | 请求通常已够大 |
| OTA 固件下载 | ❌ 不用开 | 大数据块,Nagle 自动攒满 MSS |
| WebSocket | ✅ 建议开 | 双向实时通信 |
# 7.3 I/O 多路复用演进
# 7.3.1 select 的 fd_set 天花板(1024)
fd_set rset;
FD_ZERO(&rset);
FD_SET(fd1, &rset);
FD_SET(fd2, &rset);
struct timeval tv = {.tv_sec = 1};
int n = select(max_fd + 1, &rset, NULL, NULL, &tv);
for (int i = 0; i <= max_fd; i++) {
if (FD_ISSET(i, &rset)) process_fd(i); // O(N) 扫描!
}
select 的四大缺陷:
| 缺陷 | 说明 |
|---|---|
| fd 上限 1024 | fd_set 是位图,默认 FD_SETSIZE=1024(改头文件可突破,但不好) |
| 每次传入整个集合 | 内核态要遍历 0~max_fd 检查每个 fd |
| 用户态复制开销 | 每次 select 都要把 fd_set 从用户态拷贝到内核态 |
| 返回后 O(N) 扫描 | 必须遍历 0~max_fd 找哪些 fd 在结果集中 |
# 7.3.2 poll 的无上限但 O(N) 扫描
struct pollfd fds[64];
fds[0].fd = fd1; fds[0].events = POLLIN;
fds[1].fd = fd2; fds[1].events = POLLIN;
// ... 不受 1024 限制
int n = poll(fds, nfds, 1000);
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) process(fds[i].fd); // 仍是 O(N) 扫描
}
poll vs select:
| 特性 | select | poll |
|---|---|---|
| fd 上限 | 1024 | 无(受内存限制) |
| 数据结构 | 位图 fd_set | 数组 struct pollfd |
| 事件输入输出 | 混在同一位图(每次重设) | 分离(events 输入,revents 输出) |
| 底层实现 | 同 poll(内核都用 do_poll) | 同 select |
| 性能 | O(max_fd) | O(nfds) |
poll 比 select 好在:① 无 1024 限制 ② 事件与结果分离,不用每次 FD_ZERO+FD_SET。但依然 O(N) 扫描。
# 7.3.3 epoll_create/epoll_ctl/epoll_wait 详解
epoll 是 Linux 的终极多路复用——O(1) 获取就绪 fd:
int epfd = epoll_create1(EPOLL_CLOEXEC); // 创建 epoll 实例
struct epoll_event ev = {.events = EPOLLIN, .data.fd = fd1};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev); // 注册 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev);
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, 1000); // 等待
for (int i = 0; i < n; i++) { // 只遍历 n 个就绪 fd
if (events[i].events & EPOLLIN) process(events[i].data.fd);
}
epoll 内核数据结构——为什么是 O(1):
epoll_create → 创建 eventpoll 对象
├── rbr: 红黑树(管理所有被监控的 fd) ← epoll_ctl
│ ├── fd1 (READ)
│ ├── fd2 (READ | WRITE)
│ └── fd3 (READ)
└── rdllist: 就绪链表 ← epoll_wait
└── 数据到达 → fd 挂入 rdllist → 唤醒 epoll_wait
epoll_ctl:向红黑树插入/删除/修改 fd 的监控事件(O(log N))
epoll_wait:从就绪链表取已就绪的 fd(O(1)),直接返回给用户态
为什么 select/poll 必须 O(N) 扫描而 epoll 不需要? select/poll 是无状态的——每次调用都要传入完整 fd 列表,内核不知道"上次哪些 fd 我关心";epoll 是有状态的——红黑树记住了全部 fd,事件驱动地把就绪 fd 挂在链表中。
# 7.3.4 epoll 的 LT 水平触发 vs ET 边缘触发
LT(默认)——只要 fd 可读,每次 epoll_wait 都通知你:
fd 收到 4 KB 数据
→ epoll_wait 返回(第1次通知)
→ read(fd, buf, 1024) # 读了 1 KB,还剩 3 KB
→ epoll_wait 返回(第2次通知!) ← 因为还没读完
→ read(fd, buf, 1024) # 又读 1 KB
→ epoll_wait 返回(第3次通知!)
→ read(fd, buf, 2048) # 读完
→ epoll_wait 阻塞(无数据)
ET——只在 fd 从"不可读"变为"可读"时通知一次:
fd 收到 4 KB 数据
→ epoll_wait 返回(唯一一次通知!)
→ read(fd, buf, 4096) ← 必须循环读直到返回 EAGAIN
ET 必须配合非阻塞 I/O 的原因——如果在 ET 模式下只 read 一次就停下,剩余数据永远不会再触发通知(因为 fd 状态没变——它一直是"可读")。
// ET 模式的正确写法——循环读到 EAGAIN
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) { process(buf, n); continue; }
if (n == 0) { close(fd); break; } // 对端关闭
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 读完
perror("read"); break; // 错误
}
LT vs ET 选择:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 简单网络程序 | LT | 不容易丢数据,默认就是 LT |
| 高性能服务器 | ET | 减少 epoll_wait 被唤醒次数 |
| 流式数据(HTTP body) | ET | 一次读完省通知 |
| 消息协议(Modbus) | ET | 每帧一次通知刚好 |
| 初学者 / 调试 | LT | 容错高,不会因忘循环读而丢数据 |
# 7.3.5 EPOLLONESHOT 的正确使用
ET 模式仍有缺陷——一个 fd 被读出后,在 epoll_wait 返回下一个通知之前,另一线程又开始处理它 → 同一 fd 被两个线程同时处理。
EPOLLONESHOT 保证一个 fd 在任何时刻只被一个线程处理:
struct epoll_event ev = {.events = EPOLLIN | EPOLLONESHOT, .data.fd = fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
// 线程中处理完毕后,重新激活 fd 的监听
ev.events = EPOLLIN | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
为什么 EPOLLONESHOT 对多线程 Reactor 至关重要?
无 EPOLLONESHOT:
线程 A: epoll_wait → fd1 就绪 → 读一半
线程 B: epoll_wait → fd1 仍就绪 → 也读!→ 读到的数据交叉/不完整
有 EPOLLONESHOT:
线程 A: epoll_wait → fd1 就绪 → fd1 被自动暂停监控
线程 B: epoll_wait → fd1 不再出现在就绪队列 → 安全
线程 A: 处理完 → epoll_ctl MOD 重新激活 → 等待下一批数据
# 7.4 Reactor 模式实现
# 7.4.1 单线程 Reactor 骨架
typedef void (*event_handler)(int fd, void *arg);
typedef struct {
int epfd;
event_handler accept_handler;
event_handler read_handler;
event_handler write_handler;
void *handler_arg;
} reactor_t;
void reactor_run(reactor_t *r) {
struct epoll_event events[MAX_EVENTS];
while (1) {
int n = epoll_wait(r->epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
uint32_t ev = events[i].events;
if ((ev & EPOLLIN) && r->read_handler)
r->read_handler(fd, r->handler_arg);
if ((ev & EPOLLOUT) && r->write_handler)
r->write_handler(fd, r->handler_arg);
}
}
}
// 注册监听 fd
void reactor_add_fd(reactor_t *r, int fd) {
set_nonblock(fd);
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = fd};
epoll_ctl(r->epfd, EPOLL_CTL_ADD, fd, &ev);
}
单线程 Reactor 的典型应用——Redis、Node.js 的事件循环本质。
# 7.4.2 主从 Reactor 多线程
主 Reactor (main thread)
├─ accept 新连接
└─ 分发给从 Reactor
├─ 从 Reactor 0 (worker thread 0)
│ ├─ fd 1 → 读 Modbus 请求 → 处理 → 写响应
│ └─ fd 2 → 同理
└─ 从 Reactor 1 (worker thread 1)
└─ ...
每个从 Reactor 有独立的 epoll 实例。
主 Reactor 把 accept 到的 fd 用 epoll_ctl 加到某个从 Reactor 的 epfd 中。
嵌入式简化版——固定 N 个 worker + EPOLLONESHOT:
void *worker_thread(void *arg) {
int epfd = *(int *)arg;
struct epoll_event events[32];
while (1) {
int n = epoll_wait(epfd, events, 32, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
handle_modbus_request(fd); // 业务处理
// 重新激活 fd 监听
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET | EPOLLONESHOT, .data.fd = fd
};
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
}
}
主 Reactor 分发策略——轮询:
int round_robin = 0;
while (1) {
int fd = accept(listen_fd, NULL, NULL);
int worker_idx = round_robin++ % NUM_WORKERS;
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET | EPOLLONESHOT, .data.fd = fd
};
epoll_ctl(worker_epfds[worker_idx], EPOLL_CTL_ADD, fd, &ev);
}
# 7.4.3 epoll + timerfd 定时器集成
timerfd 把定时器变成文件描述符——与 epoll 天然融合:
#include <sys/timerfd.h>
// 创建每 100ms 触发一次的定时器
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
struct itimerspec its = {
.it_value = {.tv_sec = 0, .tv_nsec = 100000000}, // 第一次:100ms 后
.it_interval = {.tv_sec = 0, .tv_nsec = 100000000} // 之后:每 100ms
};
timerfd_settime(tfd, 0, &its, NULL);
// 加入 epoll
struct epoll_event ev = {.events = EPOLLIN, .data.fd = tfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev);
// epoll_wait 循环中
for (int i = 0; i < n; i++) {
if (events[i].data.fd == tfd) {
uint64_t expirations;
read(tfd, &expirations, sizeof(expirations)); // 消费定时器事件
periodic_task(); // 执行定时任务
}
}
嵌入式定时器应用场景:
| 定时器 | 周期 | 用途 |
|---|---|---|
| Modbus 超时检测 | 500 ms | 请求未何时重发 |
| 设备心跳 | 10 s | MQTT PINGREQ |
| 内存/CPU 监控 | 5 s | 嵌入式资源监控日志 |
| 断线重连 | 指数 | 见 7.5.3 |
epoll 统一五种事件源(socket / signal / timer / pipe / eventfd)——这就是"事件驱动"的本质。
# 7.5 嵌入式网络调优
# 7.5.1 小内存设备上的接收缓冲区设置
Linux 默认 socket 缓冲区偏大——嵌入式须缩小:
# 查看系统默认值
sysctl net.core.rmem_default # 接收缓冲默认值(通常 212992 字节 ≈ 208 KB)
sysctl net.core.wmem_default # 发送缓冲默认值(相同)
每个 TCP 连接占用的内存 = rmem + wmem + tcp 控制块。默认 208 KB × 2 × 50 连接 = 20.8 MB——在 128 MB 设备上约 16% 的总内存。
嵌入式建议配置:
// 为每条连接设置较小的缓冲区
int rcvbuf = 16384; // 16 KB 接收缓冲
int sndbuf = 16384; // 16 KB 发送缓冲
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
// 注意:内核实际分配的值是设置值的 2 倍(用于 TCP 协议开销)
// 设 16384 → 内核实际分配 32768
嵌入式 socket 内存规划速算:
每个连接内存 ≈ rmem × 2 + wmem × 2 + 4 KB (tcp 控制块)
50 连接 × (16384×2 + 16384×2 + 4096) = 50 × 70 KB ≈ 3.5 MB
vs 默认 50 × (212992×2 + 212992×2 + 4096) ≈ 42 MB
缩小到原来的 1/12
TCP keepalive——探测死连接:
int keepalive = 1;
int keepidle = 60; // 60 秒无数据后开始探测
int keepintvl = 10; // 探测间隔 10 秒
int keepcnt = 3; // 3 次探测无应答则断开
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
# 7.5.2 tcpdump 抓包分析实战
嵌入式设备抓包——不占本地磁盘的远程分析:
# 方案 A:抓包直接发到 PC
ssh root@192.168.1.200 "tcpdump -i eth0 -w - not port 22" | wireshark -k -i -
# 方案 B:限制抓包大小(嵌入式保护)
tcpdump -i eth0 -c 1000 -w /tmp/cap.pcap port 502
# 方案 C:只抓头部(节省空间)
tcpdump -i eth0 -s 96 -w /tmp/cap.pcap # 只抓前 96 字节
tcpdump 排查经典问题:
# Modbus TCP 重传过多 → 网络质量差
tcpdump -i eth0 'tcp port 502 and (tcp[tcpflags] & tcp-syn != 0)'
# 连接被 RST 异常关闭
tcpdump -i eth0 'tcp[tcpflags] & tcp-rst != 0'
# SYN 未收到 SYN+ACK → 对端服务未启动
tcpdump -i eth0 'tcp port 502 and tcp[tcpflags] == tcp-syn'
# TIME_WAIT 过多 → 需要 SO_REUSEADDR
ss -tan state time-wait | wc -l
# 7.5.3 网络断线重连策略(指数退避)
嵌入式网络中,socket 断开是常态。重连策略做对与否直接影响平均恢复时间。
#define MAX_BACKOFF_MS 60000 // 最长的退避间隔(60 秒)
#define BASE_BACKOFF_MS 1000 // 初始退避(1 秒)
int reconnect_with_backoff() {
int backoff = BASE_BACKOFF_MS;
int attempt = 0;
while (1) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) {
return fd; // 重连成功
}
attempt++;
int delay = backoff;
if (delay > MAX_BACKOFF_MS) delay = MAX_BACKOFF_MS;
printf("reconnect attempt %d failed, retry in %d ms\n", attempt, delay);
usleep(delay * 1000);
// 指数退避 + 随机抖动(防止多个客户端同时重连)
backoff = backoff * 2;
backoff += (rand() % 1000); // 加 0-1000ms 随机抖动
}
}
指数退避参数建议:
| 场景 | base | max | 原因 |
|---|---|---|---|
| 本地 PLC(有线) | 100 ms | 5000 ms | 网络可靠,快恢复 |
| Wi-Fi 传感器 | 500 ms | 30000 ms | 干扰多,但非广域网 |
| 4G 蜂窝连接 | 2000 ms | 120000 ms | 信号不稳定,频繁重连会过度耗电 |
| 卫星/MQTT | 5000 ms | 300000 ms | 极高延迟 + 极不可靠 |
# 7.5.4 蜂窝网络 4G/LTE 下的 socket 特性
4G 网络给 socket 编程带来三大特殊约束:
1. NAT 超时——运营商 NAT 网关有空闲连接超时(通常 60-600 秒)。长时间不发数据的 TCP 连接会被 NAT 网关静默丢弃(不发 RST,对端不知道)。
// 必须开 TCP keepalive,且 idle 时间 < NAT 超时
int keepidle = 30; // 30 秒(远小于 NAT 超时)
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
2. IP 地址切换——设备在基站间移动、信号弱后重连 → IP 地址突然改变。已建立的 TCP 连接直接断掉(旧的 IP 对端无法路由)。必须依赖应用层重连,不能假设 IP 地址稳定。
// 连接恢复后重新查询当前本端 IP 并告知服务器
struct sockaddr_in local_addr;
socklen_t len = sizeof(local_addr);
getsockname(fd, (struct sockaddr *)&local_addr, &len);
send_device_ip_update(fd, &local_addr);
3. 长肥管道(LFN)——4G 延迟 30-200ms + 高带宽 → BDP(Bandwidth-Delay Product)= 高 RTT × 高带宽 = 大缓冲区需求。TCP 默认缓冲区(208 KB)在高 BDP 下不够——但嵌入式增加缓冲区又吃内存。
// 根据 RTT 动态调整接收缓冲区(BDP = RTT × 带宽)
// 例:RTT 100ms, 带宽 10 Mbps → BDP = 125 KB
// 设置缓冲区至少为 BDP
int rcvbuf = 256 * 1024; // 256 KB → 够 100ms × 20 Mbps
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
# 7.6 关键结论与速查表
核心结论五条:
- epoll 是 select/poll 的有状态替代——红黑树 O(log N) 管理 fd,就绪链表 O(1) 获取事件。select/poll 每次都要 O(N) 扫描是因为它们是无状态的
- ET 必须配合非阻塞 I/O + 循环读到 EAGAIN——否则丢数据无后续通知
- SO_REUSEADDR 是服务端标配——防止 TIME_WAIT 导致的
bind失败;SO_REUSEPORT实现内核级负载均衡 - 嵌入式网络 = socket + epoll + timerfd——三者统一在事件循环中,不依赖多线程也能处理并发
- 蜂窝网络下 TCP keepalive < NAT 超时——否则连接被运营商网关静默丢弃,应用层永远不知道
epoll 三 API 速查:
| API | 内核操作 | 时间复杂度 | 用途 |
|---|---|---|---|
epoll_create1 | 创建 eventpoll 对象(红黑树 + 就绪链表) | O(1) | 初始化 |
epoll_ctl ADD/MOD/DEL | 红黑树插入/更新/删除 | O(log N) | 注册/修改/取消监听 |
epoll_wait | 从就绪链表取事件 | O(1) | 等待就绪 fd |
select/poll/epoll 对比:
| 机制 | fd 上限 | 内核数据结构 | 入参扫描 | 返回扫描 | 适用场景 |
|---|---|---|---|---|---|
| select | 1024 | 无状态(fd_set) | O(N) | O(N) | 少量 fd(< 100) |
| poll | 无 | 无状态(pollfd 数组) | O(N) | O(N) | 中量 fd(< 1000) |
| epoll | 无 | 有状态(红黑树 + 链表) | O(log N) | O(1) | 大量 fd,高性能 |
Socket 选项速查:
| 选项 | 含义 | 嵌入式建议 |
|---|---|---|
SO_REUSEADDR | 端口复用 | ✅ 服务端标配 |
SO_REUSEPORT | 多进程负载均衡 | ✅ 多 worker accept |
SO_KEEPALIVE | TCP 保活探测 | ✅ 4G/NAT 必开 |
TCP_NODELAY | 禁用 Nagle | ✅ Modbus/MQTT 必开 |
SO_RCVBUF | 接收缓冲 | 16-32 KB(小嵌入式) |
SO_SNDBUF | 发送缓冲 | 16-32 KB |
TCP_QUICKACK | 立即 ACK | ✅ 请求-响应协议 |
常见陷阱速查:
| 陷阱 | 根因 | 解决 |
|---|---|---|
| ET 模式丢数据 | 只 read 一次就停 | 循环 read 到 EAGAIN |
| TIME_WAIT bind 失败 | 未设 SO_REUSEADDR | setsockopt SO_REUSEADDR |
| 4G 连接"假死" | NAT 超时静默丢弃 | TCP keepalive < NAT 超时 |
| Modbus 响应延迟高 | Nagle 算法攒包 | TCP_NODELAY = 1 |
| epoll 事件被多线程乱抢 | 无 EPOLLONESHOT | EPOLLONESHOT + 处理完 MOD |
| select fd 超过 1024 崩溃 | FD_SETSIZE 限制 | 换成 epoll |
下一步:把你的 Modbus 网关从 select 改为 epoll ET 模式——先用 LT 模式验证功能,再切 ET 调优。用 strace -e epoll_wait 看就绪事件数确认 ET 已生效(每次只返回真正有事的那几个 fd,而非全部 50 个)。
延伸阅读:
man 7 epoll—— epoll API 详解man 2 timerfd_create—— 定时器 fdman 7 tcp—— TCP 协议 socket 选项man 7 socket—— socket 选项概览- 《The Linux Programming Interface》(Kerrisk) —— 第 56、58-61、63 章