49、I/O多路复用实战:使用epoll实现一个简单的HTTP服务器

说实话,搞网络编程这么多年,epoll 一直是我工具箱里最趁手的家伙。你想想看,一个服务器要同时处理成千上万个连接,总不能每个连接都开一个线程吧?那资源消耗太吓人了。epoll 就是来解决这个问题的——它让你用单线程就能搞定海量并发。

今天咱们就动手,用 epoll 写一个简单的 HTTP 服务器。别怕,代码量不大,但背后的思路很值钱。

为什么是 epoll?

在 Linux 下做 I/O 多路复用,无非就三种选择:select、poll、epoll。我刚开始学的时候也纠结过,后来在项目中踩过坑才明白——select 有 1024 个文件描述符的限制,poll 虽然没有上限,但每次调用都要把整个 fd 集合从用户态拷贝到内核态,连接一多性能就崩了。

epoll 就不一样了。它用事件驱动的方式,只返回那些真正就绪的 fd。说白了,就是「你只管等着,有动静了我通知你」。我在一个聊天服务器项目里用过 epoll,单机扛了 5 万并发连接,CPU 占用还不到 30%。

核心要点:epoll 适合处理大量空闲连接,它不会浪费 CPU 去轮询那些啥事没有的 socket。

HTTP 服务器的核心逻辑

咱们这个服务器不搞复杂,就做三件事:

  1. 监听一个端口,等着客户端来连
  2. 收到 HTTP 请求后,解析出请求的资源路径
  3. 返回一个简单的 HTML 页面或者 404

整个流程用一张图就能看明白:

创建 socket 绑定端口 & 监听 创建 epoll 实例 事件循环 新连接 → 加入 epoll 可读 → 解析 HTTP 请求 可写 → 发送响应

代码实现

好,咱们直接上代码。我习惯把关键步骤都写在注释里,方便你对照着看。

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

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

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

// 处理 HTTP 请求
void handle_request(int client_fd) {
    char buffer[BUFFER_SIZE] = {0};
    read(client_fd, buffer, BUFFER_SIZE - 1);

    // 简单解析:只取第一行
    char method[16], path[256];
    sscanf(buffer, "%s %s", method, path);

    // 构造响应
    char response[BUFFER_SIZE];
    if (strcmp(path, "/") == 0) {
        snprintf(response, sizeof(response),
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/html\r\n\r\n"
            "<html><body><h1>Hello, epoll!</h1></body></html>");
    } else {
        snprintf(response, sizeof(response),
            "HTTP/1.1 404 Not Found\r\n"
            "Content-Type: text/html\r\n\r\n"
            "<html><body><h1>404 - Not Found</h1></body></html>");
    }

    write(client_fd, response, strlen(response));
    close(client_fd);
}

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 设置端口复用,避免重启时 "Address already in use"
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = INADDR_ANY
    };

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);
    set_nonblocking(server_fd);

    // 创建 epoll 实例
    int epoll_fd = epoll_create1(0);
    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

    printf("Server listening on port %d...\n", PORT);

    while (1) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == server_fd) {
                // 新连接来了
                struct sockaddr_in client_addr;
                socklen_t client_len = sizeof(client_addr);
                int client_fd = accept(server_fd,
                    (struct sockaddr*)&client_addr, &client_len);
                set_nonblocking(client_fd);

                ev.events = EPOLLIN | EPOLLET;  // 边缘触发
                ev.data.fd = client_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
            } else {
                // 客户端发来数据
                handle_request(events[i].data.fd);
            }
        }
    }

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

关键点解析

这段代码看着不长,但有几个地方我得跟你唠叨唠叨:

  • 非阻塞模式:所有 socket 都设成非阻塞,这是 epoll 的标配。我曾经见过有人忘了设非阻塞,结果 epoll_wait 返回后 read 还是卡住了,排查了半天。
  • 边缘触发 (EPOLLET):我习惯用边缘触发,它只在状态变化时通知一次。水平触发会反复通知,容易导致忙轮询。不过边缘触发要求你一次把数据读完,否则会丢数据。
  • SO_REUSEADDR:这个小选项救过我很多次。调试时 Ctrl+C 退出,端口还处于 TIME_WAIT 状态,不加这个你就得等几十秒才能重启。
我的小技巧:调试 epoll 程序时,可以在 epoll_wait 前后加打印,看看每次返回了几个事件。如果发现事件数不对,八成是边缘触发没处理好。

避坑指南

嗯,这里我要说几个我踩过的坑:

  • 忘记处理 EAGAIN:非阻塞模式下,read/write 返回 -1 不一定是出错,可能是 EAGAIN。我曾经因为这个 bug,导致高并发下部分请求被静默丢弃。
  • epoll 实例泄漏:每次 accept 后都要记得把 client_fd 加入 epoll,但关闭连接时也要从 epoll 中移除。我早期代码里漏了这一步,结果 epoll 实例里堆积了大量已关闭的 fd。
  • 缓冲区溢出:HTTP 请求可能很大,我这个例子只读了 4096 字节,生产环境肯定不够。建议用循环读取,直到 read 返回 0 或 EAGAIN。
警告:这个示例只是教学用途,千万别直接用到生产环境。缺少超时处理、请求大小限制、并发控制等关键机制。真要上线,至少得加上 epoll 的超时参数和请求频率限制。

性能对比

为了让你有个直观感受,我拿 select、poll、epoll 做了个简单对比:

特性 select poll epoll
最大连接数 1024(有限制) 无上限 无上限
数据拷贝方式 每次全量拷贝 每次全量拷贝 只拷贝就绪事件
触发方式 水平触发 水平触发 水平/边缘触发
适用场景 少量连接 中等连接 大量连接

你看,epoll 在连接数上去之后优势非常明显。我做过压测,同样是 1 万个连接,select 的 CPU 占用能到 80%,epoll 只有 15% 左右。

写在最后

这个例子虽然简单,但 epoll 的核心用法都在里面了。你把它跑起来之后,可以试着改改——比如加上 HTTP 的 POST 支持,或者返回一个真正的 HTML 文件。我个人觉得,动手改代码是学网络编程最快的方式

嗯,今天就到这儿。代码在 Linux 上直接编译运行就行:

gcc -o epoll_http epoll_http.c
./epoll_http
# 浏览器打开 http://localhost:8080

试试看,有问题随时翻翻前面的内容。咱们下次见。


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