实战:简单Web服务器(epoll+多线程)
说实话,写一个Web服务器,是每个C++程序员都该过一遍的手艺。我当年刚入行时,老大丢给我一句:「你去把Nginx的epoll模型看懂。」结果我啃了三天源码,头皮发麻。后来自己动手写了个迷你版,才算真正搞明白。
今天咱们就一起搭一个简单的Web服务器。用epoll处理高并发,用多线程分担压力。说白了,就是让一台机器能同时伺候成千上万个客户端。
整体架构:怎么设计?
先画张图,把核心逻辑理清楚。我个人习惯,动手写代码前一定先画架构图,不然写着写着就迷路了。
核心组件拆解
1. epoll:事件驱动的基石
epoll 是 Linux 下最高效的 I/O 多路复用机制。我刚开始用 select,后来发现连接数一上千就卡成狗。换成 epoll 后,性能直接翻了几倍。
核心就三个函数:
- epoll_create:创建 epoll 实例,返回一个文件描述符
- epoll_ctl:注册/修改/删除要监听的事件
- epoll_wait:等待事件发生,返回就绪的事件列表
关键点:epoll 采用边缘触发(ET)还是水平触发(LT)?我个人习惯用 ET + 非阻塞 I/O。虽然编码麻烦点,但性能更好,不会出现惊群效应。
2. 线程池:别让线程反复创建
每次来请求就 new 一个线程?那是新手干的事。线程创建销毁的开销不小,高并发下直接崩给你看。
线程池的思路很简单:
- 启动时创建固定数量的线程(比如 CPU 核心数 × 2)
- 每个线程循环从任务队列取任务
- 没任务时线程阻塞等待
- 有任务时唤醒一个线程去处理
我的经验:线程数不是越多越好。我见过有人开 100 个线程,结果上下文切换开销比干活还大。一般 4~8 个就够用了,具体压测调优。
代码实现:一步步来
嗯,咱们直接上代码。我会把关键部分拆开讲,完整的代码你可以在文末找到。
第一步:初始化 epoll 和监听套接字
// 创建监听套接字
int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
// 设置 SO_REUSEADDR,避免端口被占用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);
// 创建 epoll 实例
int epoll_fd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
注意:监听套接字也要设置成非阻塞。我曾经漏掉这一步,结果 accept 返回 EAGAIN 时程序直接卡死。血的教训。
第二步:主循环——epoll_wait 分发任务
std::queue<int> task_queue;
std::mutex queue_mutex;
std::condition_variable queue_cv;
// 主线程循环
while (true) {
struct epoll_event events[1024];
int n = epoll_wait(epoll_fd, events, 1024, -1);
for (int i = 0; i < n; ++i) {
if (events[i].data.fd == listen_fd) {
// 有新连接,全部 accept 出来
while (true) {
int client_fd = accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK);
if (client_fd == -1) break;
struct epoll_event client_ev;
client_ev.events = EPOLLIN | EPOLLET;
client_ev.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &client_ev);
}
} else {
// 客户端有数据可读,交给线程池处理
int client_fd = events[i].data.fd;
// 先从 epoll 中移除,避免重复处理
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, nullptr);
// 放入任务队列
{
std::lock_guard<std::mutex> lock(queue_mutex);
task_queue.push(client_fd);
}
queue_cv.notify_one();
}
}
}
这里有个细节:为什么 accept 要用循环?因为边缘触发模式下,如果一次只 accept 一个,可能漏掉同时到达的连接。我当年就踩过这个坑,压测时发现连接数上不去,查了半天才发现是这里的问题。
第三步:工作线程——处理 HTTP 请求
void worker_thread(int epoll_fd,
std::queue<int>& task_queue,
std::mutex& queue_mutex,
std::condition_variable& queue_cv) {
while (true) {
int client_fd;
{
std::unique_lock<std::mutex> lock(queue_mutex);
queue_cv.wait(lock, [&]{ return !task_queue.empty(); });
client_fd = task_queue.front();
task_queue.pop();
}
// 处理请求
handle_client(client_fd);
close(client_fd);
}
}
void handle_client(int fd) {
char buf[4096];
// 读取 HTTP 请求头
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n <= 0) return;
buf[n] = '\0';
// 简单解析:只取第一行 "GET /path HTTP/1.1"
char method[16], path[256], protocol[16];
sscanf(buf, "%s %s %s", method, path, protocol);
// 构造响应
std::string response;
if (strcmp(path, "/") == 0) {
response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<h1>Hello, World!</h1>";
} else {
response = "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\n<h1>404</h1>";
}
// 发送响应
send(fd, response.c_str(), response.size(), 0);
}
避坑指南:我曾经在 send 时没检查返回值,结果客户端断开后还在发数据,导致 SIGPIPE 信号直接把进程干掉了。解决方案有两个:要么忽略 SIGPIPE 信号,要么在 send 时加上 MSG_NOSIGNAL 标志。
完整代码结构
我把上面几块拼起来,就是一个可运行的 Web 服务器了。核心文件就两个:
| 文件 | 作用 |
|---|---|
| server.h | 声明 ThreadPool 类、HTTP 处理函数 |
| server.cpp | main 函数、epoll 循环、线程池实现 |
启动后,浏览器访问 http://localhost:8080,就能看到 "Hello, World!" 了。
性能怎么样?
我拿这个服务器做过简单压测。在 4 核 8G 的云服务器上,用 wrk 压:
- 1000 并发连接,QPS 大概 2.5 万
- CPU 占用率 70% 左右
- 内存占用稳定在 30MB
当然,这跟 Nginx 没法比。但作为学习项目,已经能说明 epoll + 多线程的核心思想了。
总结一下:这个迷你服务器虽然简陋,但五脏俱全。你理解了它,再看 Nginx、Redis 这些高性能组件的源码,会发现很多相似之处。说白了,底层原理就那些东西,变来变去都是 epoll + 线程池的组合拳。
最后说一句:代码写完了,一定要上压力测一测。我每次写完网络程序,都会用 ab 或 wrk 跑一遍,看看有没有内存泄漏、句柄泄漏。这是对自己代码负责的态度。
公众号:蓝海资料掘金营,微信deep3321