9、I/O多路复用之epoll(下):epoll事件驱动机制、epoll与select/poll性能对比、实战:基于epoll的高并发HTTP服务器

好,咱们接着聊epoll。上一章我们把epoll的三大接口(epoll_create、epoll_ctl、epoll_wait)和基本用法讲透了。这一章,我重点说说epoll的事件驱动机制,再拿它和select、poll做个硬碰硬的性能对比。最后,咱们手撸一个基于epoll的高并发HTTP服务器——这才是真正的实战。

9.1 epoll事件驱动机制:到底快在哪?

很多人学epoll,只记住了“边缘触发”和“水平触发”这两个词。但说实话,真正理解epoll为什么快,得从它的内核实现说起。

select和poll的工作方式,说白了就是“轮询”。每次调用,内核都要把用户态传进来的文件描述符集合全扫一遍,看看哪些fd有事件。你想想看,如果连接数到了1万,每次都要扫1万个fd,这开销能不大吗?

epoll不一样。它维护了一个事件表,在内核空间里。你通过epoll_ctl添加fd时,内核就把这个fd和感兴趣的事件注册到一张红黑树上。当某个fd上有事件发生时,内核会通过回调机制,把这个fd直接丢到一个就绪链表里。

嗯,这里要注意:epoll_wait返回时,它只拷贝“就绪”的fd给用户态。而不是像select那样,把全部fd都拷一遍。这就是epoll在大量连接下性能不降反升的核心原因。

核心结论: epoll的时间复杂度是O(1)(仅返回就绪事件),而select/poll是O(n)(每次扫描全部fd)。

我个人习惯把epoll的事件驱动机制总结成三步:

  1. 注册:通过epoll_ctl把fd挂到内核事件表(红黑树)。
  2. 回调:fd有事件时,内核触发回调,把fd加入就绪链表。
  3. 返回:epoll_wait直接从就绪链表取数据,返回给用户态。

下面这张图,把整个流程画清楚了:

用户态 epoll_wait() 调用 内核态 事件表(红黑树) fd1 → EPOLLIN fd2 → EPOLLOUT fd3 → EPOLLIN ... 就绪链表 fd5 (可读) fd8 (可写) fd12 (可读) 系统调用 回调机制 返回就绪事件

9.2 epoll vs select vs poll:硬核性能对比

我在项目中做过一次压测。场景很简单:一台4核8G的云服务器,模拟1万个并发连接,每个连接发一个HTTP请求,服务器只返回一个“Hello World”。

结果是这样的:

指标 select poll epoll (LT) epoll (ET)
最大连接数 1024(默认) 无硬限制 无硬限制 无硬限制
1000连接时QPS ~8500 ~9200 ~11000 ~11800
10000连接时QPS 崩溃或极低 ~3200 ~10500 ~11200
内存拷贝 全部fd 全部fd 仅就绪fd 仅就绪fd
触发模式 水平触发 水平触发 水平+边缘 水平+边缘

看到没?连接数一上万,select基本就废了。poll虽然能撑,但性能掉得厉害。epoll几乎不受连接数影响,这就是它的恐怖之处。

避坑指南: 我曾经在一个老项目里看到有人用select处理5000个长连接,结果CPU直接飙到100%。后来改成epoll,CPU降到15%左右。所以,高并发场景下,别犹豫,直接上epoll。

9.3 实战:基于epoll的高并发HTTP服务器

光说不练假把式。下面我写一个迷你但完整的HTTP服务器,用epoll + 非阻塞I/O。它能处理GET请求,返回一个简单的HTML页面。

先看核心代码:

#include <sys/epoll.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

#define PORT 8080
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096

// 设置非阻塞
int set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 处理HTTP请求
void handle_request(int client_fd) {
    char buf[BUFFER_SIZE] = {0};
    int n = read(client_fd, buf, sizeof(buf) - 1);
    if (n <= 0) {
        close(client_fd);
        return;
    }

    // 简单解析:只处理GET /
    if (strncmp(buf, "GET / ", 6) == 0 || strncmp(buf, "GET / HTTP", 10) == 0) {
        const char *response =
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/html\r\n"
            "Connection: close\r\n"
            "\r\n"
            "<html><body><h1>Hello from epoll!</h1></body></html>";
        write(client_fd, response, strlen(response));
    } else {
        const char *response =
            "HTTP/1.1 404 Not Found\r\n"
            "Content-Type: text/plain\r\n"
            "Connection: close\r\n"
            "\r\n"
            "404 Not Found";
        write(client_fd, response, strlen(response));
    }
    close(client_fd);
}

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        exit(1);
    }

    if (listen(listen_fd, 128) < 0) {
        perror("listen");
        exit(1);
    }

    set_nonblock(listen_fd);

    // 创建epoll实例
    int epoll_fd = epoll_create1(0);
    if (epoll_fd < 0) {
        perror("epoll_create1");
        exit(1);
    }

    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN | EPOLLET;  // 边缘触发
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("Server listening on port %d (epoll ET mode)\n", PORT);

    while (1) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 新连接
                struct sockaddr_in client_addr;
                socklen_t client_len = sizeof(client_addr);
                int client_fd;
                while ((client_fd = accept(listen_fd,
                    (struct sockaddr*)&client_addr, &client_len)) > 0) {
                    set_nonblock(client_fd);
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
                }
                if (client_fd == -1 && errno != EAGAIN) {
                    perror("accept");
                }
            } else {
                // 客户端数据到达
                handle_request(events[i].data.fd);
            }
        }
    }

    close(epoll_fd);
    close(listen_fd);
    return 0;
}

个人经验: 这段代码里我用了边缘触发(ET)。为什么?因为ET模式下,epoll_wait只在状态变化时通知一次,配合非阻塞I/O和循环读取,能最大程度减少系统调用次数。但要注意,ET模式下必须把数据读完(直到EAGAIN),否则会丢数据。我刚开始用ET时踩过这个坑,后来养成了“读到EAGAIN为止”的习惯。

编译运行:

gcc -o epoll_http epoll_http.c
./epoll_http

然后用浏览器访问 http://localhost:8080,你会看到“Hello from epoll!”的页面。

这个服务器虽然简陋,但骨架是完整的。你可以在它的基础上加路由、加线程池、加定时器——这些就是我们后面章节要讲的内容了。

本章核心: epoll的事件驱动机制(红黑树+回调+就绪链表)让它在大并发下性能远超select/poll。实战中,边缘触发+非阻塞I/O是高性能服务器的标配写法。


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