21. 网络编程优化:epoll vs select、零拷贝技术(sendfile)、连接池设计
网络编程这块,说白了就是跟「慢」做斗争。网络延迟我们控制不了,但程序里那些不必要的拷贝、无意义的轮询、频繁的创建销毁,这些我们完全可以干掉。
我早年做游戏服务器的时候,一台机器要扛几万连接。那时候用 select,CPU 动不动就飙到 90%。后来换成 epoll,同样的硬件,CPU 直接降到 20% 以下。你想想看,这差距有多大。
今天我们就聊三个核心优化点:事件通知机制、数据拷贝方式、连接管理策略。
21.1 epoll vs select:别再傻等了
select 和 epoll 都是 IO 多路复用,但设计思路完全不同。
select 的问题在哪?
- 每次调用都要把 fd 集合从用户态拷贝到内核态。连接数一多,这个拷贝开销就很可观。
- 内核要遍历所有 fd,O(n) 复杂度。一万个连接里只有两个活跃?对不起,照样全扫一遍。
- fd 数量有限制,默认 1024。改配置能调大,但性能会进一步下降。
epoll 怎么解决的?
- 内核维护一个事件表,你只需要用 epoll_ctl 增删改。不需要每次传全部 fd。
- 只返回就绪的事件,O(1) 复杂度。一万个连接只有两个活跃?那就只返回两个。
- 没有上限限制,只受系统内存约束。
核心区别一句话:select 是「我问问你哪些准备好了」,epoll 是「准备好了你通知我」。
我在项目中遇到过一个问题:某模块用了 select,连接数从 500 涨到 2000 后,响应时间直接翻倍。查了半天,发现 select 的遍历开销成了瓶颈。换成 epoll 后,响应时间降回原来的水平。
看个简单的对比代码:
// select 模式:每次都要重新设置 fd_set
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
// 每次调用都要传入整个集合
int ret = select(maxfd + 1, &readfds, NULL, NULL, &timeout);
// epoll 模式:一次注册,反复使用
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 循环中只需要等待就绪事件
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
// 只处理就绪的 fd
handle_event(events[i]);
}
小技巧:epoll 的 EPOLLET(边缘触发)模式比默认的水平触发效率更高。但要注意,边缘触发要求你一次性把数据读完,否则会丢事件。我一般配合非阻塞 IO 使用。
21.2 零拷贝技术:少搬一次数据
传统的文件发送流程是这样的:
- 磁盘 → 内核缓冲区(DMA 拷贝)
- 内核缓冲区 → 用户缓冲区(CPU 拷贝)
- 用户缓冲区 → 内核 Socket 缓冲区(CPU 拷贝)
- Socket 缓冲区 → 网卡(DMA 拷贝)
你想想看,数据在内存里被搬来搬去,白白浪费 CPU 周期。尤其是大文件传输,这个开销非常明显。
零拷贝技术就是绕过用户态,让数据直接从内核缓冲区送到网卡。
Linux 下最常用的就是 sendfile 系统调用:
// 传统方式:read + write
char buf[4096];
ssize_t n = read(fd_in, buf, sizeof(buf));
write(fd_out, buf, n);
// 零拷贝方式:sendfile
off_t offset = 0;
ssize_t sent = sendfile(fd_out, fd_in, &offset, file_size);
sendfile 做了什么?它直接把文件数据从内核缓冲区送到 Socket 缓冲区,中间不经过用户态。对于大文件,性能提升非常明显。
| 方式 | 上下文切换 | 数据拷贝次数 | 适用场景 |
|---|---|---|---|
| read + write | 4 次 | 4 次(2 次 DMA + 2 次 CPU) | 小数据、需要修改数据 |
| sendfile | 2 次 | 3 次(2 次 DMA + 1 次 CPU) | 大文件传输、静态文件服务 |
| splice | 2 次 | 2 次(2 次 DMA) | 两个 fd 之间传数据 |
注意:sendfile 只能从文件描述符到 Socket 描述符。如果你需要从 Socket 到 Socket,或者需要修改数据,那就不能用 sendfile。这时候可以考虑 splice。
我曾经优化过一个文件下载服务,原来用 read + write 的方式,100MB 的文件下载 CPU 占用率 15%。换成 sendfile 后,CPU 降到 3% 左右。效果立竿见影。
21.3 连接池设计:别让握手吃掉你的性能
TCP 连接的三次握手是有成本的。每次建立连接,至少一个 RTT(往返时间)。如果业务频繁创建和销毁连接,这个开销会吃掉大量性能。
连接池的核心思想:复用连接,减少创建和销毁的开销。
设计连接池时,我一般关注这几个点:
- 最小连接数:启动时就创建一批连接,避免冷启动时的等待。
- 最大连接数:防止资源耗尽,超过上限就排队或拒绝。
- 空闲回收:长时间不用的连接要关掉,避免浪费。
- 健康检查:定期发心跳包,发现断开的连接及时重建。
看一个简单的连接池设计:
class ConnectionPool {
public:
Connection* acquire() {
std::lock_guard<std::mutex> lock(mutex_);
// 先看看空闲队列有没有
if (!idle_.empty()) {
auto* conn = idle_.front();
idle_.pop();
// 检查连接是否健康
if (conn->is_alive()) {
return conn;
}
delete conn; // 不健康就丢掉
}
// 没超过上限就新建
if (active_ < max_connections_) {
active_++;
return new Connection();
}
// 超过上限了,等别人释放
// 这里可以用条件变量等待
return nullptr;
}
void release(Connection* conn) {
std::lock_guard<std::mutex> lock(mutex_);
idle_.push(conn);
// 通知等待的线程
cv_.notify_one();
}
private:
std::queue<Connection*> idle_;
int active_ = 0;
int max_connections_ = 100;
std::mutex mutex_;
std::condition_variable cv_;
};
避坑指南:我曾经在连接池里犯过一个错——没有做健康检查。结果某个连接被服务器断开了,但客户端不知道,继续用这个连接发请求,一直超时重试。排查了半天才发现是「死连接」的问题。从那以后,每次从池里取连接,我都会先发一个心跳包确认一下。
21.4 三者如何配合?
在实际项目中,这三个技术经常一起用:
- 用 epoll 管理所有连接的事件
- 用 连接池 复用 TCP 连接
- 用 sendfile 传输大文件
比如一个高性能的 HTTP 文件服务器:
- epoll 监听所有客户端连接
- 收到请求后,从连接池取一个后端连接
- 如果是静态文件请求,直接用 sendfile 发送
- 处理完把连接放回池里
这样一套组合拳下来,单机扛几万连接、处理大文件传输,都不是问题。
嗯,这三个技术点,说白了就是三个方向:别让 CPU 空转、别让数据多搬、别让连接白建。每个方向都能带来几倍甚至几十倍的性能提升。
我个人习惯是,新项目直接上 epoll + 连接池,文件传输用 sendfile。老项目迁移的话,先看看瓶颈在哪,对症下药。