ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写C语言WebServer:epoll与线程池实战

从零手写C语言WebServer:epoll与线程池实战

简介:这份资源围绕C语言实现简易Web服务器展开,面向具备一定C基础、希望理解HTTP协议与网络编程的开发者,适合作为课程设计、练手项目或面试知识梳理的参考。压缩包为7z格式,整体约17.85MB,内含webserver_f.c等核心源码文件与若干html示例页面,源码覆盖socket创建、bind监听、accept连接、recv读取请求、HTTP请求解析与响应生成、fopen/fread文件读取及send回传等关键环节,html文件则用于本地测试静态资源访问效果。已有241人学习关注,说明该主题在入门网络编程群体中具备一定参考价值。读者可借此理清从TCP套接字到HTTP响应的完整链路,掌握请求行与头部的解析思路、状态行与Content-Type等响应头的构造方式,并了解多线程或异步I/O处理并发请求的改进方向,为后续深入网络编程打下基础。

1. 从零手写一个 C 语言 webserver:为什么我劝你先别急着上框架

很多做嵌入式的朋友第一次接触网络编程,都是从“能不能自己写个 webserver”开始的。市面上的 Nginx、Lighttpd 确实成熟,但如果你连一次epoll的边沿触发和水平触发都没亲手调过,看再多源码也只是隔靴搔痒。这个项目就是一份用纯 C 实现的轻量级 webserver,不依赖任何第三方网络库,核心就是 socket、epoll、线程池和 HTTP 协议解析。它适合两类人:一是刚学完《UNIX 网络编程》想找个完整项目练手的在校生,二是需要在嵌入式设备上跑一个极简 HTTP 服务、又不想引入庞大依赖的开发者。整个项目编译出来只有一个可执行文件,静态资源、动态请求、长连接都能处理,代码量控制在两千行以内,读起来不费劲。

2. 架构拆解:从 main 到 epoll 事件循环的完整链路

2.1 整体模块划分与数据流向

这个 webserver 的架构并不复杂,但麻雀虽小五脏俱全。启动后,主线程负责监听端口、接受新连接,然后把连接分发给工作线程去处理读写和业务逻辑。模块上大致分为四层:最底层是网络层,封装了 socket 的创建、绑定、监听以及epoll的增删改查;往上是连接管理层,用http_conn结构体表示一个客户端连接,维护读缓冲区、写缓冲区、解析状态机;再往上是 HTTP 协议层,负责解析请求行、请求头,生成响应报文;最上层是业务逻辑层,根据 URL 决定是返回静态文件还是执行 CGI 或 FastCGI 请求。

数据流向是这样的:epoll_wait返回就绪事件后,主线程判断是监听套接字还是连接套接字。如果是新连接,就accept出来,把新 fd 注册到 epoll 中,并初始化对应的http_conn对象。如果是已连接套接字可读,就把数据读到读缓冲区,然后交给线程池中的工作线程去解析和处理。工作线程处理完后,把响应写入写缓冲区,再注册EPOLLOUT事件,等可写时把数据发出去。整个过程是异步非阻塞的,一个线程可以同时处理多个连接。

2.2 核心结构体与关键参数

先看连接结构体,它承载了一个客户端从连接到断开的全部状态:

// http_conn.h 中定义的核心结构 #define READ_BUFFER_SIZE 2048 #define WRITE_BUFFER_SIZE 1024 struct http_conn { int m_sockfd; // 客户端套接字 sockaddr_in m_address; // 客户端地址 char m_read_buf[READ_BUFFER_SIZE]; // 读缓冲区 int m_read_idx; // 读缓冲区中已读入的字节数 int m_checked_idx; // 当前正在解析的位置 int m_start_line; // 当前解析行的起始位置 char m_write_buf[WRITE_BUFFER_SIZE]; // 写缓冲区 int m_write_idx; // 写缓冲区中待发送字节数 METHOD m_method; // 请求方法:GET 或 POST char m_url[256]; // 请求的 URL char m_version[16]; // HTTP 版本 char m_host[256]; // 主机名 bool m_linger; // 是否保持长连接 int m_content_length; // 请求体长度 bool m_iv_count; // 是否使用 writev 分散写 struct iovec m_iv[2]; // 用于 writev 的 iovec 数组 int m_state; // 当前状态机状态 };

