用C手写HTTP服务器:从Tomcat架构到CGI管道通信
发布时间:2026/9/26 13:07:27来源:尧图网络
简介这是一份面向Web服务器开发初学者与C语言进阶学习者的轻量级HTTP服务器完整实现围绕HTTP协议解析、请求响应处理与并发通信展开适合用于课程设计、小型Web项目搭建及底层网络编程练习。资源包共92个文件以h头文件与hpp模板文件为主辅以cpp源文件、makefile构建脚本、html静态页面及sh启动脚本另含说明文档与附赠资料压缩包约9.67MB目录涵盖TcpSever、HttpSever、ThreadPool、CGI、Protocol等模块结构清晰便于按功能研读。目前已有58人学习下载。读者可从中获得一套模仿Tomcat模块化架构的服务器源码理解GET与POST方法处理、错误状态码反馈、CGI外部程序调用、管道通信、环境变量管理及I/O多路复用优化等关键实现并借助说明文件完成配置与二次开发是学习HTTP协议与Web服务器底层机制的实用参考。1. 从 Tomcat 的骨架里抽出最小 HTTP 服务器为什么值得用 C 重写一遍用 C 语言手写一个支持 GET/POST、带 CGI 和管道通信的轻量级 HTTP 服务器这件事的价值不在于“造轮子”而在于把 Tomcat 这类容器背后那套请求解析、线程调度、进程间通信的机制用最少的抽象层摊开在面前。你平时用 Tomcat 部署 Java Web 应用配个server.xml就能跑但请求进来之后连接怎么复用、POST 的 body 怎么按Content-Length截断、CGI 脚本的环境变量从哪来、管道两端谁读谁写这些全是黑匣子。用 C 实现一遍等于把黑匣子拆成零件摆在桌上。适合谁适合已经会写 C、用过 Linux socket、但没亲手处理过 HTTP 报文边界的人也适合想理解 Tomcat 架构但不想陷进 Java 源码的人。下面这套方案我按“能跑通、能复现、能排错”的标准拆成五章从 socket 监听一直讲到 CGI 管道和环境变量管理。2. 请求解析与路由GET/POST 方法处理的第一道关2.1 从recv到请求行HTTP 报文边界怎么切HTTP 请求在 TCP 流里没有天然边界你recv一次拿到的可能是半个请求行也可能是两个请求粘在一起。常见做法是先把数据读进一个固定缓冲区然后按\r\n\r\n找头部结束位置。请求行格式是METHOD SP URL SP VERSION CRLF用strtok或手写指针扫描都行但要注意strtok会修改原字符串如果你后面还要用原始报文做日志就得先拷贝一份。// 从缓冲区中解析请求行返回方法、URL、版本 int parse_request_line(char *buf, char **method, char **url, char **version) { char *line_end strstr(buf, \r\n); if (!line_end) return -1; // 请求行不完整 *line_end \0; *method buf; char *space1 strchr(buf, ); if (!space1) return -1; *space1 \0; *url space1 1; char *space2 strchr(*url, ); if (!space2) return -1; *space2 \0; *version space2 1; return 0; }这段代码把请求行按空格切成三段method指向GET或POSTurl指向路径version指向HTTP/1.1。参数说明buf是已读入的原始数据函数会原地修改它所以调用前要确保这块内存可写。返回值-1表示报文不完整实际服务器里应该继续recv而不是直接关连接。注意strstr找的是第一个\r\n如果客户端发的是\n结尾某些老工具会这样这里就会解析失败稳妥做法是同时兼容\n。2.2 GET 与 POST 的分流body 读取和Content-Length的坑GET 请求通常没有 body参数在 URL 的?后面POST 请求的 body 长度由Content-Length头决定。很多人第一次写服务器会直接recv一次就把整个请求当完整报文处理结果 POST 大表单时 body 被截断后端拿到的数据少了一截。正确做法是解析完头部后检查Content-Length如果剩余缓冲区里的数据不够这个长度就继续recv直到凑齐。// 读取完整 bodylen 来自 Content-Length int read_body(int client_fd, char *buf, int buf_size, int content_length) { int total 0; while (total content_length) { int n recv(client_fd, buf total, buf_size - total - 1, 0); if (n 0) return -1; // 连接断开或出错 total n; } buf[total] \0; return total; }参数说明content_length从头部解析得到buf_size要大于content_length否则会溢出。这里每次recv都从buf total开始写保证数据连续。失败时返回-1上层应该关闭连接并记录日志。注意Content-Length可能被恶意客户端设成极大值实际服务器里要设上限比如 1MB超过就返回 413。2.3 路由表设计用数组还是哈希轻量级服务器不需要 Tomcat 那种复杂的web.xml映射一个静态数组就够了。每个表项包含方法、路径前缀、处理函数指针。匹配时遍历数组先比方法再比路径。路径匹配支持前缀匹配比如/cgi-bin/开头的全部交给 CGI 处理器。typedef struct { const char *method; const char *path_prefix; void (*handler)(int client_fd, const char *url, const char *body); } route_t; route_t routes[] { {GET, /, handle_static}, {GET, /cgi-bin/, handle_cgi}, {POST, /cgi-bin/, handle_cgi}, {POST, /api/, handle_api}, };这个表在启动时初始化请求进来后线性扫描。路由数量少时线性查找比哈希更快因为哈希还要算 key、处理冲突。注意path_prefix匹配要防止/cgi-bin误匹配/cgi-bin-other所以比较时要么加/后缀要么用strncmp后检查下一个字符是不是/或\0。3. 模仿 Tomcat 架构连接调度与线程模型怎么落地3.1 Tomcat 的 Connector 和 Container 在 C 里对应什么Tomcat 的核心分两层Connector 负责 socket 通信和 HTTP 解析Container 负责业务逻辑和 Servlet 调用。在 C 服务器里Connector 就是acceptrecvparse这一串Container 就是路由表加处理函数。Tomcat 默认用 NIO 做非阻塞 IO但轻量级 C 服务器用阻塞 IO 线程池更简单也够用。我一般会开一个主线程accept然后把client_fd丢进任务队列工作线程从队列取任务处理。这样连接建立和请求处理解耦慢请求不会堵住accept。// 任务队列节点 typedef struct task { int client_fd; struct task *next; } task_t; // 工作线程入口 void *worker(void *arg) { thread_pool_t *pool (thread_pool_t *)arg; while (1) { pthread_mutex_lock(pool-lock); while (pool-head NULL !pool-shutdown) pthread_cond_wait(pool-cond, pool-lock); if (pool-shutdown) break; task_t *t pool-head; pool-head t-next; pthread_mutex_unlock(pool-lock); handle_client(t-client_fd); // 解析请求并路由 close(t-client_fd); free(t); } return NULL; }参数说明pool-lock保护队列pool-cond用于唤醒等待的工作线程。handle_client里做完整的请求解析、路由、响应写回。注意close必须在handle_client之后否则工作线程可能读到已关闭的 fd。线程数一般设为 CPU 核数的 2 倍IO 密集可以再多些但别超过 64否则上下文切换开销反而拖慢。3.2 线程池参数怎么调队列长度和拒绝策略任务队列不能无限长否则内存会被堆积的连接撑爆。我一般设队列上限 1024满了之后新连接直接返回 503 并关闭。线程数用sysconf(_SC_NPROCESSORS_ONLN)拿核数然后乘 2。如果请求处理里有阻塞 IO比如 CGI 要等脚本执行线程数可以再乘 1.5但要注意每个线程默认栈 8MB100 个线程就是 800MB 虚拟内存实际物理内存按需分配问题不大。int cpu_cores sysconf(_SC_NPROCESSORS_ONLN); int thread_count cpu_cores * 2; if (thread_count 64) thread_count 64;这段代码在初始化线程池时调用。sysconf返回可用核数容器里可能返回宿主核数所以上限 64 是保险。注意pthread_create失败时要回滚已创建的线程否则资源泄漏。3.3 响应构造状态码、头部和Content-Length的配合响应必须包含HTTP/1.1 200 OK\r\n、Content-Type、Content-Length然后空行然后 body。Content-Length必须和实际 body 字节数一致否则客户端会一直等或者提前截断。常见错误是用了strlen算 body 长度但 body 里可能有二进制数据比如图片strlen遇到\0就停了。正确做法是用write的返回值或者单独记录长度。void send_response(int fd, int status, const char *content_type, const char *body, int body_len) { char header[512]; const char *status_text (status 200) ? OK : Not Found; int hlen snprintf(header, sizeof(header), HTTP/1.1 %d %s\r\n Content-Type: %s\r\n Content-Length: %d\r\n Connection: close\r\n \r\n, status, status_text, content_type, body_len); write(fd, header, hlen); if (body body_len 0) write(fd, body, body_len); }参数说明body_len由调用者传入静态文件用stat拿文件大小CGI 用管道读到的字节数。Connection: close表示短连接省去 keep-alive 的状态管理。注意snprintf返回值可能大于sizeof(header)表示截断实际要检查并处理。4. CGI 机制与管道通信让 C 服务器跑起多语言后端4.1 CGI 的本质fork exec 管道CGI 不是什么神秘协议就是服务器fork一个子进程子进程exec脚本父进程通过管道把请求 body 写给子进程的 stdin再从子进程的 stdout 读响应。环境变量传递请求元数据比如REQUEST_METHOD、QUERY_STRING、CONTENT_LENGTH。Tomcat 里 CGI 是可选组件C 服务器里反而更自然因为fork和pipe都是系统调用。// 执行 CGI 脚本返回响应 body int run_cgi(const char *script_path, const char *method, const char *query, const char *body, int body_len, char *resp_buf, int resp_size) { int in_pipe[2], out_pipe[2]; pipe(in_pipe); // 父写子读 pipe(out_pipe); // 子写父读 pid_t pid fork(); if (pid 0) { // 子进程 dup2(in_pipe[0], STDIN_FILENO); dup2(out_pipe[1], STDOUT_FILENO); close(in_pipe[1]); close(out_pipe[0]); setenv(REQUEST_METHOD, method, 1); setenv(QUERY_STRING, query ? query : , 1); char len_str[16]; snprintf(len_str, sizeof(len_str), %d, body_len); setenv(CONTENT_LENGTH, len_str, 1); execl(script_path, script_path, NULL); exit(1); // exec 失败 } // 父进程 close(in_pipe[0]); close(out_pipe[1]); if (body body_len 0) write(in_pipe[1], body, body_len); close(in_pipe[1]); // 关闭写端子进程读到 EOF int total 0, n; while ((n read(out_pipe[0], resp_buf total, resp_size - total - 1)) 0) total n; resp_buf[total] \0; close(out_pipe[0]); waitpid(pid, NULL, 0); return total; }参数说明in_pipe父写子读out_pipe子写父读。dup2把管道两端复制到标准输入输出。setenv第三个参数1表示覆盖已有值。execl第一个参数是脚本路径脚本必须有可执行权限且首行#!/usr/bin/env python3或#!/bin/bash要正确。父进程写完 body 后必须close(in_pipe[1])否则子进程的read会一直阻塞等更多数据。waitpid回收子进程防止僵尸。4.2 环境变量管理哪些必须设哪些可选CGI 规范定义了一堆环境变量但轻量级服务器不用全设。必须有的REQUEST_METHOD、QUERY_STRING、CONTENT_LENGTH、CONTENT_TYPE、SCRIPT_NAME、SERVER_PROTOCOL。可选的有REMOTE_ADDR、HTTP_USER_AGENT等。我一般把常用头也塞进去比如HTTP_COOKIE方便脚本读。变量名来源是否必须REQUEST_METHOD请求行是QUERY_STRINGURL 中?后是CONTENT_LENGTH头部POST 必须CONTENT_TYPE头部POST 建议SCRIPT_NAME路由路径是SERVER_PROTOCOL请求行是HTTP_COOKIE头部可选设置时用setenv注意值不能有以外的特殊字符问题setenv会自己处理。如果脚本用 Pythonos.environ直接读用 Bash$REQUEST_METHOD直接取。4.3 管道通信的读写顺序死锁怎么避免管道缓冲区默认 64KB如果父进程写 body 超过 64KB 而子进程还没开始读父进程会阻塞在write同时子进程如果输出超过 64KB 而父进程没读子进程也会阻塞。两边互相等就是死锁。避免方法父进程用select或poll同时监听in_pipe[1]可写和out_pipe[0]可读边写边读。简单场景下 body 通常小于 64KB可以先写完再读但大文件上传就会翻车。// 用 poll 同时处理读写避免管道死锁 struct pollfd fds[2]; fds[0].fd in_pipe[1]; fds[0].events POLLOUT; fds[1].fd out_pipe[0]; fds[1].events POLLIN; int body_sent 0; while (body_sent body_len || !child_done) { poll(fds, 2, -1); if (fds[0].revents POLLOUT) { int n write(in_pipe[1], body body_sent, body_len - body_sent); body_sent n; if (body_sent body_len) { close(in_pipe[1]); fds[0].fd -1; } } if (fds[1].revents POLLIN) { int n read(out_pipe[0], resp_buf total, resp_size - total - 1); if (n 0) { child_done 1; fds[1].fd -1; } else total n; } }这段代码里poll阻塞直到有事件POLLOUT表示管道可写POLLIN表示可读。写完 body 后关闭写端并置fd -1poll会忽略负 fd。注意resp_buf要够大或者边读边写回客户端避免内存爆掉。5. 避坑与排查错误处理机制里最容易翻车的五个点5.1 现象浏览器一直转圈服务器没日志原因响应头部Content-Length和实际 body 长度不一致客户端在等剩余字节。解决在send_response里加断言body_len必须等于实际写入的字节数或者用writev一次性写头部和 body让内核保证顺序。5.2 现象POST 请求后端收到空 body原因Content-Length解析成了 0或者recv只读了一次头部就返回了。解决解析头部时用strcasestr找Content-Length注意大小写不敏感读 body 时循环recv直到凑齐长度。另外检查客户端是否用了Transfer-Encoding: chunked轻量级服务器可以不支持直接返回 411。5.3 现象CGI 脚本执行后服务器卡死原因父进程没关in_pipe[1]子进程readstdin 一直等 EOF。解决写完 body 立即close(in_pipe[1])即使 body 为空也要关。另外waitpid要用WNOHANG轮询或者阻塞等防止僵尸进程堆积。5.4 现象多线程下响应串包原因多个线程共享了同一个缓冲区或者全局变量。解决每个连接的处理上下文用栈上局部变量或者从堆分配后传给线程。路由表是只读的可以共享但errno是线程局部的不用管。检查strtok这类非线程安全函数换成strtok_r。5.5 现象静态文件返回 404 但文件明明存在原因路径拼接时多了或少了/或者用了相对路径而工作目录不对。解决启动时用getcwd打印当前目录静态文件根目录用绝对路径。URL 解码也要做%20要转成空格否则带空格的文件名找不到。6. 数据读写优化与验证用strace和ab把服务器压到边界数据读写优化最直接的手段是减少read/write系统调用次数。静态文件用sendfile零拷贝直接从文件描述符传到 socket省去用户态缓冲区。CGI 响应如果不大一次性读进内存再写回如果大用splice在管道和 socket 之间搬数据。验证方法用strace -c统计系统调用耗时用ab -n 1000 -c 10压测 QPS。# 压测 GET 接口1000 次请求10 并发 ab -n 1000 -c 10 http://127.0.0.1:8080/ # 统计系统调用 strace -c -f ./http_serverab输出里看Requests per second和Time per request如果 QPS 上不去先看strace -c里read和write的次数是不是远大于请求数。优化后sendfile能把静态文件的readwrite合并成一次sendfile调用。注意ab默认用 HTTP/1.0不带Keep-Alive测短连接性能正好。我自己的习惯是每次改完解析逻辑先用curl -v发一个手工构造的请求看响应头对不对再用ab压 30 秒观察内存和 CPU。有一次忘了关in_pipe[1]CGI 脚本一跑就卡死strace显示父进程阻塞在read子进程阻塞在read两个都等对方血泪教训。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网