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是「内核告诉你,谁好了」。

关键参数对比

特性 select epoll
最大连接数 1024(默认) 受系统内存限制
触发方式 轮询遍历 事件回调
数据拷贝 每次全量拷贝fd集合 仅拷贝就绪事件
适用场景 连接数少(<500) 高并发(>1000)
我的习惯: 新项目直接上epoll。如果必须兼容老平台,用poll替代select,至少没有1024限制。

代码对比示例

先看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%左右。

避坑指南: 我曾经在epoll里忘了设置EPOLLET(边缘触发),结果用水平触发模式处理大量数据,导致事件反复触发,CPU飙升。记住:高吞吐场景用边缘触发,配合非阻塞IO。

二、零拷贝技术:少搬一次数据

传统IO流程是这样的:磁盘 → 内核缓冲区 → 用户缓冲区 → 内核socket缓冲区 → 网卡。数据被拷贝了4次,上下文切换了4次。

零拷贝技术,说白了就是让数据直接从内核缓冲区送到网卡,绕开用户态。Linux下主要有两种方式:sendfilemmap

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不是完全零拷贝,它只是减少了拷贝次数。真正零拷贝是sendfile + DMA收集。

我有个项目是做消息队列的,每条消息都要从磁盘读到内存再发出去。改用mmap后,吞吐量提升了3倍。但代价是——内存占用变高了,需要自己控制映射大小。

我的建议: 文件传输用sendfile;频繁读写小文件用mmap;如果数据量不大,传统IO也够用,别过度优化。

三、连接池:复用才是王道

每次请求都新建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);
}
我曾经踩过的坑: 连接池没有做心跳检测。空闲连接被服务端断开后,客户端不知道,拿到的连接已经失效了。后来加了定时ping机制,每15秒发一次心跳包。

知识体系总览

下面这张图把本章的核心逻辑串起来了:

网络性能优化核心体系 IO模型 数据拷贝 连接管理 select(轮询) epoll(事件驱动) 传统IO(4次拷贝) 零拷贝(sendfile/mmaps) 短连接(每次新建) 连接池(复用) 核心原则 1. 减少系统调用次数(epoll替代select) 2. 减少数据拷贝次数(零拷贝技术) 3. 减少连接建立开销(连接池复用)

这三块技术,说白了就是围绕一个核心目标:减少不必要的开销。IO模型解决的是「等待」的开销,零拷贝解决的是「搬运」的开销,连接池解决的是「建立」的开销。

实际项目中,我通常这样组合:epoll + 连接池是标配,零拷贝看场景选用。比如做HTTP静态文件服务器,epoll + sendfile + 连接池,性能直接拉满。

最后说一句: 别为了用技术而用技术。如果你的服务只有几百个连接,select完全够用。优化之前,先压测,找到真正的瓶颈在哪。

公众号:蓝海资料掘金营,微信deep3321