这里有几个参数值得注意。READ_BUFFER_SIZE设为 2048 是权衡后的结果:太小会导致一次请求读不完,需要多次epoll_wait,太大则浪费内存,尤其在高并发下每个连接都占一块。m_checked_idx和m_start_line配合使用,用来逐行解析 HTTP 请求头,避免每次解析都从头扫描。m_iv数组配合writev可以把响应头和响应体一次性发出,减少系统调用次数,这是提升吞吐量的常见做法。

2.3 epoll 事件注册与线程池协作

主线程的事件循环是整个服务器的引擎,代码不长但逻辑密度高:

// main.c 中的事件循环片段 while (!stop_server) { int num = epoll_wait(m_epoll_fd, events, MAX_EVENT_NUMBER, -1); if (num < 0 && errno != EINTR) { break; } for (int i = 0; i < num; i++) { int sockfd = events[i].data.fd; if (sockfd == m_listen_fd) { // 新连接到达 struct sockaddr_in client_address; socklen_t client_addrlength = sizeof(client_address); int connfd = accept(m_listen_fd, (struct sockaddr*)&client_address, &client_addrlength); if (connfd < 0) { continue; } // 初始化连接对象并注册到 epoll http_conn* conn = new http_conn(); conn->init(connfd, client_address); m_users[connfd] = conn; addfd(m_epoll_fd, connfd, true, false); // 开启 ET 模式 } else if (events[i].events & EPOLLIN) { // 可读事件,交给线程池 threadpool_append(&m_pool, events[i].data.ptr); } else if (events[i].events & EPOLLOUT) { // 可写事件,直接发送 http_conn* conn = (http_conn*)events[i].data.ptr; conn->write(); } } }

这段代码里,epoll_wait的超时设为 -1 表示永久阻塞,直到有事件发生。MAX_EVENT_NUMBER一般设为 1024 或更大,取决于预期并发量。新连接进来后,addfd函数会把 fd 设为非阻塞,并且注册EPOLLIN | EPOLLET,也就是边沿触发模式。边沿触发要求一次把数据读干净,所以http_conn::read里必须循环recv直到返回EAGAIN或EWOULDBLOCK,否则会丢事件。线程池那边,threadpool_append把任务放进工作队列,唤醒一个空闲线程去执行http_conn::process。

注意:边沿触发模式下,如果读缓冲区满了还没读完,剩余数据会留在内核缓冲区,但 epoll 不会再通知你,除非对端又发了新数据。所以读缓冲区要足够大,或者用m_checked_idx做流式解析,边读边处理。

3. 编译与运行:从源码到可执行文件的每一步

3.1 环境准备与依赖检查

这个项目只依赖 Linux 系统调用和 pthread 库,不需要额外安装第三方包。编译前确认几件事:gcc 或 clang 已安装,make 工具可用,系统头文件里有sys/epoll.h。在 Ubuntu 或 CentOS 上,通常只需要build-essential包。可以用下面命令快速检查:

# 检查编译器版本 gcc --version # 检查 epoll 头文件是否存在 ls /usr/include/sys/epoll.h # 检查 pthread 库 ldconfig -p | grep pthread

如果epoll.h不存在,说明系统太老或者不是 Linux 环境,这个项目就跑不起来。另外,项目里用到了writev和mmap,这些在标准 Linux 上都有,不需要额外配置。

3.2 Makefile 解析与编译参数

项目根目录下有一个 Makefile,内容不长但把编译规则写得很清楚:

