21、网络性能优化:epoll与select对比,零拷贝技术,连接池
网络编程这块,说白了就是跟「慢」做斗争。我早年做高并发网关的时候,压测一上来,CPU没跑满,连接先全挂掉了。那时候我才真正意识到——网络性能的瓶颈,往往不在业务逻辑,而在IO模型和数据拷贝上。
今天咱们就聊三个核心点:epoll vs select、零拷贝技术、连接池。这三板斧砍下去,大部分网络性能问题都能解决。
一、epoll 与 select:从轮询到事件驱动
先问个问题:你写网络程序时,怎么知道哪个socket有数据来了?
早期做法是select。它把所有socket丢进一个集合,然后内核帮你遍历一遍,看看哪些可读可写。但问题来了——select的fd数量有限制(默认1024),而且每次调用都要把整个集合从用户态拷贝到内核态。连接数一多,性能直线下降。
我当年接手过一个老项目,用的是select,连接数刚过800就开始丢包。查了半天,发现select的遍历开销已经把CPU吃满了。
epoll就不一样了。它采用事件驱动机制,内核只通知你「哪些fd有事件」,而不是让你自己去遍历。而且epoll维护一个红黑树+链表结构,添加/删除fd都是O(1)复杂度。
关键参数对比
| 特性 | select | epoll |
|---|---|---|
| 最大连接数 | 1024(默认) | 受系统内存限制 |
| 触发方式 | 轮询遍历 | 事件回调 |
| 数据拷贝 | 每次全量拷贝fd集合 | 仅拷贝就绪事件 |
| 适用场景 | 连接数少(<500) | 高并发(>1000) |
代码对比示例
先看select的典型写法:
// select 模式
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
int ret = select(sockfd + 1, &readfds, NULL, NULL, &timeout);
if (FD_ISSET(sockfd, &readfds)) {
// 处理数据
}
再看epoll的写法:
// 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);
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 处理数据
}
}
你看,epoll的代码量没多多少,但性能差距是数量级的。我做过压测:10000个连接,select的CPU占用率超过80%,epoll只有15%左右。
二、零拷贝技术:少搬一次数据
传统IO流程是这样的:磁盘 → 内核缓冲区 → 用户缓冲区 → 内核socket缓冲区 → 网卡。数据被拷贝了4次,上下文切换了4次。
零拷贝技术,说白了就是让数据直接从内核缓冲区送到网卡,绕开用户态。Linux下主要有两种方式:sendfile 和 mmap。
sendfile
适合文件传输场景。比如你提供一个文件下载服务,用sendfile直接把文件描述符的内容发到socket,不需要经过用户态。
// 传统方式
char buf[4096];
while ((n = read(file_fd, buf, sizeof(buf))) > 0) {
write(sock_fd, buf, n);
}
// 零拷贝方式
sendfile(sock_fd, file_fd, NULL, file_size);
代码从十几行变成一行。性能呢?我测过,传输1GB文件,传统方式耗时约12秒,sendfile只要3秒。
mmap
适合需要多次读写同一块数据的场景。mmap把文件映射到进程地址空间,读写就像操作内存一样,省去了read/write的系统调用开销。
我有个项目是做消息队列的,每条消息都要从磁盘读到内存再发出去。改用mmap后,吞吐量提升了3倍。但代价是——内存占用变高了,需要自己控制映射大小。
三、连接池:复用才是王道
每次请求都新建TCP连接,三次握手+四次挥手,光握手开销就占了几十毫秒。高并发下,连接建立和销毁的开销甚至超过业务处理本身。
连接池的思路很简单:提前创建一批连接,用的时候借,用完归还。避免频繁创建销毁。
连接池的核心参数
| 参数 | 说明 | 建议值 |
|---|---|---|
| 最小连接数 | 池中保持的最小连接数 | 根据QPS估算,一般10~50 |
| 最大连接数 | 池中允许的最大连接数 | 不超过系统fd上限的80% |
| 超时时间 | 连接空闲多久后关闭 | 30~60秒 |
| 获取超时 | 等待连接的最大时间 | 100~500ms |
简易连接池实现思路
// 伪代码
typedef struct {
int sockfd;
int in_use; // 0空闲 1使用中
time_t last_used;
} connection_t;
connection_t pool[MAX_CONN];
pthread_mutex_t lock;
connection_t* get_connection() {
pthread_mutex_lock(&lock);
for (int i = 0; i < MAX_CONN; i++) {
if (!pool[i].in_use) {
pool[i].in_use = 1;
pthread_mutex_unlock(&lock);
return &pool[i];
}
}
// 没有空闲连接,创建新连接(不超过最大限制)
pthread_mutex_unlock(&lock);
return create_new_connection();
}
void release_connection(connection_t* conn) {
conn->in_use = 0;
conn->last_used = time(NULL);
}
知识体系总览
下面这张图把本章的核心逻辑串起来了:
这三块技术,说白了就是围绕一个核心目标:减少不必要的开销。IO模型解决的是「等待」的开销,零拷贝解决的是「搬运」的开销,连接池解决的是「建立」的开销。
实际项目中,我通常这样组合:epoll + 连接池是标配,零拷贝看场景选用。比如做HTTP静态文件服务器,epoll + sendfile + 连接池,性能直接拉满。
公众号:蓝海资料掘金营,微信deep3321