1. 项目概述为什么我们需要自己动手写一个高并发HTTP服务器在当今这个万物互联的时代HTTP协议几乎是无处不在的基石。从你刷的网页、用的App接口到各种物联网设备的控制面板背后都离不开一个稳定高效的HTTP服务器。市面上成熟的方案很多像Nginx、Apache它们功能强大、生态完善几乎是生产环境的首选。那为什么我们还要用C/C从零开始吭哧吭哧地自己实现一个呢这绝不是为了重复造轮子而是一次深入理解网络编程、操作系统和并发模型核心原理的绝佳实践。对于C/C开发者尤其是那些对系统底层、性能优化有追求的工程师来说亲手实现一个高并发HTTP服务器就像汽车工程师亲手拆装一台发动机。你能清晰地看到每一个“气缸”线程/进程如何工作燃油请求如何被喷射和点燃处理废气响应如何被排出。这个过程会让你对“高并发”这三个字有肌肉记忆般的理解它不仅仅是开多几个线程那么简单而是涉及到I/O模型的选择、内存的管理、锁的竞争、上下文切换的开销等一系列精密的权衡。这个项目我们将聚焦于用C语言兼容C环境实现一个支持高并发的简易HTTP服务器。我会带你从最基础的Socket编程开始一步步构建出能同时处理成千上万个连接的服务器核心。我们会重点探讨几种经典的并发模型比如多进程、多线程以及更高效的I/O多路复用技术如select、poll特别是epoll。最终你会得到一套可以运行、可以测试、并且能清晰看到每一行代码作用的完整示例。无论你是想巩固网络编程基础为面试做准备还是为某个嵌入式或高性能场景寻找轻量级解决方案这个“手搓”服务器的经历都会让你受益匪浅。2. 核心架构与并发模型选型实现一个服务器首先要确定它的“骨架”也就是并发模型。模型选对了性能事半功倍选错了可能事倍功半甚至漏洞百出。2.1 从单线程阻塞式到多线程/多进程最原始的服务器模型是单线程阻塞式。它一次只能处理一个连接accept一个客户端然后read请求、处理、write响应全程阻塞直到这个连接关闭才能服务下一个。这在学习Socket API时很常见但实战中毫无用处因为任何一个慢请求都会让整个服务器卡住。为了同时服务多个客户端很自然的想法是引入“多个工人”。这就是多进程模型和多线程模型。多进程模型主进程只负责accept新连接一旦接受就fork()出一个子进程来处理这个连接的所有事务。子进程独立拥有自己的地址空间一个进程崩溃不会影响其他进程安全性好。但进程创建、销毁、上下文切换的成本很高且进程间通信IPC比较复杂内存占用也大。多线程模型主线程accept然后创建新线程或从线程池取来处理连接。线程共享进程的内存空间创建和切换开销比进程小数据共享方便。但正因共享内存需要引入锁等机制来保证线程安全编程复杂度陡增一个线程的野指针可能搞垮整个进程。这两种模型在连接数不多比如几百个时是可行的。但它们有一个共同的、根本性的瓶颈一个连接对应一个线程/进程。当连接数飙升到几千、几万时系统光是创建和管理这些线程/进程就会耗尽资源大部分CPU时间都花在了上下文切换上而不是真正的业务处理。这就是著名的 C10K问题 的由来。2.2 I/O多路复用突破C10K的关键为了解决上述瓶颈我们需要更高效的I/O事件管理机制——I/O多路复用。它的核心思想是用一个单独的线程或少量线程来监视大量的文件描述符fd如socket一旦某个fd就绪可读、可写或出错就通知程序进行相应的读写操作。这样一个线程就能管理成千上万个连接。Linux下主要有三种技术select、poll和epoll。select/poll它们的工作原理类似都是通过轮询的方式检查所有被监视的fd。select有fd数量的限制通常是1024poll则没有。但它们的共同问题是效率随监控fd的数量线性下降。当fd很多但活跃很少时大部分轮询都是无效的浪费CPU。epoll这是Linux 2.6内核后引入的是当前实现高性能网络服务器的首选。它采用了事件驱动的方式。程序先将需要监控的fd添加到epoll实例epoll_create然后通过epoll_wait等待事件发生。内核只会返回那些真正发生了事件的fd而不是轮询全部。这种“事件通知”机制在连接数巨大且活跃比例不高时效率远超select/poll。实操心得在现代Linux服务器编程中除非有极强的跨平台需求因为epoll是Linux特有的否则应毫不犹豫地选择epoll作为高并发的基础。select/poll更多用于教学或连接数极少的场景。Windows平台有类似的IOCP完成端口模型设计哲学不同但同样高效。2.3 本项目架构Reactor模式与线程池结合综合以上分析我们将采用一个混合架构这也是Nginx、Redis等高性能服务器普遍采用的模式主从Reactor模式 线程池。主Reactor单线程主线程充当主Reactor它运行一个epoll事件循环只负责监听监听socket用于accept新连接的事件。当有新连接到来时主线程accept它然后将新建立的客户端socket连接fd以某种方式比如通过轮询分发给多个从Reactor。从Reactor多线程我们创建多个工作线程每个工作线程都有自己的epoll实例它们就是从Reactor。主线程将新的客户端fd分配给某个工作线程的epoll去监控。每个工作线程独立运行自己的epoll_wait循环处理自己负责的那些连接上的读写事件。线程池处理业务逻辑当工作线程的epoll捕获到一个客户端fd的可读事件HTTP请求报文已到达内核缓冲区时它并不自己执行复杂的HTTP解析和业务处理这可能会阻塞该工作线程影响其他连接的响应。而是将这个“请求数据”封装成一个任务投递到一个全局的线程池中。线程池中的业务线程专门负责计算密集型或可能阻塞的任务如解析HTTP、访问数据库、渲染模板等处理完毕后将响应数据写回通常通过回调或通知工作线程写回。这个架构的优势非常明显高并发利用epoll管理海量连接。职责分离网络I/Oepoll循环与业务处理线程池解耦避免慢业务阻塞快I/O。充分利用多核工作线程和业务线程都可以根据CPU核心数配置并行处理。扩展性好可以方便地调整各组件数量。接下来我们就将按照这个架构一步步用代码实现。3. 基础构建从Socket到HTTP解析在搭建复杂架构之前我们先实现一个最基础的、单线程的HTTP服务器它能够接受连接并返回一个简单的HTTP响应。这能帮助我们巩固Socket编程和HTTP协议的基础。3.1 创建监听Socket与绑定端口一切始于创建一个监听socket。这个过程是标准的“三部曲”socket()-bind()-listen()。#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include stdio.h #include string.h #include stdlib.h #define PORT 8080 #define BACKLOG 1024 // 全连接队列的最大长度 int create_listen_socket(int port) { int listen_fd; struct sockaddr_in server_addr; // 1. 创建socket (IPv4, TCP流式套接字) if ((listen_fd socket(AF_INET, SOCK_STREAM, 0)) -1) { perror(socket); return -1; } // 2. 设置SO_REUSEADDR选项避免“Address already in use”错误 int optval 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, optval, sizeof(optval)) -1) { perror(setsockopt); close(listen_fd); return -1; } // 3. 绑定地址和端口 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 server_addr.sin_port htons(port); // 端口号主机字节序转网络字节序 if (bind(listen_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) -1) { perror(bind); close(listen_fd); return -1; } // 4. 开始监听指定全连接队列大小 if (listen(listen_fd, BACKLOG) -1) { perror(listen); close(listen_fd); return -1; } printf(Server listening on port %d\n, port); return listen_fd; }注意事项SO_REUSEADDR这个选项至关重要尤其是在服务器重启时。没有它之前连接处于TIME_WAIT状态的socket会占用端口导致bind失败。设置它允许内核重用处于TIME_WAIT状态的地址。INADDR_ANY表示绑定到本机所有IP地址。如果你只想监听特定网卡如127.0.0.1可以改为inet_addr(127.0.0.1)。BACKLOGlisten的第二个参数指定了“全连接队列”的最大长度。当客户端完成三次握手后连接会进入这个队列等待服务器accept。如果队列满了新的连接会被忽略或拒绝。这个值需要根据服务器预期压力调整但不宜过大内核也有自己的上限。3.2 实现最简单的HTTP请求处理与响应现在我们写一个简单的循环accept连接读取请求这里我们简单读取不解析然后返回一个固定的HTTP响应。void handle_client(int client_fd) { char buffer[4096]; ssize_t bytes_read; // 1. 读取HTTP请求这里简单读取不做完整解析 bytes_read read(client_fd, buffer, sizeof(buffer) - 1); if (bytes_read 0) { buffer[bytes_read] \0; printf(Received request:\n%s\n, buffer); // 打印请求头看看 } else if (bytes_read 0) { printf(Client disconnected.\n); close(client_fd); return; } else { perror(read); close(client_fd); return; } // 2. 构造一个简单的HTTP/1.1 200 OK响应 const char *response_headers HTTP/1.1 200 OK\r\n Content-Type: text/html; charsetutf-8\r\n Connection: close\r\n // 处理完即关闭连接 \r\n; // 空行分隔头部和主体 const char *response_body htmlheadtitleTest Server/title/head\n bodyh1Hello from C HTTP Server!/h1/body/html\n; // 3. 发送响应头 write(client_fd, response_headers, strlen(response_headers)); // 4. 发送响应体 write(client_fd, response_body, strlen(response_body)); // 5. 关闭连接因为设置了Connection: close close(client_fd); } int main_single_thread() { int listen_fd create_listen_socket(PORT); if (listen_fd 0) { exit(EXIT_FAILURE); } printf(Single-threaded server started. Press CtrlC to stop.\n); while (1) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd; // 阻塞等待新连接 client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len); if (client_fd -1) { perror(accept); continue; // 接受失败继续循环 } // 打印客户端信息 char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, sizeof(client_ip)); printf(Accepted connection from %s:%d\n, client_ip, ntohs(client_addr.sin_port)); // 处理这个连接会阻塞直到处理完 handle_client(client_fd); } close(listen_fd); return 0; }这个服务器是严格串行的一次只能服务一个客户端。但它已经是一个能工作的HTTP服务器了。你可以用浏览器访问http://localhost:8080或者用curl命令测试它。4. 引入Epoll迈向高并发的第一步现在我们引入epoll将服务器改造成单线程事件驱动模型使其能够同时监视和处理多个连接。4.1 Epoll API核心用法epoll主要涉及三个系统调用epoll_create1(int flags): 创建一个epoll实例返回一个文件描述符。epoll_ctl(int epfd, int op, int fd, struct epoll_event *event): 管理epoll实例中的fd。op可以是EPOLL_CTL_ADD添加、EPOLL_CTL_MOD修改、EPOLL_CTL_DEL删除。epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout): 等待事件发生。返回就绪的事件数量并将它们填充到events数组中。我们需要将监听socket和所有客户端socket都添加到epoll中监听EPOLLIN可读事件。4.2 实现单Reactor单线程模型我们先实现一个最简单的模型只有一个epoll循环既负责accept新连接也负责处理所有已连接socket的读写。#include sys/epoll.h #define MAX_EVENTS 1024 int main_epoll_single() { int listen_fd create_listen_socket(PORT); if (listen_fd 0) exit(EXIT_FAILURE); // 1. 创建epoll实例 int epoll_fd epoll_create1(0); if (epoll_fd -1) { perror(epoll_create1); close(listen_fd); exit(EXIT_FAILURE); } // 2. 将监听socket添加到epoll监听读事件新连接 struct epoll_event ev; ev.events EPOLLIN; // 监听可读事件 ev.data.fd listen_fd; // 保存fd事件发生时能知道是哪个fd if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev) -1) { perror(epoll_ctl: listen_fd); close(epoll_fd); close(listen_fd); exit(EXIT_FAILURE); } struct epoll_event events[MAX_EVENTS]; printf(Epoll single-threaded server started.\n); while (1) { // 3. 等待事件发生-1表示无限阻塞 int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds -1) { perror(epoll_wait); break; } // 4. 处理所有就绪的事件 for (int i 0; i nfds; i) { int fd events[i].data.fd; uint32_t event_type events[i].events; // 如果是监听socket可读表示有新连接 if (fd listen_fd) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len); if (client_fd -1) { perror(accept); continue; } // 设置新socket为非阻塞模式重要 int flags fcntl(client_fd, F_GETFL, 0); fcntl(client_fd, F_SETFL, flags | O_NONBLOCK); // 将新客户端socket添加到epoll监听读事件 ev.events EPOLLIN | EPOLLET; // 边缘触发(Edge Trigger)模式 ev.data.fd client_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, ev) -1) { perror(epoll_ctl: client_fd); close(client_fd); } else { printf(New client connected, fd%d\n, client_fd); } } // 如果是客户端socket可读 else if (event_type EPOLLIN) { // 处理客户端请求这里调用之前的handle_client但需要改造为非阻塞式读取 handle_client_epoll(fd, epoll_fd); } // 处理错误事件如连接断开 else if (event_type (EPOLLERR | EPOLLHUP)) { printf(Client fd%d disconnected or error.\n, fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } close(listen_fd); close(epoll_fd); return 0; }这里的关键变化是非阻塞IO我们将客户端socket设置为非阻塞模式O_NONBLOCK。这是配合epoll高效工作的关键。在水平触发LT模式下如果不设置为非阻塞当数据没有一次性读完时epoll_wait会持续报告该fd可读导致忙等待。在边缘触发ET模式下必须使用非阻塞IO并循环读取直到EAGAIN或EWOULDBLOCK错误以确保读完所有数据。边缘触发ET模式我们在添加客户端fd时使用了EPOLLET标志。ET模式只在fd状态发生变化时比如从无数据到有数据通知一次效率更高但编程更复杂要求必须一次性读完或写完所有数据。LT模式默认则只要fd处于就绪状态就会持续通知。对于高性能服务器通常推荐使用ET模式非阻塞IO。统一的处理循环所有事件新连接、数据到达、连接关闭都在同一个循环中被处理。我们需要改造handle_client函数使其适应非阻塞读取和ET模式。void handle_client_epoll(int client_fd, int epoll_fd) { char buffer[4096]; ssize_t total_read 0; ssize_t bytes_read; // 非阻塞循环读取直到读完所有数据或遇到EAGAIN while (1) { bytes_read read(client_fd, buffer total_read, sizeof(buffer) - 1 - total_read); if (bytes_read 0) { total_read bytes_read; // 简单判断请求头是否接收完毕根据空行\r\n\r\n if (total_read 4 strstr(buffer, \r\n\r\n) ! NULL) { break; // 请求头已接收完整 } if (total_read sizeof(buffer) - 1) { // 缓冲区快满了可以动态扩容这里简单处理为请求过大 break; } } else if (bytes_read 0) { // 客户端关闭连接 printf(Client fd%d closed connection.\n, client_fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); close(client_fd); return; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 非阻塞socket数据已读完 break; } else { // 真正的读错误 perror(read); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); close(client_fd); return; } } } if (total_read 0) { buffer[total_read] \0; // 这里可以调用HTTP解析函数我们暂时简单处理 printf(Received %zd bytes from fd%d\n, total_read, client_fd); // 发送响应同样写操作也应该是非阻塞的这里简化 const char *response HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, Epoll!; write(client_fd, response, strlen(response)); } // 注意在ET模式下处理完读事件后如果需要继续监听写事件例如大响应需要分多次写 // 需要修改epoll监听的事件为EPOLLOUT。这里我们一次写完所以直接关闭连接。 // 生产环境应该更精细地管理连接状态如HTTP Keep-Alive。 epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); close(client_fd); }这个单线程epoll服务器已经具备了高并发的潜力它可以轻松应对数千个空闲或低活跃度的连接。然而它的业务处理handle_client_epoll中的请求解析和响应生成仍然在epoll循环线程中执行。如果某个请求的处理很慢比如需要复杂的计算或阻塞的IO它会阻塞整个事件循环影响其他所有连接的响应。这就是我们需要引入线程池的原因。5. 构建完整架构主从Reactor与线程池现在我们将实现之前设计的完整架构主线程负责accept多个工作线程从Reactor负责网络IO线程池负责业务处理。5.1 定义数据结构与全局变量首先我们需要定义一些结构来管理连接和任务。// connection.h #ifndef CONNECTION_H #define CONNECTION_H #include stdbool.h // 连接状态 typedef enum { CONN_STATE_READING, // 正在读取请求 CONN_STATE_PROCESSING, // 请求正在处理中在线程池 CONN_STATE_WRITING, // 正在写入响应 CONN_STATE_CLOSED // 连接已关闭 } conn_state_t; // 连接上下文结构体 typedef struct connection { int fd; // 客户端socket文件描述符 int epoll_fd; // 该连接所属的epoll实例工作线程的epoll_fd conn_state_t state; // 当前状态 char* read_buf; // 读缓冲区 size_t read_buf_size; // 读缓冲区大小 size_t read_len; // 已读取数据长度 char* write_buf; // 写缓冲区 size_t write_buf_size; // 写缓冲区大小 size_t write_len; // 待写入数据长度 size_t write_sent; // 已发送数据长度 // 可以添加更多字段如请求解析结果、响应内容等 void* request; // 指向解析后的请求结构 void* response; // 指向待发送的响应结构 } connection_t; // 任务结构体由工作线程投递给线程池 typedef struct task { void (*process_func)(struct task*); // 处理函数指针 connection_t* conn; // 关联的连接 // 可以添加其他任务参数 } task_t; // 创建和销毁连接 connection_t* conn_new(int fd, int epoll_fd); void conn_free(connection_t* conn); // 向连接所属的epoll实例修改监听事件 void conn_modify_events(int epoll_fd, int fd, uint32_t events); #endif// threadpool.h #ifndef THREADPOOL_H #define THREADPOOL_H typedef struct threadpool threadpool_t; // 创建线程池 threadpool_t* threadpool_create(int thread_num, int queue_size); // 向线程池添加任务 int threadpool_add_task(threadpool_t* pool, void (*func)(void*), void* arg); // 销毁线程池 int threadpool_destroy(threadpool_t* pool); #endif线程池的实现是一个经典的生产者-消费者模型涉及互斥锁和条件变量代码较长这里不展开。你可以参考许多开源实现如pthreadpool核心是维护一个任务队列工作线程等待任务主线程或工作线程投递任务。5.2 主线程主Reactor实现主线程只做一件事监听监听socketaccept新连接并以负载均衡的方式如轮询分发给工作线程。// worker_thread.h #ifndef WORKER_THREAD_H #define WORKER_THREAD_H // 工作线程上下文 typedef struct worker_thread { pthread_t tid; // 线程ID int epoll_fd; // 该工作线程自己的epoll实例 int event_fd; // 用于主线程通知工作线程有新连接的事件fd (eventfd) struct rb_root conn_tree; // 用于管理本线程所有连接的红黑树或哈希表key为fd pthread_mutex_t lock; // 保护conn_tree的锁 } worker_thread_t; // 初始化所有工作线程 int workers_init(int worker_num); // 主线程分发新连接到指定工作线程 int dispatch_new_conn_to_worker(int client_fd); // 停止所有工作线程 void workers_stop(); #endif主线程main函数int main(int argc, char* argv[]) { int worker_num 4; // 工作线程数通常设置为CPU核心数 int listen_fd create_listen_socket(PORT); if (listen_fd 0) exit(EXIT_FAILURE); // 1. 初始化工作线程每个线程会创建自己的epoll实例并运行 if (workers_init(worker_num) ! 0) { fprintf(stderr, Failed to init worker threads.\n); close(listen_fd); exit(EXIT_FAILURE); } // 2. 创建主线程的epoll只监听listen_fd int main_epoll_fd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(main_epoll_fd, EPOLL_CTL_ADD, listen_fd, ev); printf(Main Reactor started with %d workers.\n, worker_num); struct epoll_event events[128]; int next_worker_idx 0; // 简单的轮询分发 while (1) { int nfds epoll_wait(main_epoll_fd, events, 128, -1); if (nfds -1) { if (errno EINTR) continue; perror(main epoll_wait); break; } for (int i 0; i nfds; i) { if (events[i].data.fd listen_fd) { // 有新连接 struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr*)client_addr, addr_len); if (client_fd -1) { perror(accept); continue; } // 设置非阻塞 set_nonblocking(client_fd); // 轮询选择一个工作线程 int target_worker_idx next_worker_idx % worker_num; next_worker_idx; // 分发连接给选中的工作线程 if (dispatch_new_conn_to_worker(client_fd, target_worker_idx) ! 0) { fprintf(stderr, Failed to dispatch conn fd%d to worker %d\n, client_fd, target_worker_idx); close(client_fd); } else { printf(Dispatched new conn fd%d to worker %d\n, client_fd, target_worker_idx); } } } } workers_stop(); close(main_epoll_fd); close(listen_fd); return 0; }dispatch_new_conn_to_worker函数的核心是通过工作线程的event_fd一种高效的线程间通知机制或管道通知对应的工作线程“你有新连接了fd是XXX”。工作线程收到通知后会将这个新的client_fd添加到自己的epoll实例中。5.3 工作线程从Reactor实现工作线程运行一个独立的epoll_wait循环处理自己负责的所有连接的读写事件。void* worker_thread_func(void* arg) { worker_thread_t* worker (worker_thread_t*)arg; struct epoll_event events[MAX_EVENTS_PER_WORKER]; threadpool_t* pool threadpool_create(4, 1024); // 每个工作线程关联一个业务线程池 printf(Worker thread %lu started.\n, (unsigned long)pthread_self()); while (!worker-stop) { int nfds epoll_wait(worker-epoll_fd, events, MAX_EVENTS_PER_WORKER, 100); // 100ms超时 if (nfds -1) { if (errno EINTR) continue; perror(worker epoll_wait); break; } for (int i 0; i nfds; i) { int fd events[i].data.fd; uint32_t revents events[i].events; // 检查是否是用于接收新连接通知的event_fd if (fd worker-event_fd) { uint64_t u; read(worker-event_fd, u, sizeof(uint64_t)); // 清除eventfd的计数 // 从队列或共享结构中获取主线程分发的新client_fd并添加到epoll int new_fd fetch_new_fd_from_queue(worker); if (new_fd 0) { struct epoll_event ev; ev.events EPOLLIN | EPOLLET | EPOLLRDHUP; // EPOLLRDHUP用于检测对端关闭 ev.data.fd new_fd; epoll_ctl(worker-epoll_fd, EPOLL_CTL_ADD, new_fd, ev); // 创建connection_t并管理起来 connection_t* conn conn_new(new_fd, worker-epoll_fd); pthread_mutex_lock(worker-lock); // 将conn插入worker的conn_treekey为fd insert_connection(worker, new_fd, conn); pthread_mutex_unlock(worker-lock); } continue; } // 处理客户端socket事件 connection_t* conn find_connection(worker, fd); if (!conn) { // 连接可能已被移除忽略 epoll_ctl(worker-epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } if (revents (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { // 连接错误或对端关闭 printf(Connection fd%d error/closed.\n, fd); close_connection(worker, conn); continue; } if (revents EPOLLIN) { // 可读事件接收HTTP请求数据 if (conn-state CONN_STATE_READING) { if (read_request_data(conn) 0) { close_connection(worker, conn); continue; } // 判断请求是否接收完整例如收到了\r\n\r\n if (is_request_complete(conn)) { conn-state CONN_STATE_PROCESSING; // 将请求处理任务提交给线程池 task_t* task (task_t*)malloc(sizeof(task_t)); task-process_func process_http_request; task-conn conn; threadpool_add_task(pool, (void (*)(void*))task-process_func, task); // 注意此时epoll监听的事件可能需要修改例如移除EPOLLIN因为数据已读完 conn_modify_events(worker-epoll_fd, fd, EPOLLOUT | EPOLLET); // 示例改为监听写事件 } } } if (revents EPOLLOUT) { // 可写事件发送HTTP响应数据 if (conn-state CONN_STATE_WRITING) { if (write_response_data(conn) 0) { close_connection(worker, conn); continue; } if (is_response_sent_complete(conn)) { // 响应发送完毕根据HTTP头决定是否关闭连接或保持 if (should_keep_alive(conn)) { // 保持连接重置连接状态重新监听读事件 conn_reset_for_next_request(conn); conn_modify_events(worker-epoll_fd, fd, EPOLLIN | EPOLLET); } else { close_connection(worker, conn); } } } } } } threadpool_destroy(pool); return NULL; }5.4 线程池中的业务处理函数业务处理函数在线程池的线程中执行它不涉及任何网络IO只负责解析HTTP请求、生成响应内容。void process_http_request(task_t* task) { connection_t* conn task-conn; // 1. 解析conn-read_buf中的HTTP请求 // 解析方法、URL、协议版本、头部字段等存储到conn-request中 parse_http_request(conn); // 2. 根据请求生成响应内容 // 例如静态文件服务、API处理等 // 将响应头和响应体准备好填充到conn-write_buf设置conn-write_len generate_http_response(conn); // 3. 业务处理完成将连接状态改为可写并通知工作线程可能需要通过eventfd或修改epoll事件 // 这里简化处理我们假设generate_http_response已经修改了conn-state // 实际中可能需要通过工作线程的eventfd通知它或者业务线程直接修改epoll事件需线程同步。 // 一种常见做法是业务线程将任务结果放回工作线程的任务队列工作线程在epoll_wait返回后处理这些结果。 notify_worker_response_ready(conn); }6. 关键问题与性能优化实战实现过程中会遇到很多坑这里记录几个关键问题和优化点。6.1 缓冲区设计与内存管理网络编程中缓冲区的设计直接影响性能和稳定性。定长缓冲区 vs 动态缓冲区简单的定长缓冲区如char buf[4096]容易实现但无法处理超长请求或大文件。生产环境需要使用动态增长的缓冲区如链表式缓冲区或连续内存扩容。读写缓冲区分离connection_t中我们设计了read_buf和write_buf。读写分离可以避免逻辑混乱。写缓冲区尤其重要因为响应可能很大如文件需要支持“写不完下次再写”的模式。内存池频繁的malloc/free会导致内存碎片和性能下降。可以为connection_t和缓冲区实现一个简单的内存池一次性申请大块内存循环使用。6.2 惊群问题Thundering Herd在多进程/多线程模型中如果多个进程/线程同时阻塞在accept同一个监听socket上当新连接到来时内核会唤醒所有进程/线程但只有一个能accept成功其他都被唤醒后又继续睡眠造成不必要的上下文切换和性能损耗。这就是“惊群”。解决方案在现代Linux内核2.6中对accept的惊群问题已经解决多个线程accept同一个socket是安全的内核会保证只有一个线程成功。但对于epoll如果多个线程共享同一个epoll实例并调用epoll_wait仍然可能存在惊群。因此我们的架构中每个工作线程拥有自己独立的epoll实例主线程通过负载均衡分发连接从根源上避免了惊群。6.3 连接状态管理与超时服务器需要维护成千上万个连接的状态必须妥善管理。数据结构工作线程需要用高效的数据结构如红黑树、哈希表来存储fd到connection_t的映射以便在事件触发时快速找到上下文。超时处理客户端可能长时间不发送请求慢连接攻击或发送过慢。服务器必须设置超时机制。可以在connection_t中记录最后一次活动的时间戳。工作线程的epoll_wait可以设置一个较小的超时时间如100ms每次循环检查所有连接是否超时超时的连接直接关闭。也可以使用timerfd或时间轮等更高效的定时器方案。6.4 HTTP协议处理的完整性我们的示例只处理了最简单的请求。一个完整的HTTP服务器还需要请求行与头部解析严格按照RFC标准解析Method、URI、Version以及各个头部字段。请求体处理支持Content-Length和Transfer-Encoding: chunked两种方式。响应生成正确设置状态码、头部如Content-Type、Content-Length、Connection、Server等。连接管理支持HTTP/1.1的持久连接Keep-Alive在一个TCP连接上处理多个请求响应。安全性对请求URI进行规范化防止路径遍历攻击如../../../etc/passwd。6.5 性能测试与调试编写完成后需要用工具进行压测。压测工具ab(ApacheBench)、wrk、siege或更现代的hey。关键指标QPS (Queries Per Second)每秒处理的请求数。吞吐量 (Throughput)每秒传输的数据量。延迟 (Latency)平均、P95、P99响应时间。并发连接数服务器能稳定维持的连接数。系统监控在压测时使用top、vmstat、pidstat等工具观察CPU、内存、上下文切换次数。我们的目标是让CPU主要消耗在用户态处理业务而不是系统态系统调用、上下文切换。一个常见的性能瓶颈是锁竞争。在我们的架构中主线程与工作线程之间传递新连接描述符fd时如果使用共享队列就需要加锁。可以使用无锁队列如boost::lockfree::spsc_queue或Linux的eventfd配合EPOLLEXCLUSIVE标志来减少竞争。7. 从示例到产品还有多远通过以上步骤我们已经构建了一个具备高并发潜力的HTTP服务器骨架。但它离一个真正的产品级服务器如Nginx还有很长的路要走。以下是一些可以继续深入的方向协议支持添加HTTPSTLS/SSL支持这需要集成如OpenSSL或mbedTLS库。静态文件服务实现高效的文件发送涉及sendfile系统调用零拷贝、文件缓存、MIME类型识别等。模块化与配置设计插件或模块架构支持通过配置文件加载不同的处理模块如反向代理、负载均衡、缓存、访问控制。日志系统实现异步、分级、可配置的日志系统便于问题排查。信号处理与优雅退出正确处理SIGINT、SIGTERM等信号实现平滑关闭完成正在处理的请求后再退出。更精细的流量控制与限流防止恶意请求打满服务器资源。亲手实现这个项目最大的收获不是代码本身而是对“高并发”背后那些复杂权衡的深刻理解。你会明白为什么Nginx要采用多进程模型为什么Redis是单线程却依然高效为什么Go语言的goroutine在网络编程中如此流行。这些洞察力是直接使用现成框架无法获得的。当你再遇到线上服务的性能问题时你的思考会直接穿透到TCP缓冲区、epoll事件表、线程上下文切换这些底层细节从而能更快地定位和解决问题。这或许就是系统编程的魅力所在。
C语言实现高并发HTTP服务器:从Socket到Epoll与线程池架构
1. 项目概述为什么我们需要自己动手写一个高并发HTTP服务器在当今这个万物互联的时代HTTP协议几乎是无处不在的基石。从你刷的网页、用的App接口到各种物联网设备的控制面板背后都离不开一个稳定高效的HTTP服务器。市面上成熟的方案很多像Nginx、Apache它们功能强大、生态完善几乎是生产环境的首选。那为什么我们还要用C/C从零开始吭哧吭哧地自己实现一个呢这绝不是为了重复造轮子而是一次深入理解网络编程、操作系统和并发模型核心原理的绝佳实践。对于C/C开发者尤其是那些对系统底层、性能优化有追求的工程师来说亲手实现一个高并发HTTP服务器就像汽车工程师亲手拆装一台发动机。你能清晰地看到每一个“气缸”线程/进程如何工作燃油请求如何被喷射和点燃处理废气响应如何被排出。这个过程会让你对“高并发”这三个字有肌肉记忆般的理解它不仅仅是开多几个线程那么简单而是涉及到I/O模型的选择、内存的管理、锁的竞争、上下文切换的开销等一系列精密的权衡。这个项目我们将聚焦于用C语言兼容C环境实现一个支持高并发的简易HTTP服务器。我会带你从最基础的Socket编程开始一步步构建出能同时处理成千上万个连接的服务器核心。我们会重点探讨几种经典的并发模型比如多进程、多线程以及更高效的I/O多路复用技术如select、poll特别是epoll。最终你会得到一套可以运行、可以测试、并且能清晰看到每一行代码作用的完整示例。无论你是想巩固网络编程基础为面试做准备还是为某个嵌入式或高性能场景寻找轻量级解决方案这个“手搓”服务器的经历都会让你受益匪浅。2. 核心架构与并发模型选型实现一个服务器首先要确定它的“骨架”也就是并发模型。模型选对了性能事半功倍选错了可能事倍功半甚至漏洞百出。2.1 从单线程阻塞式到多线程/多进程最原始的服务器模型是单线程阻塞式。它一次只能处理一个连接accept一个客户端然后read请求、处理、write响应全程阻塞直到这个连接关闭才能服务下一个。这在学习Socket API时很常见但实战中毫无用处因为任何一个慢请求都会让整个服务器卡住。为了同时服务多个客户端很自然的想法是引入“多个工人”。这就是多进程模型和多线程模型。多进程模型主进程只负责accept新连接一旦接受就fork()出一个子进程来处理这个连接的所有事务。子进程独立拥有自己的地址空间一个进程崩溃不会影响其他进程安全性好。但进程创建、销毁、上下文切换的成本很高且进程间通信IPC比较复杂内存占用也大。多线程模型主线程accept然后创建新线程或从线程池取来处理连接。线程共享进程的内存空间创建和切换开销比进程小数据共享方便。但正因共享内存需要引入锁等机制来保证线程安全编程复杂度陡增一个线程的野指针可能搞垮整个进程。这两种模型在连接数不多比如几百个时是可行的。但它们有一个共同的、根本性的瓶颈一个连接对应一个线程/进程。当连接数飙升到几千、几万时系统光是创建和管理这些线程/进程就会耗尽资源大部分CPU时间都花在了上下文切换上而不是真正的业务处理。这就是著名的 C10K问题 的由来。2.2 I/O多路复用突破C10K的关键为了解决上述瓶颈我们需要更高效的I/O事件管理机制——I/O多路复用。它的核心思想是用一个单独的线程或少量线程来监视大量的文件描述符fd如socket一旦某个fd就绪可读、可写或出错就通知程序进行相应的读写操作。这样一个线程就能管理成千上万个连接。Linux下主要有三种技术select、poll和epoll。select/poll它们的工作原理类似都是通过轮询的方式检查所有被监视的fd。select有fd数量的限制通常是1024poll则没有。但它们的共同问题是效率随监控fd的数量线性下降。当fd很多但活跃很少时大部分轮询都是无效的浪费CPU。epoll这是Linux 2.6内核后引入的是当前实现高性能网络服务器的首选。它采用了事件驱动的方式。程序先将需要监控的fd添加到epoll实例epoll_create然后通过epoll_wait等待事件发生。内核只会返回那些真正发生了事件的fd而不是轮询全部。这种“事件通知”机制在连接数巨大且活跃比例不高时效率远超select/poll。实操心得在现代Linux服务器编程中除非有极强的跨平台需求因为epoll是Linux特有的否则应毫不犹豫地选择epoll作为高并发的基础。select/poll更多用于教学或连接数极少的场景。Windows平台有类似的IOCP完成端口模型设计哲学不同但同样高效。2.3 本项目架构Reactor模式与线程池结合综合以上分析我们将采用一个混合架构这也是Nginx、Redis等高性能服务器普遍采用的模式主从Reactor模式 线程池。主Reactor单线程主线程充当主Reactor它运行一个epoll事件循环只负责监听监听socket用于accept新连接的事件。当有新连接到来时主线程accept它然后将新建立的客户端socket连接fd以某种方式比如通过轮询分发给多个从Reactor。从Reactor多线程我们创建多个工作线程每个工作线程都有自己的epoll实例它们就是从Reactor。主线程将新的客户端fd分配给某个工作线程的epoll去监控。每个工作线程独立运行自己的epoll_wait循环处理自己负责的那些连接上的读写事件。线程池处理业务逻辑当工作线程的epoll捕获到一个客户端fd的可读事件HTTP请求报文已到达内核缓冲区时它并不自己执行复杂的HTTP解析和业务处理这可能会阻塞该工作线程影响其他连接的响应。而是将这个“请求数据”封装成一个任务投递到一个全局的线程池中。线程池中的业务线程专门负责计算密集型或可能阻塞的任务如解析HTTP、访问数据库、渲染模板等处理完毕后将响应数据写回通常通过回调或通知工作线程写回。这个架构的优势非常明显高并发利用epoll管理海量连接。职责分离网络I/Oepoll循环与业务处理线程池解耦避免慢业务阻塞快I/O。充分利用多核工作线程和业务线程都可以根据CPU核心数配置并行处理。扩展性好可以方便地调整各组件数量。接下来我们就将按照这个架构一步步用代码实现。3. 基础构建从Socket到HTTP解析在搭建复杂架构之前我们先实现一个最基础的、单线程的HTTP服务器它能够接受连接并返回一个简单的HTTP响应。这能帮助我们巩固Socket编程和HTTP协议的基础。3.1 创建监听Socket与绑定端口一切始于创建一个监听socket。这个过程是标准的“三部曲”socket()-bind()-listen()。#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include stdio.h #include string.h #include stdlib.h #define PORT 8080 #define BACKLOG 1024 // 全连接队列的最大长度 int create_listen_socket(int port) { int listen_fd; struct sockaddr_in server_addr; // 1. 创建socket (IPv4, TCP流式套接字) if ((listen_fd socket(AF_INET, SOCK_STREAM, 0)) -1) { perror(socket); return -1; } // 2. 设置SO_REUSEADDR选项避免“Address already in use”错误 int optval 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, optval, sizeof(optval)) -1) { perror(setsockopt); close(listen_fd); return -1; } // 3. 绑定地址和端口 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 server_addr.sin_port htons(port); // 端口号主机字节序转网络字节序 if (bind(listen_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) -1) { perror(bind); close(listen_fd); return -1; } // 4. 开始监听指定全连接队列大小 if (listen(listen_fd, BACKLOG) -1) { perror(listen); close(listen_fd); return -1; } printf(Server listening on port %d\n, port); return listen_fd; }注意事项SO_REUSEADDR这个选项至关重要尤其是在服务器重启时。没有它之前连接处于TIME_WAIT状态的socket会占用端口导致bind失败。设置它允许内核重用处于TIME_WAIT状态的地址。INADDR_ANY表示绑定到本机所有IP地址。如果你只想监听特定网卡如127.0.0.1可以改为inet_addr(127.0.0.1)。BACKLOGlisten的第二个参数指定了“全连接队列”的最大长度。当客户端完成三次握手后连接会进入这个队列等待服务器accept。如果队列满了新的连接会被忽略或拒绝。这个值需要根据服务器预期压力调整但不宜过大内核也有自己的上限。3.2 实现最简单的HTTP请求处理与响应现在我们写一个简单的循环accept连接读取请求这里我们简单读取不解析然后返回一个固定的HTTP响应。void handle_client(int client_fd) { char buffer[4096]; ssize_t bytes_read; // 1. 读取HTTP请求这里简单读取不做完整解析 bytes_read read(client_fd, buffer, sizeof(buffer) - 1); if (bytes_read 0) { buffer[bytes_read] \0; printf(Received request:\n%s\n, buffer); // 打印请求头看看 } else if (bytes_read 0) { printf(Client disconnected.\n); close(client_fd); return; } else { perror(read); close(client_fd); return; } // 2. 构造一个简单的HTTP/1.1 200 OK响应 const char *response_headers HTTP/1.1 200 OK\r\n Content-Type: text/html; charsetutf-8\r\n Connection: close\r\n // 处理完即关闭连接 \r\n; // 空行分隔头部和主体 const char *response_body htmlheadtitleTest Server/title/head\n bodyh1Hello from C HTTP Server!/h1/body/html\n; // 3. 发送响应头 write(client_fd, response_headers, strlen(response_headers)); // 4. 发送响应体 write(client_fd, response_body, strlen(response_body)); // 5. 关闭连接因为设置了Connection: close close(client_fd); } int main_single_thread() { int listen_fd create_listen_socket(PORT); if (listen_fd 0) { exit(EXIT_FAILURE); } printf(Single-threaded server started. Press CtrlC to stop.\n); while (1) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd; // 阻塞等待新连接 client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len); if (client_fd -1) { perror(accept); continue; // 接受失败继续循环 } // 打印客户端信息 char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, sizeof(client_ip)); printf(Accepted connection from %s:%d\n, client_ip, ntohs(client_addr.sin_port)); // 处理这个连接会阻塞直到处理完 handle_client(client_fd); } close(listen_fd); return 0; }这个服务器是严格串行的一次只能服务一个客户端。但它已经是一个能工作的HTTP服务器了。你可以用浏览器访问http://localhost:8080或者用curl命令测试它。4. 引入Epoll迈向高并发的第一步现在我们引入epoll将服务器改造成单线程事件驱动模型使其能够同时监视和处理多个连接。4.1 Epoll API核心用法epoll主要涉及三个系统调用epoll_create1(int flags): 创建一个epoll实例返回一个文件描述符。epoll_ctl(int epfd, int op, int fd, struct epoll_event *event): 管理epoll实例中的fd。op可以是EPOLL_CTL_ADD添加、EPOLL_CTL_MOD修改、EPOLL_CTL_DEL删除。epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout): 等待事件发生。返回就绪的事件数量并将它们填充到events数组中。我们需要将监听socket和所有客户端socket都添加到epoll中监听EPOLLIN可读事件。4.2 实现单Reactor单线程模型我们先实现一个最简单的模型只有一个epoll循环既负责accept新连接也负责处理所有已连接socket的读写。#include sys/epoll.h #define MAX_EVENTS 1024 int main_epoll_single() { int listen_fd create_listen_socket(PORT); if (listen_fd 0) exit(EXIT_FAILURE); // 1. 创建epoll实例 int epoll_fd epoll_create1(0); if (epoll_fd -1) { perror(epoll_create1); close(listen_fd); exit(EXIT_FAILURE); } // 2. 将监听socket添加到epoll监听读事件新连接 struct epoll_event ev; ev.events EPOLLIN; // 监听可读事件 ev.data.fd listen_fd; // 保存fd事件发生时能知道是哪个fd if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev) -1) { perror(epoll_ctl: listen_fd); close(epoll_fd); close(listen_fd); exit(EXIT_FAILURE); } struct epoll_event events[MAX_EVENTS]; printf(Epoll single-threaded server started.\n); while (1) { // 3. 等待事件发生-1表示无限阻塞 int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds -1) { perror(epoll_wait); break; } // 4. 处理所有就绪的事件 for (int i 0; i nfds; i) { int fd events[i].data.fd; uint32_t event_type events[i].events; // 如果是监听socket可读表示有新连接 if (fd listen_fd) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len); if (client_fd -1) { perror(accept); continue; } // 设置新socket为非阻塞模式重要 int flags fcntl(client_fd, F_GETFL, 0); fcntl(client_fd, F_SETFL, flags | O_NONBLOCK); // 将新客户端socket添加到epoll监听读事件 ev.events EPOLLIN | EPOLLET; // 边缘触发(Edge Trigger)模式 ev.data.fd client_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, ev) -1) { perror(epoll_ctl: client_fd); close(client_fd); } else { printf(New client connected, fd%d\n, client_fd); } } // 如果是客户端socket可读 else if (event_type EPOLLIN) { // 处理客户端请求这里调用之前的handle_client但需要改造为非阻塞式读取 handle_client_epoll(fd, epoll_fd); } // 处理错误事件如连接断开 else if (event_type (EPOLLERR | EPOLLHUP)) { printf(Client fd%d disconnected or error.\n, fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } close(listen_fd); close(epoll_fd); return 0; }这里的关键变化是非阻塞IO我们将客户端socket设置为非阻塞模式O_NONBLOCK。这是配合epoll高效工作的关键。在水平触发LT模式下如果不设置为非阻塞当数据没有一次性读完时epoll_wait会持续报告该fd可读导致忙等待。在边缘触发ET模式下必须使用非阻塞IO并循环读取直到EAGAIN或EWOULDBLOCK错误以确保读完所有数据。边缘触发ET模式我们在添加客户端fd时使用了EPOLLET标志。ET模式只在fd状态发生变化时比如从无数据到有数据通知一次效率更高但编程更复杂要求必须一次性读完或写完所有数据。LT模式默认则只要fd处于就绪状态就会持续通知。对于高性能服务器通常推荐使用ET模式非阻塞IO。统一的处理循环所有事件新连接、数据到达、连接关闭都在同一个循环中被处理。我们需要改造handle_client函数使其适应非阻塞读取和ET模式。void handle_client_epoll(int client_fd, int epoll_fd) { char buffer[4096]; ssize_t total_read 0; ssize_t bytes_read; // 非阻塞循环读取直到读完所有数据或遇到EAGAIN while (1) { bytes_read read(client_fd, buffer total_read, sizeof(buffer) - 1 - total_read); if (bytes_read 0) { total_read bytes_read; // 简单判断请求头是否接收完毕根据空行\r\n\r\n if (total_read 4 strstr(buffer, \r\n\r\n) ! NULL) { break; // 请求头已接收完整 } if (total_read sizeof(buffer) - 1) { // 缓冲区快满了可以动态扩容这里简单处理为请求过大 break; } } else if (bytes_read 0) { // 客户端关闭连接 printf(Client fd%d closed connection.\n, client_fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); close(client_fd); return; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 非阻塞socket数据已读完 break; } else { // 真正的读错误 perror(read); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); close(client_fd); return; } } } if (total_read 0) { buffer[total_read] \0; // 这里可以调用HTTP解析函数我们暂时简单处理 printf(Received %zd bytes from fd%d\n, total_read, client_fd); // 发送响应同样写操作也应该是非阻塞的这里简化 const char *response HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, Epoll!; write(client_fd, response, strlen(response)); } // 注意在ET模式下处理完读事件后如果需要继续监听写事件例如大响应需要分多次写 // 需要修改epoll监听的事件为EPOLLOUT。这里我们一次写完所以直接关闭连接。 // 生产环境应该更精细地管理连接状态如HTTP Keep-Alive。 epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); close(client_fd); }这个单线程epoll服务器已经具备了高并发的潜力它可以轻松应对数千个空闲或低活跃度的连接。然而它的业务处理handle_client_epoll中的请求解析和响应生成仍然在epoll循环线程中执行。如果某个请求的处理很慢比如需要复杂的计算或阻塞的IO它会阻塞整个事件循环影响其他所有连接的响应。这就是我们需要引入线程池的原因。5. 构建完整架构主从Reactor与线程池现在我们将实现之前设计的完整架构主线程负责accept多个工作线程从Reactor负责网络IO线程池负责业务处理。5.1 定义数据结构与全局变量首先我们需要定义一些结构来管理连接和任务。// connection.h #ifndef CONNECTION_H #define CONNECTION_H #include stdbool.h // 连接状态 typedef enum { CONN_STATE_READING, // 正在读取请求 CONN_STATE_PROCESSING, // 请求正在处理中在线程池 CONN_STATE_WRITING, // 正在写入响应 CONN_STATE_CLOSED // 连接已关闭 } conn_state_t; // 连接上下文结构体 typedef struct connection { int fd; // 客户端socket文件描述符 int epoll_fd; // 该连接所属的epoll实例工作线程的epoll_fd conn_state_t state; // 当前状态 char* read_buf; // 读缓冲区 size_t read_buf_size; // 读缓冲区大小 size_t read_len; // 已读取数据长度 char* write_buf; // 写缓冲区 size_t write_buf_size; // 写缓冲区大小 size_t write_len; // 待写入数据长度 size_t write_sent; // 已发送数据长度 // 可以添加更多字段如请求解析结果、响应内容等 void* request; // 指向解析后的请求结构 void* response; // 指向待发送的响应结构 } connection_t; // 任务结构体由工作线程投递给线程池 typedef struct task { void (*process_func)(struct task*); // 处理函数指针 connection_t* conn; // 关联的连接 // 可以添加其他任务参数 } task_t; // 创建和销毁连接 connection_t* conn_new(int fd, int epoll_fd); void conn_free(connection_t* conn); // 向连接所属的epoll实例修改监听事件 void conn_modify_events(int epoll_fd, int fd, uint32_t events); #endif// threadpool.h #ifndef THREADPOOL_H #define THREADPOOL_H typedef struct threadpool threadpool_t; // 创建线程池 threadpool_t* threadpool_create(int thread_num, int queue_size); // 向线程池添加任务 int threadpool_add_task(threadpool_t* pool, void (*func)(void*), void* arg); // 销毁线程池 int threadpool_destroy(threadpool_t* pool); #endif线程池的实现是一个经典的生产者-消费者模型涉及互斥锁和条件变量代码较长这里不展开。你可以参考许多开源实现如pthreadpool核心是维护一个任务队列工作线程等待任务主线程或工作线程投递任务。5.2 主线程主Reactor实现主线程只做一件事监听监听socketaccept新连接并以负载均衡的方式如轮询分发给工作线程。// worker_thread.h #ifndef WORKER_THREAD_H #define WORKER_THREAD_H // 工作线程上下文 typedef struct worker_thread { pthread_t tid; // 线程ID int epoll_fd; // 该工作线程自己的epoll实例 int event_fd; // 用于主线程通知工作线程有新连接的事件fd (eventfd) struct rb_root conn_tree; // 用于管理本线程所有连接的红黑树或哈希表key为fd pthread_mutex_t lock; // 保护conn_tree的锁 } worker_thread_t; // 初始化所有工作线程 int workers_init(int worker_num); // 主线程分发新连接到指定工作线程 int dispatch_new_conn_to_worker(int client_fd); // 停止所有工作线程 void workers_stop(); #endif主线程main函数int main(int argc, char* argv[]) { int worker_num 4; // 工作线程数通常设置为CPU核心数 int listen_fd create_listen_socket(PORT); if (listen_fd 0) exit(EXIT_FAILURE); // 1. 初始化工作线程每个线程会创建自己的epoll实例并运行 if (workers_init(worker_num) ! 0) { fprintf(stderr, Failed to init worker threads.\n); close(listen_fd); exit(EXIT_FAILURE); } // 2. 创建主线程的epoll只监听listen_fd int main_epoll_fd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(main_epoll_fd, EPOLL_CTL_ADD, listen_fd, ev); printf(Main Reactor started with %d workers.\n, worker_num); struct epoll_event events[128]; int next_worker_idx 0; // 简单的轮询分发 while (1) { int nfds epoll_wait(main_epoll_fd, events, 128, -1); if (nfds -1) { if (errno EINTR) continue; perror(main epoll_wait); break; } for (int i 0; i nfds; i) { if (events[i].data.fd listen_fd) { // 有新连接 struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr*)client_addr, addr_len); if (client_fd -1) { perror(accept); continue; } // 设置非阻塞 set_nonblocking(client_fd); // 轮询选择一个工作线程 int target_worker_idx next_worker_idx % worker_num; next_worker_idx; // 分发连接给选中的工作线程 if (dispatch_new_conn_to_worker(client_fd, target_worker_idx) ! 0) { fprintf(stderr, Failed to dispatch conn fd%d to worker %d\n, client_fd, target_worker_idx); close(client_fd); } else { printf(Dispatched new conn fd%d to worker %d\n, client_fd, target_worker_idx); } } } } workers_stop(); close(main_epoll_fd); close(listen_fd); return 0; }dispatch_new_conn_to_worker函数的核心是通过工作线程的event_fd一种高效的线程间通知机制或管道通知对应的工作线程“你有新连接了fd是XXX”。工作线程收到通知后会将这个新的client_fd添加到自己的epoll实例中。5.3 工作线程从Reactor实现工作线程运行一个独立的epoll_wait循环处理自己负责的所有连接的读写事件。void* worker_thread_func(void* arg) { worker_thread_t* worker (worker_thread_t*)arg; struct epoll_event events[MAX_EVENTS_PER_WORKER]; threadpool_t* pool threadpool_create(4, 1024); // 每个工作线程关联一个业务线程池 printf(Worker thread %lu started.\n, (unsigned long)pthread_self()); while (!worker-stop) { int nfds epoll_wait(worker-epoll_fd, events, MAX_EVENTS_PER_WORKER, 100); // 100ms超时 if (nfds -1) { if (errno EINTR) continue; perror(worker epoll_wait); break; } for (int i 0; i nfds; i) { int fd events[i].data.fd; uint32_t revents events[i].events; // 检查是否是用于接收新连接通知的event_fd if (fd worker-event_fd) { uint64_t u; read(worker-event_fd, u, sizeof(uint64_t)); // 清除eventfd的计数 // 从队列或共享结构中获取主线程分发的新client_fd并添加到epoll int new_fd fetch_new_fd_from_queue(worker); if (new_fd 0) { struct epoll_event ev; ev.events EPOLLIN | EPOLLET | EPOLLRDHUP; // EPOLLRDHUP用于检测对端关闭 ev.data.fd new_fd; epoll_ctl(worker-epoll_fd, EPOLL_CTL_ADD, new_fd, ev); // 创建connection_t并管理起来 connection_t* conn conn_new(new_fd, worker-epoll_fd); pthread_mutex_lock(worker-lock); // 将conn插入worker的conn_treekey为fd insert_connection(worker, new_fd, conn); pthread_mutex_unlock(worker-lock); } continue; } // 处理客户端socket事件 connection_t* conn find_connection(worker, fd); if (!conn) { // 连接可能已被移除忽略 epoll_ctl(worker-epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } if (revents (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { // 连接错误或对端关闭 printf(Connection fd%d error/closed.\n, fd); close_connection(worker, conn); continue; } if (revents EPOLLIN) { // 可读事件接收HTTP请求数据 if (conn-state CONN_STATE_READING) { if (read_request_data(conn) 0) { close_connection(worker, conn); continue; } // 判断请求是否接收完整例如收到了\r\n\r\n if (is_request_complete(conn)) { conn-state CONN_STATE_PROCESSING; // 将请求处理任务提交给线程池 task_t* task (task_t*)malloc(sizeof(task_t)); task-process_func process_http_request; task-conn conn; threadpool_add_task(pool, (void (*)(void*))task-process_func, task); // 注意此时epoll监听的事件可能需要修改例如移除EPOLLIN因为数据已读完 conn_modify_events(worker-epoll_fd, fd, EPOLLOUT | EPOLLET); // 示例改为监听写事件 } } } if (revents EPOLLOUT) { // 可写事件发送HTTP响应数据 if (conn-state CONN_STATE_WRITING) { if (write_response_data(conn) 0) { close_connection(worker, conn); continue; } if (is_response_sent_complete(conn)) { // 响应发送完毕根据HTTP头决定是否关闭连接或保持 if (should_keep_alive(conn)) { // 保持连接重置连接状态重新监听读事件 conn_reset_for_next_request(conn); conn_modify_events(worker-epoll_fd, fd, EPOLLIN | EPOLLET); } else { close_connection(worker, conn); } } } } } } threadpool_destroy(pool); return NULL; }5.4 线程池中的业务处理函数业务处理函数在线程池的线程中执行它不涉及任何网络IO只负责解析HTTP请求、生成响应内容。void process_http_request(task_t* task) { connection_t* conn task-conn; // 1. 解析conn-read_buf中的HTTP请求 // 解析方法、URL、协议版本、头部字段等存储到conn-request中 parse_http_request(conn); // 2. 根据请求生成响应内容 // 例如静态文件服务、API处理等 // 将响应头和响应体准备好填充到conn-write_buf设置conn-write_len generate_http_response(conn); // 3. 业务处理完成将连接状态改为可写并通知工作线程可能需要通过eventfd或修改epoll事件 // 这里简化处理我们假设generate_http_response已经修改了conn-state // 实际中可能需要通过工作线程的eventfd通知它或者业务线程直接修改epoll事件需线程同步。 // 一种常见做法是业务线程将任务结果放回工作线程的任务队列工作线程在epoll_wait返回后处理这些结果。 notify_worker_response_ready(conn); }6. 关键问题与性能优化实战实现过程中会遇到很多坑这里记录几个关键问题和优化点。6.1 缓冲区设计与内存管理网络编程中缓冲区的设计直接影响性能和稳定性。定长缓冲区 vs 动态缓冲区简单的定长缓冲区如char buf[4096]容易实现但无法处理超长请求或大文件。生产环境需要使用动态增长的缓冲区如链表式缓冲区或连续内存扩容。读写缓冲区分离connection_t中我们设计了read_buf和write_buf。读写分离可以避免逻辑混乱。写缓冲区尤其重要因为响应可能很大如文件需要支持“写不完下次再写”的模式。内存池频繁的malloc/free会导致内存碎片和性能下降。可以为connection_t和缓冲区实现一个简单的内存池一次性申请大块内存循环使用。6.2 惊群问题Thundering Herd在多进程/多线程模型中如果多个进程/线程同时阻塞在accept同一个监听socket上当新连接到来时内核会唤醒所有进程/线程但只有一个能accept成功其他都被唤醒后又继续睡眠造成不必要的上下文切换和性能损耗。这就是“惊群”。解决方案在现代Linux内核2.6中对accept的惊群问题已经解决多个线程accept同一个socket是安全的内核会保证只有一个线程成功。但对于epoll如果多个线程共享同一个epoll实例并调用epoll_wait仍然可能存在惊群。因此我们的架构中每个工作线程拥有自己独立的epoll实例主线程通过负载均衡分发连接从根源上避免了惊群。6.3 连接状态管理与超时服务器需要维护成千上万个连接的状态必须妥善管理。数据结构工作线程需要用高效的数据结构如红黑树、哈希表来存储fd到connection_t的映射以便在事件触发时快速找到上下文。超时处理客户端可能长时间不发送请求慢连接攻击或发送过慢。服务器必须设置超时机制。可以在connection_t中记录最后一次活动的时间戳。工作线程的epoll_wait可以设置一个较小的超时时间如100ms每次循环检查所有连接是否超时超时的连接直接关闭。也可以使用timerfd或时间轮等更高效的定时器方案。6.4 HTTP协议处理的完整性我们的示例只处理了最简单的请求。一个完整的HTTP服务器还需要请求行与头部解析严格按照RFC标准解析Method、URI、Version以及各个头部字段。请求体处理支持Content-Length和Transfer-Encoding: chunked两种方式。响应生成正确设置状态码、头部如Content-Type、Content-Length、Connection、Server等。连接管理支持HTTP/1.1的持久连接Keep-Alive在一个TCP连接上处理多个请求响应。安全性对请求URI进行规范化防止路径遍历攻击如../../../etc/passwd。6.5 性能测试与调试编写完成后需要用工具进行压测。压测工具ab(ApacheBench)、wrk、siege或更现代的hey。关键指标QPS (Queries Per Second)每秒处理的请求数。吞吐量 (Throughput)每秒传输的数据量。延迟 (Latency)平均、P95、P99响应时间。并发连接数服务器能稳定维持的连接数。系统监控在压测时使用top、vmstat、pidstat等工具观察CPU、内存、上下文切换次数。我们的目标是让CPU主要消耗在用户态处理业务而不是系统态系统调用、上下文切换。一个常见的性能瓶颈是锁竞争。在我们的架构中主线程与工作线程之间传递新连接描述符fd时如果使用共享队列就需要加锁。可以使用无锁队列如boost::lockfree::spsc_queue或Linux的eventfd配合EPOLLEXCLUSIVE标志来减少竞争。7. 从示例到产品还有多远通过以上步骤我们已经构建了一个具备高并发潜力的HTTP服务器骨架。但它离一个真正的产品级服务器如Nginx还有很长的路要走。以下是一些可以继续深入的方向协议支持添加HTTPSTLS/SSL支持这需要集成如OpenSSL或mbedTLS库。静态文件服务实现高效的文件发送涉及sendfile系统调用零拷贝、文件缓存、MIME类型识别等。模块化与配置设计插件或模块架构支持通过配置文件加载不同的处理模块如反向代理、负载均衡、缓存、访问控制。日志系统实现异步、分级、可配置的日志系统便于问题排查。信号处理与优雅退出正确处理SIGINT、SIGTERM等信号实现平滑关闭完成正在处理的请求后再退出。更精细的流量控制与限流防止恶意请求打满服务器资源。亲手实现这个项目最大的收获不是代码本身而是对“高并发”背后那些复杂权衡的深刻理解。你会明白为什么Nginx要采用多进程模型为什么Redis是单线程却依然高效为什么Go语言的goroutine在网络编程中如此流行。这些洞察力是直接使用现成框架无法获得的。当你再遇到线上服务的性能问题时你的思考会直接穿透到TCP缓冲区、epoll事件表、线程上下文切换这些底层细节从而能更快地定位和解决问题。这或许就是系统编程的魅力所在。