CXX = g++ CFLAGS = -g -Wall -O2 -pthread TARGET = server OBJS = main.o http_conn.o threadpool.o $(TARGET): $(OBJS) $(CXX) $(CFLAGS) -o $(TARGET) $(OBJS) main.o: main.c http_conn.h $(CXX) $(CFLAGS) -c main.c http_conn.o: http_conn.c http_conn.h $(CXX) $(CFLAGS) -c http_conn.c threadpool.o: threadpool.c threadpool.h $(CXX) $(CFLAGS) -c threadpool.c clean: rm -f $(OBJS) $(TARGET)

-g保留调试符号,方便用 gdb 排查问题;-Wall打开所有警告,写 C 代码时这个很重要,能提前发现类型不匹配、未使用变量等问题;-O2是常用的优化级别,既不会像-O3那样激进导致调试困难,又能保证性能;-pthread链接 pthread 库,线程池和互斥锁都依赖它。编译时直接make就行,生成的可执行文件叫server。

3.3 启动参数与运行验证

编译完成后,运行方式很简单:

# 默认监听 9006 端口,可以在 main.c 里改 ./server 9006

启动后终端不会有太多输出,这是正常的,因为日志都写到了文件或者只打印错误信息。验证服务是否正常,可以用 curl 或者浏览器:

# 请求根路径,应该返回 index.html curl -v http://127.0.0.1:9006/ # 请求一个不存在的路径,应该返回 404 curl -v http://127.0.0.1:9006/notfound.html # 测试长连接,连续发两个请求 curl -v http://127.0.0.1:9006/ http://127.0.0.1:9006/test.html

如果返回 200 并且内容正确,说明静态文件服务没问题。再测试一下 POST 请求,可以用curl -d发数据,看服务器是否能正确读取Content-Length并解析请求体。如果一切正常,这个 webserver 就算跑起来了。

提示:如果启动时报 “bind: Address already in use”,说明端口被占用了,换一个端口或者用lsof -i:9006找到占用进程杀掉。

4. 避坑与排查:那些年我踩过的 epoll 和 HTTP 解析的坑

4.1 边沿触发下数据读不干净导致连接卡死

现象:客户端发了一个较大的 POST 请求,服务器只收到一部分,剩下的数据迟迟不到,连接一直挂着直到超时。

原因:epoll用的是EPOLLET边沿触发,http_conn::read里只调用了一次recv,没有循环读到EAGAIN。内核缓冲区里还有数据,但 epoll 不会再通知,因为边沿触发只在状态变化时触发一次。

解决:把read函数改成循环读,直到recv返回 -1 且errno == EAGAIN或EWOULDBLOCK。同时要处理读缓冲区满的情况,如果缓冲区满了还没读完,需要动态扩容或者把剩余数据暂存到临时缓冲区,等下一轮继续读。

4.2 HTTP 请求头解析越界导致段错误

现象:用ab压测时,服务器偶尔崩溃,gdb 显示段错误发生在parse_request_line函数里。

原因:解析请求行时,直接用strchr找空格,但没有检查字符串是否以\0结尾。如果读缓冲区里的数据不是完整的请求行,strchr会一直往后找,越过缓冲区边界,访问到非法内存。

解决:解析前先确保读缓冲区里有完整的行,也就是找到\r\n。如果没找到,就返回LINE_OPEN状态,等下次数据到了再解析。另外,所有字符串操作都要带上长度参数,或者手动在缓冲区末尾补\0,但要注意不要覆盖有效数据。

4.3 线程池任务队列满导致连接被丢弃

现象:高并发下,部分客户端连接被 accept 了,但一直没有响应,最后超时断开。

原因:线程池的任务队列有最大长度限制,当队列满了之后,threadpool_append直接返回失败,但主线程没有处理这个失败,连接对象既没被处理也没被关闭,成了僵尸连接。

解决:在threadpool_append返回失败时,主线程应该直接关闭这个连接,或者给客户端返回 503。更好的做法是动态调整线程数量,或者用更高效的任务分发机制,比如每个线程自己维护一个队列,减少锁竞争。

4.4 长连接超时时间设置不当导致资源泄漏

现象:服务器运行一段时间后,内存占用越来越高,lsof显示大量 CLOSE_WAIT 状态的连接。

