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 零拷贝技术:少搬一次数据

传统的文件发送流程是这样的:

  1. 磁盘 → 内核缓冲区(DMA 拷贝)
  2. 内核缓冲区 → 用户缓冲区(CPU 拷贝)
  3. 用户缓冲区 → 内核 Socket 缓冲区(CPU 拷贝)
  4. 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 文件服务器:

  1. epoll 监听所有客户端连接
  2. 收到请求后,从连接池取一个后端连接
  3. 如果是静态文件请求,直接用 sendfile 发送
  4. 处理完把连接放回池里

这样一套组合拳下来,单机扛几万连接、处理大文件传输,都不是问题。

网络编程优化核心架构 事件通知机制 epoll(高效) vs select(低效) O(1) vs O(n),边缘触发 vs 水平触发 零拷贝技术 sendfile / splice 减少 CPU 拷贝,提升吞吐量 连接池设计 复用连接,减少握手开销 健康检查 + 空闲回收 高性能网络应用 epoll 管理连接 + 连接池复用 + sendfile 传输 性能提升效果 select(万连接) CPU 90% epoll(万连接) 20% 传统 read+write 15% sendfile 传输 3% 无连接池 高延迟 有连接池 低延迟

嗯,这三个技术点,说白了就是三个方向:别让 CPU 空转、别让数据多搬、别让连接白建。每个方向都能带来几倍甚至几十倍的性能提升。

我个人习惯是,新项目直接上 epoll + 连接池,文件传输用 sendfile。老项目迁移的话,先看看瓶颈在哪,对症下药。

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