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的事件驱动机制总结成三步:
- 注册:通过epoll_ctl把fd挂到内核事件表(红黑树)。
- 回调:fd有事件时,内核触发回调,把fd加入就绪链表。
- 返回:epoll_wait直接从就绪链表取数据,返回给用户态。
下面这张图,把整个流程画清楚了:
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