简介:面向具备C语言基础的研发人员,这是一份以TCP聊天室和HTTP服务器两大实战项目为线索的网络编程指南。内容从Socket套接字、端口号等基础讲起,深入解析三次握手、滑动窗口、拥塞控制等TCP核心机制,并覆盖HTTP请求响应结构、常见状态码、HTTPS安全原理,最终给出可运行的服务器端与客户端代码。文档共35页,支持目录章节跳转与大纲快速定位,图表、函数和代码示例显示完整,适合工作1-3年的开发人员分阶段学习。资源包为单个PDF文件,大小1.89MB,已有215人浏览学习。通过阅读可掌握C语言网络编程的完整知识链,包括TCP聊天室实现、HTTP服务器搭建流程,以及多线程、异步I/O、缓存机制等性能优化和输入验证、防缓冲区溢出等安全加固方法,助力独立开发高效安全的网络应用。
1. C语言网络编程这条线,为什么从TCP聊天室起步最不劝退
把C语言基础语法过完后,很多人的下一站就是网络编程。TCP聊天室到HTTP服务器这条路线,恰好把socket网络编程里最核心的两件事都覆盖了:长连接下的并发收发,和短连接下的文本协议解析。我按这条线,从bind、listen、accept这些最小API讲起,一直推到能处理并发请求的HTTP服务器,中间所有代码都能直接抄下来跑。
这套方案适合两类人:一是语法已入门、想找个真实项目落地的C新手,二是平时写业务代码、想补底层网络细节的开发者。聊天室帮你理解TCP连接和select复用,HTTP服务器让你真正看懂recv缓冲区、Content-Length和并发模型的坑。看完你不只“知道”,而是能本地跑通,也知道每个参数改了什么。
2. TCP聊天室:先把socket编程的骨架立住
2.1 三次握手和socket API的最小事实:bind、listen、accept
很多人学socket,第一个卡住的地方是三个常见词的顺序:客户端connect、服务端bind、listen、accept。封装得太友好,反而让人忽略了内核实际干了什么。三次握手并不发生在accept内部:客户端发出SYN开始,内核就自动完成握手,accept只是把已完成握手的连接从队列里摘出来。这个事实直接影响你对“并发”的理解——在两台机器之间通信时,服务端哪怕还阻塞在accept上,内核的已完成队列也能继续接收新连接。
服务端最小骨架就四步:
int srvfd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(srvfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(int)); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(9000); addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(srvfd, (struct sockaddr*)&addr, sizeof(addr)); listen(srvfd, 128);几个参数值得停下来说。第一,htons(9000)必须写,否则在x86这类小端机器上,9000(0x2328)会被原样塞进端口字段变成0x2823,服务端会莫名其妙连不上。字节序问题是C网络编程的第一个玄学,第5章我还会专门讲。第二,listen的backlog参数,Linux上用128比较常规,它管的是内核维护的“已完成握手但还没被accept取走”的连接队列长度,不是设成1024就能多扛1024个并发——真正吃资源的是后面accept出来的fd和它占用的收发缓冲区。第三,bind的IP我写的是INADDR_ANY,也就是0.0.0.0,表示监听本机所有网卡地址。如果只想本机调试,可以改成inet_addr("127.0.0.1"),但远程客户端就连不上了,很多查过“局域网访问不了服务器”这类问题的人,最后都栽在这一行。
2.2 一个能跑的最小聊天室:共享栈缓冲区版本
直接上epoll不现实,我选择先让单线程加select跑通。用整数数组存所有客户端fd,每次select前把数组里的fd全部加到读集合,谁有数据就把它的数据转发给其他所有人。这是“群聊”的最小实现,没有用户名、没有私聊,但网络编程的地基都在里面。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <sys/select.h> #define PORT 9000 #define MAXCLI 64 int clients[MAXCLI]; int client_count = 0; void broadcast(int fd, char *buf, int n) { for (int i = 0; i < client_count; i++) { if (clients[i] != fd) { send(clients[i], buf, n, 0); /* send不保证一次写完 */ } } } int main() { int srvfd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(srvfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(int)); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(PORT), .sin_addr.s_addr = htonl(INADDR_ANY), }; bind(srvfd, (struct sockaddr*)&addr, sizeof(addr)); listen(srvfd, 128); while (1) { fd_set rset; FD_ZERO(&rset); int maxfd = srvfd; FD_SET(srvfd, &rset); for (int i = 0; i < client_count; i++) { FD_SET(clients[i], &rset); if (clients[i] > maxfd) maxfd = clients[i]; } int nready = select(maxfd + 1, &rset, NULL, NULL, NULL); if (nready <= 0) continue; if (FD_ISSET(srvfd, &rset)) { int cfd = accept(srvfd, NULL, NULL); if (cfd >= 0 && client_count < MAXCLI) { clients[client_count++] = cfd; } } for (int i = 0; i < client_count; i++) { if (FD_ISSET(clients[i], &rset)) { char buf[1024]; int n = recv(clients[i], buf, sizeof(buf), 0); if (n <= 0) { close(clients[i]); clients[i] = clients[client_count - 1]; client_count--; i--; /* 替换过来的元素还没检查 */ } else { broadcast(clients[i], buf, n); } } } } }关键一点:select的返回值和recv的返回值不是一回事。nready表示有几个fd就绪,第一个就绪fd可能只来几个字节,其他fd还有数据等着收。所以下面的循环必须对整轮rset都做FD_ISSET检查,不能拿nready去限制循环次数,我见过很多人在这一步漏收消息。
再讲两个容易被忽略的实现细节。第一,删除客户端用的是“尾部覆盖”而不是memmove搬移,好处是数组始终连续,代价是你要对被覆盖的位置重新处理一次,所以循环里才有i--。第二,栈上的char buf[1024]对聊天室够用,但你要知道send并不保证把buf里所有字节一次写完,它可能只写了一部分;这个版本在局域网环境因为发送缓冲区通常空着,所以暂时没事,但到了HTTP大响应时会出问题,到时候就得循环send。代码里的注释写的“send不保证一次写完”,这是后面所有缓冲问题的源头。
跑起来怎么验证?开两个终端,用nc连上:
printf "hello chat\n" | nc 127.0.0.1 9000如果没装nc,用telnet也行。看到自己发的消息被另一个客户端收到,说明TCP长连接收发通路是通的。此时你就在跑一个真正的TCP服务器了,聊天室这个壳子只是让“连接”“读”“写”三者有了具体的业务含义。
2.3 聊天的本质是复用:select模型为什么是新手的第一道坎
有人一上来就想用多线程,每来一个连接就pthread_create。这个方案在十几个连接时很简单,但线程栈默认8MB,再加上线程切换和锁,聊天室超过百人就开始吃力。select模型解决的是“同时等一堆fd”:把关心的fd放进fd_set,内核阻塞等待,任何一个fd可读就唤醒你。
select的时代局限恰好是理解后面epoll的起点。第一,FD_SETSIZE默认是1024,fd超过这个数就装不下。第二,select返回后你不知道是哪个fd就绪,必须从头到尾遍历一遍,和在线fd数量线性相关。第三,fd_set是一次性集合,每轮select前都要重新FD_SET,重新拷贝的成本随fd数量线性增长。聊天室到几百个连接还感觉不到,再往上就开始喘。我在代码里没设timeout,传NULL表示永久阻塞,这在聊天室场景是对的——但如果有人把一个fd挂在集合里又一直不发数据,select永远等不到他之外的其他事件,这就是僵尸连接拖垮服务端的原型。后面做HTTP服务器时,超时管理是必做的功课。
到这里,你已经把bind、listen、accept、recv、send、select这几颗钉子钉好了。下一章把这些能力迁移到HTTP上,你会发现编码思路基本不变,变的是“怎么解析字节”。这才是从聊天室到HTTP服务器的真正衔接点。
3. 从TCP聊天室到HTTP服务器:协议换了,路子没换
3.1 HTTP和TCP聊天室的四个关键差异
很多人把HTTP服务器想成高深的东西,其实它就是“一个能解析特定文本格式的TCP服务器”。TCP聊天室里你收的是聊天内容的字节流,HTTP服务器收的则是请求行、请求头、请求体这几段文本。把差异提炼成四条,你后面不容易被细节淹没。
第一,连接生命周期不同。聊天室是长连接,连上后你来我往;HTTP/1.0默认是服务端返回响应就关闭连接,HTTP/1.1默认开启Keep-Alive,也就是http连接复用,让同一TCP连接上串行处理多个请求。第二,数据分帧机制不同。聊天室靠EOF或自定义分隔,HTTP靠\r\n\r\n加Content-Length:头部以空行结束,实体长度由头部里的Content-Length告诉你,这两者结合才能真正把“一个请求”从字节流里切出来。第三,通信方向不同。聊天室是任意客户端之间互发,HTTP是请求和响应严格一对一。第四,编码难度转移:聊天室的难点在并发广播,HTTP服务器的难点在解析和缓冲区管理。理解这四条,HTTP解析的代码就有了骨架。
3.2 解析请求行和头部:从recv到buffer的陷阱
先看最常见的翻车写法:
char buf[4096]; int n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { buf[n] = '\0'; /* 直接用buf做字符串解析 */ }问题出在哪?recv一次拿到的可能只是半个请求,也可能拿到两个完整的请求粘在一起。TCP是字节流协议,socket的接收缓冲区和C标准库的文件缓冲不是一回事:文件缓冲区是你在fopen、fread时通过setvbuf控制的,而socket缓冲区由内核管理,recv只是把内核缓冲里已经到的字节取走,它永远不知道“一个请求”从哪开始到哪结束。所以必须自己攒一个累积缓冲区,直到能切出一个完整的请求头。
我一般用一个带偏移量的buffer结构做累积读,最小实现长这样:
struct reqbuf { char data[8192]; int len; /* 当前已累积的字节数 */ }; /* 找头部结束符 \r\n\r\n;找到返回结束位置,找不到返回-1 */ int find_header_end(struct reqbuf *rb) { for (int i = 0; i + 3 < rb->len; i++) { if (rb->data[i] == '\r' && rb->data[i+1] == '\n' && rb->data[i+2] == '\r' && rb->data[i+3] == '\n') { return i + 4; } } return -1; } /* 每轮select让自己置位后调用 */ int append_and_parse(int fd, struct reqbuf *rb) { int n = recv(fd, rb->data + rb->len, sizeof(rb->data) - rb->len, 0); if (n <= 0) return -1; /* 连接关闭或出错 */ rb->len += n; int end = find_header_end(rb); if (end > 0) { rb->data[end] = '\0'; /* 在头部末尾安全截断 */ const char *reqline = rb->data; char *header_block = rb->data + end; /* 这里就可以交给解析函数了 */ return end; } return 0; /* 半包,需要继续recv */ }这段代码有个细节非常容易踩坑:rb->data[end] = '\0'是在已经确认找到\r\n\r\n之后才做的,所以不会越界;如果没找到,绝不能提前在rb->data[rb->len]处写\0再去做strstr,因为那会在半包时把数据截断,下一个recv从错误偏移继续写,整个缓冲区越写越乱。
累积缓冲的边界要提前定好。我设8192是因为绝大多数请求头不会超过这个量;如果recv返回0,或者rb->len等于缓冲区大小还没找到头部结束符,就直接回一个“400 Bad Request”并关闭连接。这个决策在真实服务器里很常见——恶意客户端可以只发半个请求头把你拖住,所以必须给“半包”设上限,不能无限等。
请求行的解析还有一层小坑。第一行的格式是METHOD SP URI SP HTTP/x.y,注意空格只有一个,URI里的?query要保留,后面的版本号有HTTP/1.0和HTTP/1.1之分。可以用sscanf,因为%s遇空格就停,天然适合拆这三段。但我建议不要直接对原始累积缓冲做strtok,因为它会原地写\0,把你后续按偏移读头部的计划全打乱。先把请求行拷贝到另一个数组里再解析,代价很小,心智负担小得多。
3.3 最小HTTP响应:状态行、响应头、Content-Length
解析完请求之后,响应就是照格式回文本。最小响应长这样:
const char *body = "<html><body><h1>hello from c</h1></body></html>"; char resp[2048]; int body_len = (int)strlen(body); int n = snprintf(resp, sizeof(resp), "HTTP/1.1 200 OK\r\n" "Content-Type: text/html; charset=utf-8\r\n" "Content-Length: %d\r\n" "Connection: close\r\n" "\r\n" "%s", body_len, body); send(fd, resp, n, 0);这里最值得较真的是Content-Length。它必须是实体的字节数,不是字符数。C语言里strlen正好返回字节数,但如果body里包含中文UTF-8编码,一个汉字占3个字节,对"你好"来说Content-Length应该是6而不是2。很多客户端确认响应完整就是靠这个数字:它发现收到的字节数小于Content-Length就继续等,发现大于就按长度截断。数错了,轻则超时,重则下一个响应被当成本次响应的残留体。这个机制也顺带解释了为什么“粘包/半包是网络编程的必修课”,HTTP服务器能不能做对,一半看这里。
Connection: close的作用是告诉客户端“响应发完我就断”。这样测试阶段省心,不用处理Keep-Alive的请求复用。等你想上http连接复用的时候,再去掉这个头改成Connection: keep-alive,但切记还要在服务端维护每个连接上一次请求的解析状态,不是随手就能改的。到这一步,你的服务器已经能返回一个静态HTML,但并发能力还停在第一章的select水平。下一章我们把模型升级成epoll,再做本地压测看真实吞吐。
4. HTTP服务器落地:并发模型选择与本地压测
4.1 多线程 vs 非阻塞IO:怎么选
到了HTTP服务器,select模型已经明显拖后腿,但不意味着所有人都该直接上epoll。先看清楚三种模型的开销对比:
| 并发模型 | 核心开销 | 适合规模 | 实现复杂度 |
|---|---|---|---|
| 每连接一线程 | 线程栈加切换 | 几十连接 | 最低 |
| select/poll单线程 | fd_set线性遍历 | 几百连接 | 低 |
| epoll | 事件驱动,就绪通知O(1) | 几千到十万连接 | 中 |
我自己的选择标准是:给本地工具或个人服务用多线程,一百个连接内代码最短;给教学演示用select,因为逻辑直白;给要拿数据说话的服务用epoll。线程方案的隐患是“线程数等于连接数”,一个恶意客户端连上不发言,它的线程就空占着栈内存和上下文切换额度。你如果目标只是让局域网里的几台机器访问,线程模型完全够,但并发量一上来,最痛苦的就是“连接还在,系统却卡死”的状态。
4.2 epoll版本的核心代码路径
epoll和select最大的区别是:不用每次把全部fd集合拷贝给内核,内核帮你维护一棵红黑树,事件发生时你只需要从就绪链表里取出有限个活动fd。这是从“遍历所有连接”到“只处理有事的连接”的质变。最小骨架:
#include <sys/epoll.h> struct epoll_event ev; struct epoll_event events[128]; int epfd = epoll_create1(0); ev.events = EPOLLIN; /* 只关心可读 */ ev.data.fd = srvfd; epoll_ctl(epfd, EPOLL_CTL_ADD, srvfd, &ev); while (1) { int n = epoll_wait(epfd, events, 128, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == srvfd) { int cfd = accept(srvfd, NULL, NULL); ev.events = EPOLLIN; ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); } else { /* 在这个分支里累积读并解析HTTP */ handle_request(fd); } } }三个参数必须解释清楚。epoll_wait的第二参数是就绪事件数组,128表示一次最多取128个,实际就绪数可能更多,剩下的下次再取;第三参数和第二个配套,是数组容量;第四参数-1表示永久等待,和select里的NULL一个意思。events[i].data.fd是我们在ADD时放进来的用户数据,epoll_event里data是最容易被用错的字段:它是union,你既可以用fd也可以用ptr,但用ptr指向的堆内存必须自己管理生命周期,否则fd关闭后指针失效,回调一访问就是use-after-free。
这里用的是LT(水平触发),epoll默认模式:只要fd还有数据没读完,每次epoll_wait都会重新返回它。ET(边沿触发)只在状态变化时报一次,要求你一次recv必须读到EAGAIN为止,否则数据会一直留在内核缓冲区而不再通知你,属于典型的“写起来帅气、调起来费劲”方案。新手不要先碰ET,把LT调通再考虑,我就吃过ET的亏:测试时正常,线上一条慢速连接导致后续请求全部排队。
注意:epoll默认是LT。想用ET必须处理好EAGAIN,否则服务器在低流量时正常,高流量或慢连接下会丢事件。
4.3 用curl和wrk做本地验证:参数怎么设
写好代码后不要急着说性能,先冷冰冰地做三件事:curl验证正确性,wrk测吞吐,tcpdump抓包看握手和关闭。命令如下:
# 1. 验证单次请求能拿到正确响应 curl -v http://127.0.0.1:9000/ # 2. 模拟带Body的POST,验证Content-Length解析 curl -X POST http://127.0.0.1:9000/ \ -H "Content-Type: text/plain" \ -d "hello body" # 3. 压测:4线程、100并发、打10秒 wrk -t4 -c100 -d10s http://127.0.0.1:9000/wrk的输出里重点看两个数:Requests/sec和Latency,特别是P99。P99如果比中位数高一两个数量级,通常意味着有慢连接在拖累事件循环。压测用的-c是并发连接数,它不等于线程数,wrk每个线程会同时维护多个连接。还有一个容易犯迷糊的点:你的服务器如果没实现Keep-Alive,每条响应都带Connection: close,wrk会不断建立新连接。这时候Requests/sec里隐含了大量握手开销,不能代表HTTP处理本身的速度。想看纯处理能力,必须先在服务端支持http连接复用。
压测时如果发现Connection refused或者Address already in use,先查两件事:一是服务端是不是没设SO_REUSEADDR,二是客户端那边TIME_WAIT的socket是否堆积。这两个问题我放到第5章专门拆开讲,因为它们几乎每个从聊天室转HTTP的人都会遇到。
5. 避坑:C语言网络编程最常见的几个翻车点
5.1 粘包/半包:recv一次不一定拿到完整请求
现象:客户端连发两条HTTP请求,服务器一次recv把两条都读进来了;或者请求头比较长,一次recv只读了一半。
原因:TCP是字节流协议,内核只保证“按序交付字节流”,不保证“你一次recv的边界等于对方一次send的边界”。TCP协议栈会在IP层分片、在接收端缓冲,recv何时返回数据,取决于对方报文到达节奏和内核接收缓冲区水位。
解决:不要指望recv来分帧。服务端必须自己维持累积缓冲,先找到\r\n\r\n切出头部,再按Content-Length读够请求体。我在3.2节写的find_header_end就是干这个的。把“顺手在recv后加\0”的习惯删除,把“接收”和“解析”解耦。
5.2 SIGPIPE导致进程无声退出
现象:客户端已经断开,服务端还在send,进程直接退出,日志里什么都没有。
原因:Linux里对已关闭连接的另一端做write或send,内核会发SIGPIPE信号,默认动作是终止进程。这跟“send返回EPIPE错误码”是两回事——信号来了,进程根本没机会执行你的错误分支。
解决:三种常见做法,任选其一。启动时signal(SIGPIPE, SIG_IGN)全局忽略;或者send时加MSG_NOSIGNAL标志;或者写循环send并检查返回值。我推荐至少把MSG_NOSIGNAL加上,因为全局忽略信号会让其他依赖SIGPIPE的库的行为变得不直观。这个问题太隐蔽了,大多数人第一次遇到时都会觉得是“服务器被鬼杀了”。
5.3 TIME_WAIT和端口耗尽
现象:服务端重启时报Address already in use,或者压测跑了几万请求后新连接建不上了。
原因:主动关闭连接的一方会进入TIME_WAIT状态,持续约2MSL,Linux默认60秒。如果压测客户端不断打开新连接、又不断关闭,端口被TIME_WAIT占满,新连接的本地端口就没有可用的四元组了。服务端重启时,旧连接还挂在TIME_WAIT上,bind同一个端口会被拒绝。
解决:服务端在bind前设置SO_REUSEADDR,一般就好了。压测端则尽量使用Keep-Alive长连接复用,不要每请求一连接;真要短连接风暴,可以调net.ipv4.ip_local_port_range扩大可用端口段。这里有一条血泪经验:HTTP服务器没开Keep-Alive前,wrk一压就死,跟你的epoll逻辑没关系,全是TIME_WAIT在捣乱。
5.4 字节序:端口和IP的坑
现象:服务端写htons(9000)就正常,不写就出现ESP01这类嵌入式客户端连不上;把sockaddr_in里的sin_port打出来,看到的是一堆乱值。
原因:x86是小端,网络字节序是大端。多字节整数在上网络前必须转成网络序,头文件里给齐了htons、htonl、ntohs、ntohl一组函数。字符串IP也卡在同一个点上:inet_addr("192.168.1.10")返回的就是网络序整数,直接赋给s_addr没问题;但如果你自己拼整数再赋过去,方向就反了。
解决:把“看到sin_port,就要想到你正在看小端值”当成肌肉记忆,调试时用ntohs(addr.sin_port)再打印。跨设备、跨语言联调时,字节序问题往往表现为“服务器能收到包,但解析全错”。
5.5 recv返回值和缓冲区边界
现象:一段代码在局域网好好的,一上公网就随机崩溃;或者recv返回-1后程序死循环。
原因:recv返回三种值:大于0是收到的字节数,0是对方正常关闭,-1是出错。新手只处理大于0的情况,把0和-1当普通数据往下传,缓冲区索引就会越界。另一个经典坑是:把recv的返回值(int)和sizeof(buf)(size_t,无符号)比较,-1会被转成一个巨大的正数,判断逻辑全乱。
解决:每次recv后第一件事就是三路分支:n > 0处理数据;n == 0关闭连接并清理fd;n < 0判断错误码是不是EINTR或EAGAIN,这两个错误码代表“不是真错误”,其余全部按连接异常处理。我后来写服务器时把这套判断封装成一个recv_safe函数,所有调用点统一走它,从那以后再没有因为返回值吃过暗亏。
6. 最后一步:把简单服务器改造成能抗压的样子
6.1 超时、Keep-Alive、优雅关闭:三个绕不开的补丁
到这里,TCP聊天室和基础HTTP服务器都已经能跑,但离“能上线”还差三件特别具体的事:超时、Keep-Alive、优雅关闭。这三个功能单独看都不复杂,组合起来才是真实服务的常态。我的经验是:不要先追求功能多,先让服务器在持续压测下不崩溃。
加超时的方案是给每个连接记录last_active时间戳,epoll_wait返回后先检查有没有连接超时,超时的直接close。timeout值设多少是门玄学:HTTP短连接压测时设5秒足够,长连接保活建议60秒,太小会让局域网内的客户端频繁断线重连。实现上也很简单:
struct epoll_event events[128]; int timeout_ms = 1000; /* 1秒轮询一次,检查超时连接 */ while (1) { int n = epoll_wait(epfd, events, 128, timeout_ms); if (n == 0) { check_timeout(); /* 没有事件,趁机扫描空闲连接 */ } for (int i = 0; i < n; i++) { /* 原有的事件处理 */ } }Keep-Alive的实现也不难,关键是把Connection: close改成Connection: keep-alive,同时在服务端记住每个fd的解析状态——上次请求留下的半包还存在该fd对应的缓冲区里。这里最容易犯的错是把多个连接的数据共用一份全局缓冲,我一开始就是这么干的,结果并发一上来消息全部串台。每个fd一份struct reqbuf,连接关闭才释放,这是http连接复用必须付出的内存成本,属于该花的钱。
优雅关闭的意思是:收到退出信号后,不再accept新连接,但给已经在处理的请求几秒钟时间完成响应,超时再强杀。这样升级服务时客户端不会突然觉得连接被腰斩。
6.2 压测三关:验证这台服务器能不能活着
所有功能加完后,我通常会做一轮验证,跑过三关才算收工。第一关,长时间压测看进程是否还活着:
wrk -t2 -c50 -d30s http://127.0.0.1:9000/第二关,杀掉进程后立刻重启,看端口是否马上可用:
pkill -f server sleep 1 ./server &第三关,用curl把GET、POST、带Body、不带Body四类请求各打一遍,确认没有崩溃、没有超时、返回头里的Content-Length和实际响应体字节数一致。三关都过,这台服务器至少能处理真实局域网环境下的中小规模负载了。
我还想建议你养成两个习惯。第一,每个连接外面包一层连接对象,而不是到处传递裸fd,裸fd在并发、日志、超时叠加之后根本没法维护。第二,响应头里尽量用固定顺序:状态行、Content-Type、Content-Length、Connection,少一个头就少一层调试心智。线上我见过最头疼的bug就是响应头顺序混乱导致某些客户端解析出错,最后靠抓包才发现是顺序问题。
我自己当年从聊天室学到HTTP服务器,最大的教训就是:别把“连接能建立”当成“功能做完了”。字节流是黑匣子,缓冲区是放大器,所有看起来的玄学最终都会回到字节和边界。这个方向值得继续投入——把它吃透,往后看Redis、Nginx这类C网络服务的源码,你会发现到处都是这套骨架的影子。希望帮到你。
本文还有配套的精品资源,点击获取