原因:HTTP/1.1 默认是长连接,但服务器没有设置超时时间,客户端断开后,服务器端没有及时关闭套接字,导致 fd 和内存泄漏。

解决:给每个连接设置一个最后活跃时间,用一个定时器或者epoll_wait的超时参数定期检查,超过一定时间(比如 60 秒)没有数据交互就主动关闭连接。同时,在http_conn的析构函数里确保关闭 fd 并释放资源。

注意:CLOSE_WAIT 状态表示被动关闭方没有调用close,通常是代码里漏了关闭操作。用netstat -natp | grep CLOSE_WAIT可以快速定位。

5. 二次开发:从能跑到好用,我改过的三个地方

5.1 把静态文件服务换成 FastCGI 转发

原项目只支持静态文件,但实际场景里往往需要跑 PHP 或者 Python 脚本。我做的第一个改造是接入 FastCGI。思路是:当 URL 以.php结尾时,不直接读文件,而是把请求转发给本地的 PHP-FPM。核心是构造 FastCGI 协议包,通过 Unix Socket 发给 FPM,然后解析返回的响应。

// 简化的 FastCGI 请求构造 void build_fcgi_request(http_conn* conn, int fcgi_fd) { FCGI_BeginRequestBody body; body.role = htons(FCGI_RESPONDER); body.flags = 0; // 发送 BEGIN_REQUEST 记录 send_fcgi_record(fcgi_fd, FCGI_BEGIN_REQUEST, 1, &body, sizeof(body)); // 发送 PARAMS 记录,包含 REQUEST_METHOD、SCRIPT_FILENAME 等 send_fcgi_params(fcgi_fd, conn); // 发送 STDIN 记录,POST 请求体放在这里 send_fcgi_stdin(fcgi_fd, conn); }

改造后,服务器既能返回静态 HTML,也能执行动态脚本,适用面广了很多。需要注意的是,FastCGI 的响应可能分多个记录返回,要循环读取并拼接,直到收到FCGI_END_REQUEST。

5.2 增加简单的访问日志和错误日志

原项目几乎没有日志,出了问题只能靠 gdb。我加了一个日志模块,用宏封装fprintf,支持日志级别和按天切割。关键代码如下:

#define LOG_DEBUG(format, ...) log_write(LOG_LEVEL_DEBUG, __FILE__, __LINE__, format, ##__VA_ARGS__) #define LOG_ERROR(format, ...) log_write(LOG_LEVEL_ERROR, __FILE__, __LINE__, format, ##__VA_ARGS__) void log_write(int level, const char* file, int line, const char* format, ...) { // 获取当前时间,格式化到缓冲区 // 根据级别决定是否写入文件 // 用互斥锁保证多线程安全 }

日志加上后,排查问题快了很多。比如某个请求返回 400,直接看日志就知道是请求行解析失败还是请求头缺了 Host。建议日志文件按天切割,避免单个文件过大。

5.3 用定时器优化长连接的超时管理

原项目用alarm或者epoll_wait的超时来检查连接,精度不高。我改成了基于最小堆的定时器,每个连接对应一个定时器节点,超时时间到了就触发回调关闭连接。这样既能精确控制超时,又能高效管理大量连接。

// 定时器节点结构 struct timer_node { int fd; time_t expire; // 绝对过期时间 void (*callback)(int fd); // 堆相关字段 }; // 检查并处理过期定时器 void tick() { time_t now = time(NULL); while (!heap_empty() && heap_top()->expire <= now) { timer_node* node = heap_top(); node->callback(node->fd); heap_pop(); } }

这个改造让长连接的管理变得清晰,也避免了轮询所有连接带来的性能损耗。后来我又把定时器和 epoll 的timeout参数结合,epoll_wait的超时设为堆顶定时器的剩余时间,这样既不会空转,又能及时处理超时。

从那以后我每次写网络服务,都会先把边沿触发的读写循环和超时管理这两块代码反复检查一遍,确认没有漏读、漏关的情况才继续往下写。希望这些经验能帮到你,少走一些弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进