
简介这份7z压缩包围绕“用C语言实现一个简易Web服务器”展开面向具备C语言与操作系统基础、希望打通网络编程与HTTP协议实践链路的学习者适合课程设计、网络编程入门和底层原理复盘。内容主线覆盖套接字创建、绑定监听、接受连接、读取并解析请求行与头部再到构造状态行、响应头和实体主体并通过文件I/O读取HTML页面返回客户端同时涉及错误处理以及多线程或异步I/O的并发优化思路。压缩包约17.85MB文件总数与类型明细上游暂未提供暂无法据此判断内部构成。已有241人学习可借助其源码脉络理解从socket到HTTP响应的完整闭环并对照请求解析、响应生成与并发处理中的常见问题形成排错思路。1. 从一次接口超时说起这个 C 版 WebServer 值不值得你花一个周末某次排查一个接口的超时抖动抓包看了半天最后发现是服务端在write返回EAGAIN之后没有把剩余数据补发出去。那一刻我才真正意识到平时写业务代码时被框架挡掉的那些细节恰恰是网络服务最容易翻车的地方。这也是我建议每个后端都动手拆一遍 webserver 的原因用 C 语言从 socket 开始写一个能跑静态页面、支持 keep-alive、能扛住几千并发连接的 HTTP 服务器你对「一个请求到底经历了什么」的理解会发生质变。这份源码就是干这件事的主干是 epoll 事件循环 固定线程池 HTTP 报文状态机解析体积不大但链路完整。适合刚学完计算机网络想找个练手项目的人也适合写了几年 CRUD、想补一补底层的那批人。2. 分层与选型Reactor 主干怎么搭模块边界怎么切一个 C 语言 webserver 项目最容易出现的问题不是写不出来而是写着写着main.c变成两千行epoll、协议解析、线程调度全糊在一起。这份源码在结构上是克制的它把「事件怎么来」「报文怎么解」「业务怎么跑」三件事拆成了独立模块理解这个拆法后面改代码才不会失控。2.1 目录结构六个目录把 IO、协议、并发拆开拿到源码先别急着看main.c先看目录。这个项目的组织方式基本是教科书式的分层每个文件只干一件事webserver/ ├── include/ # 全部对外声明方便其他模块引用 │ ├── http_conn.h # 连接对象结构体 解析器 响应生成 │ ├── epoll_loop.h # 事件循环与注册接口 │ ├── threadpool.h # 线程池与任务队列 │ ├── timer.h # 定时器最小堆 │ └── log.h # 异步日志 ├── src/ │ ├── main.c # 命令行参数、初始化、启动循环 │ ├── http_conn.c # HTTP 状态机解析 响应拼装 │ ├── epoll_loop.c # epoll_create / ctl / wait 的封装 │ ├── threadpool.c # 固定线程数 环形任务队列 │ ├── timer.c # 最小堆定时器踢空闲连接 │ └── log.c # 双缓冲异步日志 ├── www/ # 静态资源根目录响应时按 URI 映射到这里 ├── tests/ # 简单脚本压测和功能自测 ├── Makefile └── config.ini # 端口、线程数、超时时间等include和src分离不是形式主义。C 项目里最容易出现的耦合是「A.c 里 include 了 B.c」一旦这样写想单独把解析器拿去做单元测试就没戏了。这个项目所有跨模块引用都走头文件http_conn.c完全不知道线程池的存在threadpool.c也不知道 HTTP 协议长什么样两边只通过一个函数指针任务结构体通信。还有一个细节值得学www/被单独放在根目录而不是塞进src/。因为静态资源根路径是运行时配置项通过config.ini里的root_dir指定压测时换一份资源不用重新编译。这种「可变的东西不写死在代码里」的习惯后面做二次开发会省很多事。2.2 主从 Reactor主线程只管 accept工作线程管读写这个项目采用的是主从 Reactor 模型。主线程持有listen_fd和它自己的 epoll 实例只负责一件事accept新连接然后把新连接的fd分配给某个工作线程的 epoll。工作线程各自持有一个 epoll 实例负责已建立连接的读写事件、协议解析和响应。为什么不让所有线程共享一个 epoll因为epoll_wait返回的事件列表是所有线程共享的多个线程同时对同一个 fd 做读写会引入惊群和锁竞争处理不好就是「连接数一上去 CPU 就 100% 但 QPS 反降」。主从模型把 accept 和 IO 彻底分开代价是需要自己做 fd 的归属分配。分配策略常见的有两种轮询和按负载。这个项目用的是最简单的轮询next_thread (next_thread 1) % pool_size。轮询在连接耗时均匀时表现很好如果业务里有明显长短请求混合就会出现某个线程排队、其他线程空闲的情况。我一般会在这个位置埋一个计数器记录每个线程当前挂载的连接数分配时选最小的那个改动量只有十几行但长尾延迟能明显改善。主线程的循环长这样逻辑很薄但每行都不能省// 主线程只处理 listen_fd 上的可读事件也就是新连接到达 while (!stop) { int n epoll_wait(listen_epollfd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; // 被信号打断重来 log_error(epoll_wait: %s, strerror(errno)); break; } for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { struct sockaddr_in cli; socklen_t len sizeof(cli); int conn_fd accept(listen_fd, (struct sockaddr *)cli, len); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; continue; // 单个 accept 失败不影响整体 } set_nonblocking(conn_fd); // 必须非阻塞否则后面全乱 int idx pick_worker(); // 轮询挑一个工作线程 add_to_worker(idx, conn_fd); // 跨线程投递内部走 eventfd 唤醒 } } }几个参数和写法需要说明。set_nonblocking是硬性要求监听 fd 和连接 fd 都必须设成非阻塞否则一个慢客户端就能把整个线程卡在read上。EPOLLIN如果监听 fd 是边缘触发模式accept必须循环调用直到返回EAGAIN上面这版为了可读性做了简化实际代码里accept是包在一个while里的。跨线程投递不能直接对别人的 epoll 调epoll_ctl标准做法是每个工作线程开一个eventfd主线程往里面写 8 个字节工作线程的epoll_wait被唤醒后再从任务队列里取 fd。2.3 编译与启动make 之后到底发生了什么项目用 Makefile 管理没有引入 CMake这在教学项目里是合理的因为依赖太简单了——只有 pthread。构建的目标文件分三类main.o、协议与事件相关的.o、以及工具类.o最后链接成server。CC gcc CFLAGS -Wall -Wextra -O2 -g -I./include -pthread LDFLAGS -pthread TARGET server SRCS $(wildcard src/*.c) OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(OBJS) -o $ $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET)-pthread出现在编译和链接两处很多人只写链接那处结果在某些平台上pthread_create行为异常。-O2是必须的-O0下解析器那种逐字节循环会慢一个数量级压测数据完全没参考价值。-g建议保留后面用 gdb 或 perf 排查时符号表就是救命的东西。跑起来分三步先make然后确认www/下有index.html最后./server -p 8080 -t 8。-t是工作线程数一般设成 CPU 核数设太大反而因为上下文切换掉性能。启动后用curl -v http://127.0.0.1:8080/index.html验证看响应头里有没有Content-Length和Connection: keep-alive这两个字段对不上基本就是解析器或响应生成有 bug。提示如果make报找不到头文件检查CFLAGS里的-I./include是否写对从别的目录执行 make 时相对路径会失效改成绝对路径或者先 cd 进去。3. 核心链路拆解从 accept 到 write 的一次完整请求架构看清之后就该顺着一个请求走一遍了。这条链路上的每一段都有讲究监听 socket 怎么建、事件怎么注册、报文怎么解、响应怎么发。四段代码读懂这个项目就通了百分之八十。3.1 监听 socket端口复用和 backlog 这两个参数别乱填建监听 socket 的代码只有二十来行但里面两个参数的选择直接决定了服务能不能正常重启、能不能扛住瞬时连接洪峰。int socket_bind_listen(int port) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) return -1; // 关键点一允许端口复用解决重启时的 TIME_WAIT 占用 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 htonl(INADDR_ANY); addr.sin_port htons(port); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { close(listen_fd); return -1; } // 关键点二backlog 是已完成三次握手但还没被 accept 的队列长度 if (listen(listen_fd, SOMAXCONN) 0) { close(listen_fd); return -1; } return listen_fd; }SO_REUSEADDR的作用不是「让端口能重复绑定」而是允许绑定处于TIME_WAIT状态的地址。不加这一行你改完代码重启服务就会撞上Address already in use然后要等两分钟这是血泪经验级别的坑。SOMAXCONN是系统定义的最大值在 Linux 上通常由net.core.somaxconn决定默认可能只有 128压测前建议sysctl -w net.core.somaxconn1024否则高并发下会看到SYN被丢弃、客户端侧表现为连接超时。bind之前记得把sockaddr_in清零这个结构体有填充字节不清零在某些架构上会出现bind返回成功但端口不对的玄学现象。htons、htonl也不能漏网络字节序弄反了端口号会变成一个完全无关的数字。3.2 epoll 注册与分发边缘触发下必须一次把数据读干净这个项目用的是EPOLLET边缘触发。边缘触发的语义是「状态变化时才通知一次」如果你收到通知后没有把缓冲区里的数据读完剩下的数据不会再触发新的通知连接就这么僵死了。这是新手最容易踩的坑也是我认为必须先讲清楚的一点ET 模式下读和写都必须循环到EAGAIN。void handle_read(int epollfd, http_conn_t *conn) { char buf[BUFFER_SIZE]; while (1) { ssize_t n read(conn-fd, buf, sizeof(buf)); if (n 0) { // 不假设一次读完整个报文喂给状态机返回 1 表示报文完整 if (http_parse(conn, buf, (size_t)n) PARSE_DONE) { mod_to_write(epollfd, conn); // 解析完成转为关注可写 return; } } else if (n 0) { // 对端关闭优雅关闭场景下这里经常先于 EPOLLRDHUP 出现 close_conn(epollfd, conn); return; } else { if (errno EAGAIN || errno EWOULDBLOCK) return; // 读干净了正常退出 if (errno EINTR) continue; // 被信号打断 close_conn(epollfd, conn); return; } } }BUFFER_SIZE一般取 4096 或 8192太小会导致一次请求读很多轮太大单个连接的栈占用上去了。EAGAIN是正常出口不是错误——这一点极其重要很多实现把它当异常处理结果日志里全是噪音。EINTR必须continue信号处理函数会打断read不处理的话偶发连接被误关这种 bug 在低并发下根本复现不出来。注册事件时还有一个容易漏的选项EPOLLRDHUP。它让内核在对端半关闭时立刻通知你不用等到下一次read返回 0。对于 keep-alive 的长连接场景这个选项能让空闲连接的回收更及时。注册代码大致是ev.events EPOLLIN | EPOLLET | EPOLLRDHUP; ev.data.ptr conn;注意data存的是连接对象指针而不是 fd这样事件回调里不用再做一次哈希查找是一个很实用的优化。3.3 HTTP 解析用有限状态机逐字节吃掉报文HTTP 报文有个特点TCP 是流你不保证一次read能拿到完整的请求头。一个请求可能分三段到达也可能两个请求粘在一起。所以解析器不能写成「读一次然后sscanf」必须是一个能吃任意切片的增量状态机。项目里的状态机分四个状态请求行、头部、正文、完成。每个字节进来推进一步遇到\r\n就切分。typedef enum { S_REQ_LINE, S_REQ_LINE_LF, // 请求行 等待 \n S_HEADER, S_HEADER_LF, // 头部行 等待 \n S_BODY, S_DONE } parse_state_t; // 逐字节推进返回 PARSE_DONE 表示一个完整请求已经就绪 int http_parse(http_conn_t *c, const char *data, size_t len) { for (size_t i 0; i len; i) { char ch data[i]; switch (c-state) { case S_REQ_LINE: if (ch \r) { c-state S_REQ_LINE_LF; } else if (c-line_len MAX_LINE - 1) { c-line[c-line_len] ch; } else { return PARSE_TOO_LONG; } // 防御超长请求行 break; case S_REQ_LINE_LF: if (ch ! \n) return PARSE_BAD; c-line[c-line_len] \0; if (parse_request_line(c, c-line) ! 0) return PARSE_BAD; c-line_len 0; c-state S_HEADER; break; case S_HEADER: if (ch \r) { c-state S_HEADER_LF; } else if (ch \n) { // 空行头部结束 c-state (c-content_length 0) ? S_BODY : S_DONE; if (c-state S_DONE) return PARSE_DONE; } else if (c-line_len MAX_LINE - 1) { c-line[c-line_len] ch; } break; case S_HEADER_LF: if (ch ! \n) return PARSE_BAD; c-line[c-line_len] \0; parse_header_line(c, c-line); // 解析 Content-Length、Connection 等 c-line_len 0; c-state S_HEADER; break; case S_BODY: // 正文长度由 Content-Length 决定够数就完成 if (c-body_read c-content_length) return PARSE_DONE; break; default: break; } } return PARSE_NEED_MORE; // 数据不够等下一次 read }MAX_LINE是单行长度上限一般 8192。加这个限制不是小心过度而是必须的不加的话一个恶意构造的超长请求行会把连接对象的内存撑爆。parse_header_line里要处理的字段至少有Content-Length、Connection、Host、Content-Type其中Content-Length要用strtol而不是atoiatoi溢出行为未定义遇到畸形输入直接翻车。请求行解析要校验方法名。这个项目只支持GET和POST遇到PUT、DELETE应该返回501 Not Implemented而不是继续往下走。URI 里还要做路径穿越检查../必须拦掉否则GET /../../etc/passwd就能读到任意文件这是所有静态文件服务器第一个要堵的洞。3.4 响应生成writev 聚包和 sendfile 零拷贝响应分两类小响应HTML、JSON直接拼在用户态缓冲区里大文件走sendfile或者mmapwritev。项目里两种都实现了用一个阈值切换常见做法是 64KB 以下直接拷贝以上走零拷贝。int send_response(http_conn_t *c) { struct iovec iov[2]; int iovcnt 0; if (c-file_size 0 c-file_size ZERO_COPY_THRESHOLD) { // 大文件头部和文件内容一起发避免两次系统调用 iov[0].iov_base c-header_buf; iov[0].iov_len c-header_len; iovcnt 1; size_t sent 0; while (sent c-file_size) { ssize_t n sendfile(c-fd, c-file_fd, (off_t *)sent, c-file_size - sent); if (n 0) { if (errno EAGAIN) return RESP_AGAIN; // 内核缓冲满等下次可写事件 if (errno EINTR) continue; return RESP_ERROR; } sent n; } } else { // 小响应分散写一次 syscall 把头部和正文都发出去 iov[0].iov_base c-header_buf; iov[0].iov_len c-header_len; iov[1].iov_base c-body_buf; iov[1].iov_len c-body_len; iovcnt 2; ssize_t n writev(c-fd, iov, iovcnt); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) return RESP_AGAIN; return RESP_ERROR; } // 关键处理部分写把没发完的部分挪到缓冲区头部 size_t total c-header_len c-body_len; if ((size_t)n total) { memmove(c-header_buf, c-header_buf n, total - n); c-pending_offset total - n; return RESP_AGAIN; } } return RESP_DONE; }writev的价值在于把头部和正文合并成一次系统调用减少上下文切换。sendfile走的是内核态直接发送数据不用拷到用户态。ZERO_COPY_THRESHOLD我一般设 64KB低于这个值构建iovec和调sendfile的开销反而不划算具体数值要靠压测定。最容易翻车的点是部分写。writev返回的字节数可能小于总长度这是完全合法的。上面这段代码用memmove把剩余部分挪回缓冲区头部下次可写事件来了接着发。如果偷懒不处理表现为「小文件正常、大文件下载到一半就断」或者客户端收到截断的 HTML。sendfile那段的sent变量必须传地址它是「输入输出参数」内核会更新它很多实现把它当局部变量每轮重置结果是文件从头反复发。响应头里Content-Length一定要准确中文页面用strlen算长度会少算正确做法是拿到文件stat的st_size或者缓冲区实际字节数。Connection: keep-alive要跟config.ini里的keepalive_timeout对上声明了长连接却不维护定时器客户端复用到第二个请求就会发现连接已经被关了。4. 工程化细节定时器、线程池与压测调优能跑通和能用之间隔着一堆工程细节。这个项目在这块做得比较完整三个东西值得单独拿出来讲踢空闲连接的定时器、接任务的线程池、以及怎么用压测数据反推瓶颈。4.1 最小堆定时器把空闲连接按到期时间排序长连接不踢掉连接数只涨不跌几千个客户端挂上来什么都不干内存就被吃光了。项目里用最小堆维护所有连接的超时时间堆顶是最近要过期的那个主循环里每次epoll_wait返回后检查一下堆顶超时就关掉。// 最小堆上浮新节点插入到末尾后往上比比父节点小就交换 static void heap_sift_up(timer_heap_t *h, int idx) { while (idx 0) { int parent (idx - 1) 1; if (h-heap[parent]-expire h-heap[idx]-expire) break; swap(h-heap[parent], h-heap[idx]); idx parent; } } // 每次处理完 IO 后检查过期一次可以踢掉多个 void timer_tick(timer_heap_t *h, time_t now) { while (h-size 0 h-heap[0]-expire now) { timer_node_t *node h-heap[0]; heap_pop(h); // 主动关闭前先从 epoll 摘掉避免关闭后再收到事件 epoll_ctl(epollfd, EPOLL_CTL_DEL, node-conn_fd, NULL); close(node-conn_fd); free_conn(node-conn_fd); } }expire存的是绝对时间戳不是相对秒数每次收到数据就expire now timeout然后对被修改的节点做一次下沉或上浮。最小堆的插入和删除都是O(log n)比遍历链表快得多。项目里timeout默认 15 秒可以在config.ini里改。常见做法是用时间轮性能更好但实现复杂度上一个台阶。连接数在几万这个量级最小堆完全够用不要过度设计。要注意的是「调整超时时间」这个动作必须同时更新堆结构只改字段不动位置堆序就被破坏了表现出来就是某些连接永远不超时。epoll_ctl删除要在close之前顺序反了会遇到「fd 已关闭但 epoll 里还有记录」的情况close之后 fd 号可能被新连接复用导致事件投递到错误的对象上这种 bug 排查起来能让人怀疑人生。4.2 线程池任务队列和事件循环怎么配合线程池是固定的 N 个 worker 加一个环形队列。主线程accept到新连接后把「这个 fd 该做什么」封装成任务投递进去worker 取出来执行。void threadpool_add(threadpool_t *pool, void (*fn)(void *), void *arg) { pthread_mutex_lock(pool-lock); while (((pool-tail 1) % pool-cap) pool-head) { // 队列满可以阻塞等待也可以直接拒绝新任务 pthread_cond_wait(pool-not_full, pool-lock); } pool-tasks[pool-tail].fn fn; pool-tasks[pool-tail].arg arg; pool-tail (pool-tail 1) % pool-cap; pthread_cond_signal(pool-not_empty); pthread_mutex_unlock(pool-lock); } void *worker_routine(void *arg) { threadpool_t *pool arg; while (1) { pthread_mutex_lock(pool-lock); while (pool-head pool-tail !pool-shutdown) { pthread_cond_wait(pool-not_empty, pool-lock); } if (pool-shutdown) { pthread_mutex_unlock(pool-lock); break; } task_t task pool-tasks[pool-head]; pool-head (pool-head 1) % pool-cap; pthread_cond_signal(pool-not_full); pthread_mutex_unlock(pool-lock); task.fn(task.arg); // 锁外执行避免长任务阻塞其他 worker } return NULL; }队列容量cap要设成 2 的幂取模可以改成位运算虽然编译器一般会优化。任务必须在锁外执行这一点写反了就是灾难——一个耗时任务持着锁其他 worker 全部堵在pthread_mutex_lock上整个线程池退化成单线程。pthread_cond_wait必须放在while里不是if因为存在虚假唤醒。还有一个设计选择如果线程池主要用来处理 IO其实没必要用线程池。这个项目的做法是「每个 worker 线程自己跑一个 epoll」线程池只用于少量耗时任务比如访问日志落盘、gzip 压缩。这两种模式别混着理解一个 fd 只能归一个线程的 epoll 管跨线程操作同一个 fd 是最典型的并发 bug 来源。4.3 压测数据怎么看从 QPS 和延迟反推瓶颈压测工具用wrk就行wrk -t4 -c1000 -d30s http://127.0.0.1:8080/index.html。看三个数QPS、P99 延迟、CPU 占用。三个数连起来看才有意义。现象大概率瓶颈排查方向QPS 上不去CPU 单核跑满事件循环是单线程检查 accept 和读写是否在同一线程QPS 正常但 P99 抖动大部分写没处理 / 定时器扫描太重strace 看 write 调用次数和耗时连接数一上去就掉backlog 太小 / 文件描述符上限ulimit -n、sysctl net.core.somaxconn内存持续增长连接对象没释放 / 定时器节点泄漏valgrind massif 看分配点ulimit -n是第一个要确认的默认 1024压到 1000 并发就开始出现Too many open files报错在日志里可能被刷过去表现为「偶发连接建立失败」。临时改ulimit -n 65535要持久化就改/etc/security/limits.conf。strace -c -p pid能看到系统调用的分布。如果write调用次数远大于请求数说明响应被拆成了很多次小写问题在缓冲策略上如果epoll_wait占了大头说明大部分时间在空转等待。perf top看热点函数-O2下如果memcpy排第一那基本就是零拷贝阈值设错了。注意压测前先把日志级别调成 warn异步日志的写盘开销在几千 QPS 下会明显扭曲数据。这不是理论是我第一次压测时盯着 3k QPS 查了半天最后发现日志线程占了 40% CPU。5. 避坑与排查跑起来但不工作的五个瞬间代码能编译、服务能启动不代表它是对的。下面这几条都是我在拆这类项目时真实撞过的现象、原因、解决按顺序写遇到了可以直接对号入座。第一条客户端连上就断服务端 CPU 打满。现象是curl立刻返回Empty reply from server同时top看到一个核跑在 100%。原因八成是边缘触发下read没读干净EPOLLIN事件反复触发循环空转。判断方法是strace看到read一直返回-1 EAGAIN但 epoll 还在不停通知。解决就是把读操作包在while里直到EAGAIN或0才退出。写操作同理EPOLLOUT也要发到EAGAIN为止。第二条改完代码重启报Address already in use。现象很直接bind返回 -1服务起不来。原因是上一次的连接还处于TIME_WAIT端口被占用。解决是setsockopt(SO_REUSEADDR)位置要在bind之前。注意SO_REUSEPORT是另一个选项它允许多个 socket 绑同一端口做负载均衡用错了会出现「两个进程抢连接」的诡异现象新手别乱加。第三条小文件正常大文件下载到一半断掉。现象是浏览器下载进度条卡在某个百分比服务端没有报错。原因是writev或sendfile返回了部分写的字节数代码没有保存剩余偏移量下次可写事件来了又从头开始发循环往复永远发不完。解决是把未发送的数据memmove到缓冲区头部并记录pending_offset可写事件回调里从偏移处继续发完了再EPOLL_CTL_MOD摘掉写关注。这个坑的隐蔽之处在于本地测试文件小根本触发不了。第四条压测跑了十分钟内存涨了两百兆。现象是RSS单调上升不回落。原因通常是连接对象在异常路径上没释放解析失败直接return忘了free或者定时器节点被移除后没从堆里删干净悬空指针还挂着。排查用valgrind --toolmassif跑一轮短压测看分配点的调用栈。解决是给连接对象加一个统一的release_conn()出口所有失败路径都走它别在每个分支里手写closefree。第五条中文页面返回乱码或者浏览器一直转圈。现象是 HTML 里的中文显示成方块或者响应头有Content-Length但内容对不上。原因是长度用strlen算的遇到 UTF-8 多字节字符没问题但遇到二进制内容图片、字体就废了strlen会在第一个\0处停下。还有一种是响应头里写了Content-Length: N实际发的字节数不是 N客户端就一直等剩下的数据。解决是所有长度都从stat的st_size或缓冲区实际长度取绝不碰strlen。6. 二次开发把教学 demo 改成能用的服务读到这里这个项目的骨架你应该已经清楚了。接下来讲怎么改。我的建议是从小到大的顺序一次只动一个模块每次改完都跑一遍wrk和valgrind别攒着一起改。第一个改造点是路由分发。现在它只会按 URI 映射到www/下的静态文件加一层路由表就能支持动态接口。做法是在http_conn.c里加一个route_t数组元素是{method, path_prefix, handler_fn}http_parse完成后先查路由表命中就调 handler 生成响应缓冲区没命中再走静态文件逻辑。改动集中在响应生成那一段一百行以内能搞定。typedef struct { const char *method; // GET / POST const char *prefix; // /api/user int (*handler)(http_conn_t *c); // 返回 0 表示已填好响应 } route_t; static route_t routes[] { {GET, /api/ping, handle_ping}, {POST, /api/echo, handle_echo}, {NULL, NULL, NULL}, }; // 在 http_parse 返回 PARSE_DONE 之后调用 int dispatch(http_conn_t *c) { for (route_t *r routes; r-prefix; r) { if (strcmp(r-method, c-method) 0 strncmp(r-prefix, c-uri, strlen(r-prefix)) 0) { return r-handler(c); } } return serve_static(c); // 兜底走静态文件 }前缀匹配是简单粗暴的做法够用但不精细/api/user会误命中/api/username。真要做得稳一点就换成前缀 长度校验或者干脆引入一棵字典树但那属于过度设计了先跑起来再说。第二个改造点是配置热加载。config.ini现在是启动时读一次改端口、改线程数都得重启。做法是主循环里每 N 秒stat一下配置文件mtime变了就重新解析把可热更的项超时时间、日志级别、静态资源根目录更新到全局配置结构体里端口和线程数这种需要重建的资源就忽略日志里提示一下必须重启。这个功能不复杂但对调试体验提升很大。第三个改造点是日志补全请求上下文。现在日志只记错误排查问题时根本不知道是哪个请求出的错。建议在连接对象里加一个request_id可以是 fd 时间戳的组合每条日志都带上它同时记录 URI、方法、状态码、耗时。压测时把级别调到 warn出问题再调回 info 复现一次比翻strace高效得多。改造完怎么验证我的做法是三条线一起跑。功能线用curl脚本覆盖所有路由和异常路径404、405、超长 URI、路径穿越性能线用wrk跑固定并发看 QPS 波动是否在 5% 以内内存线用valgrind --leak-checkfull跑一轮短压测确认零泄漏。三条都过了才算改完缺一条都可能在上线后炸。讲个教训。我早期改这类项目特别喜欢一次动三四个模块觉得省时间结果某次改完 QPS 掉了三成perf看半天找不到原因最后发现是加路由表的时候顺手把响应缓冲区改小了writev的调用次数翻了一倍。从那以后我每次动核心链路前都先把当前的 QPS 和 P99 记在便签上改完必须对比数字对不上就不往下走。底层代码不像业务代码它不会报错给你看只会悄悄变慢。希望这些能帮到你少走几段弯路。本文还有配套的精品资源点击获取