实战:简单Web服务器(epoll+多线程)

说实话,写一个Web服务器,是每个C++程序员都该过一遍的手艺。我当年刚入行时,老大丢给我一句:「你去把Nginx的epoll模型看懂。」结果我啃了三天源码,头皮发麻。后来自己动手写了个迷你版,才算真正搞明白。

今天咱们就一起搭一个简单的Web服务器。用epoll处理高并发,用多线程分担压力。说白了,就是让一台机器能同时伺候成千上万个客户端。

整体架构:怎么设计?

先画张图,把核心逻辑理清楚。我个人习惯,动手写代码前一定先画架构图,不然写着写着就迷路了。

简单Web服务器架构图 主线程 epoll_create / epoll_wait 任务队列 std::queue + 互斥锁 线程池 4~8个工作线程 工作线程1 工作线程2 工作线程3 工作线程4 每个工作线程: 从队列取fd → 解析HTTP请求 → 读取文件 → 构造响应 → 发送 说明:主线程只负责epoll_wait和分发任务,不处理具体请求。 工作线程从任务队列取任务,处理完再继续等待新任务。避免频繁创建/销毁线程。

核心组件拆解

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