你敲下浏览器地址栏的回车键,页面哗一下打开了。你有没有想过,整个过程里,那个存着网页的“房间”到底长什么样?它为什么能把内容给你,又是用什么语言跟你对话的?我一直觉得,程序员真正的成人礼不是写出第一个Hello World,而是亲手做一个让浏览器能访问的服务端程序。哪怕只是返回一个静态页面,那种“我能控制一台机器对外提供服务”的感觉,和写普通控制台程序完全不一样。
这篇文章就围绕“从服务器定义到C语言实现简易Web服务器”展开,目标很明确:用最直白的话告诉你服务器是什么,然后带着你用C语言和socket从零实现一个能跑通HTTP请求的Web服务器。你会看到监听端口、解析请求、拼装响应这些底层动作是怎么一步步发生的。适合刚学完C语言但不知道它能干嘛的在校生,也适合想回过头补一补网络基础的在职程序员。
1. 服务器到底是个什么东西
1.1 别被“服务器”三个字吓住
很多初学者一听到“服务器”就脑补出一间冷冰冰的机房、一堆闪着灯的铁盒子。这不算错,但容易把自己吓住。你可以换个角度想:所谓服务器,就是一台装了特定软件、在特定端口上等着别人来访问的电脑。它可以是几万块的机架式机器,也可以是家里退役的笔记本,甚至可以就是你现在手上正在写代码的这台电脑。
关键不在硬件,而在软件。只要你的程序绑定了某个端口,让外部可以通过网络发消息进来并得到回应,你就在那台机器上制造了一台“服务器”。所以,你自己完全可以做一台服务器,这不依赖任何云厂商或者机房。
这里有个常见的误区:服务器和客户端不是两台固定的设备,而是两种“角色”。同样一台电脑,可以同时跑着Web服务端、SSH服务端、数据库服务端,也可以开着浏览器去访问别人的服务。角色可以叠加,全看你装了哪些服务软件。
1.2 服务器和IP、端口的暧昧关系
一台机器对外提供服务,必须说清楚两件事:谁找到你,以及找到你之后做什么。谁找到你,是IP地址。它就像你家小区的门牌号。但一台机器上可能同时跑着十几个服务,光找到这台机器还不够,你得走到具体的房间门口。这个“房间号”就是端口。
HTTP服务默认是80端口,HTTPS是443端口,但你测试环境完全可以用8080、8899这类高端口,避免权限和冲突问题。
我见过不少初学者混淆“URL里面的路径”和“端口”这两个概念。URL里的路径比如/index.html,是到了服务器之后,程序内部再去匹配的资源名称;端口则是在连接阶段就要确定的入口。两者层级完全不同,一个是应用层逻辑,一个是传输层寻址。
1.3 一次HTTP访问背后的几个心眼
当你在浏览器输入地址,回车,按下那一下的瞬间,背后其实发生了一连串事情:DNS把域名解析成IP;浏览器向目标IP的80端口发起TCP连接;连接建立后,浏览器发送一段文本(HTTP请求);服务器解析这段文本,在磁盘上找到对应文件,再回一段文本(HTTP响应);最后浏览器画出页面。
用代码去模拟这个过程,最直接的办法就是借助C语言的socket。socket可以理解成操作系统给你开的一扇门,通过这扇门,你既能收发网络数据,又能控制连接的建立和关闭。我们接下来的整个项目,本质上就是围绕着“创建socket、绑定、监听、接收、解析、回包”这几个动作在做文章。很多博客教程喜欢一上来就贴几十行代码,看得人眼花缭乱,我实在不赞成那种教法。更好的思路是:先知道每一步在做什么,再去看代码,代码就是工具的说明书。
2. 写Web服务器之前,先把这些基础设施盘清楚
2.1 socket到底是C语言的什么东西
如果你只是用C语言写过文件操作,那么理解socket有个捷径:你可以把它当成一种特殊的文件。文件是open、read、write、close,网络传输是socket、recv、send、close,姿势几乎一样。只不过文件是往磁盘上写,socket是往网络上传。
服务器和客户端建立连接的方式不同调:客户端需要主动connect去连别人;服务器则要被动地坐在那里,先bind把自己的地址和端口定下来,再listen表示“我这里可以接受连接”,然后调accept等待来访者。
用生活场景来比:bind是打工人在写字楼里租了一个工位并贴上自己的工牌;listen是前台就位;accept是你抬头看到有人走过来,开始握手交谈。
这里有个细节必须提醒你:accept不是接收数据,它只是完成了“从全连接队列里取出一个已经排好队的连接”。真正的数据接收发生在连接建立之后,靠的是recv或者read。这个概念在初学阶段特别容易混淆,多花两分钟想想,后面能省不少事。
2.2 HTTP协议只需要懂三行
市面上讲HTTP的书动不动就是三百页,但你想实现一个静态文件服务器,其实只需要懂三样东西:请求行、请求头、然后是空行加请求体。普通GET请求连请求体都没有。
一个最典型的HTTP请求是长这个样子的:
GET /index.html HTTP/1.1 Host: 127.0.0.1:8080 Connection: keep-alive第一行是请求行,包含方法、路径、协议版本;中间几行是请求头;最后那个空行是分隔符。注意,浏览器发请求时,服务器必须先看到空行,才认为头部到此为止。很多简易服务器“读不到完整请求”,原因就是没处理这个空行。
响应的结构类似:第一行是状态行,比如HTTP/1.1 200 OK,然后是一堆响应头,然后空行,最后是响应体。响应头里的Content-Length特别关键,它告诉浏览器你到底要发多长的内容。如果你响应体有1000个字节,但你写成了900,浏览器就会一直转圈,一次又一次地等;如果你写成了1100,它就会多出100个字节的乱码挂在页面尾部。这个地方我后面会在常见问题里展开。
2.3 开发环境和编译指令
这个项目用到的头文件都需要Unix环境支持,所以最好在Linux或者macOS上操作。Windows用户建议装一个虚拟机或者用WSL,否则要处理Windows Socket的差异,对新手来说那是额外的负担。
我的建议版本是酱紫的:一台装了Linux的电脑(或者云主机),带gcc,不需要任何第三方库。确认gcc是否就绪可以敲:
gcc --version然后就是整个项目用到的全部外部知识点了。剩下的,都是你熟悉的C语法,结构体、字符串处理、函数调用。
3. 核心实现:每一行代码我都帮你拆开讲
3.1 从创建socket到绑定端口
先看第一段代码。我习惯在项目里单独建一个目录,比如tinyweb/,里面只放一个server.c文件。这个阶段专注逻辑,不搞工程化。
#include <arpa/inet.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <sys/types.h> #include <unistd.h> #define PORT 8080 #define BUFFER_SIZE 4096 int main() { int server_fd, client_fd; struct sockaddr_in address; socklen_t addr_len = sizeof(address); // 1. 创建socket,AF_INET表示IPv4,SOCK_STREAM表示TCP server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 设置地址结构体:IP、端口 address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; // 绑定所有网卡 address.sin_port = htons(PORT); // 转为网络字节序 // 3. bind:把socket和端口绑在一起 if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { perror("bind"); close(server_fd); exit(EXIT_FAILURE); } // 4. listen:告诉系统可以开始排队了 if (listen(server_fd, 10) < 0) { perror("listen"); close(server_fd); exit(EXIT_FAILURE); } printf("Server listening on port %d\n", PORT); // 5. main loop while (1) { client_fd = accept(server_fd, (struct sockaddr *)&address, &addr_len); if (client_fd < 0) { perror("accept"); continue; } // 这里暂时留空,后面填处理逻辑 close(client_fd); } close(server_fd); return 0; }socket()的第一个参数AF_INET用的是IPv4,第二个参数SOCK_STREAM代表TCP。这个组合最常见,也是Web服务器最常用的模式。
bind()这一行要特别注意:传入的地址结构体里,端口号必须用htons()做字节序转换。原因是网络传输用的是大端,而你的机器可能是小端,你不转一下,端口就会偏到意料之外的位置去。初学者第一次写的时候,十有八九会忘掉htons,结果绑定不上端口,还找不到原因。
listen()的第二个参数是backlog,表示连接队列的最大长度。我写10,意思是最多允许10个连接在队列里等待accept。这个值不是越大越好,太大反而可能掩盖处理不过来的问题。
这段代码写完,你已经拥有了一个“能监听端口”的半成品服务器。至少可以在另一台机器上敲telnet ip 8080试试,端口通不通,一测便知道。
3.2 读取并解析HTTP请求
accept拿到client_fd之后,就该跟客户端对话了。我们先用一块缓冲区把客户端发过来的数据读出来:
char buffer[BUFFER_SIZE] = {0}; int bytes_read = read(client_fd, buffer, sizeof(buffer) - 1); if (bytes_read < 0) { perror("read"); close(client_fd); continue; }读完以后,buffer里就是浏览器发过来的原始HTTP请求文本。我们可以把它打印出来,你会发现跟你手写的HTTP报文长得一模一样。这是个非常惊艳的时刻,因为你确实在用最底层的方式跟真实的浏览器面对面交流。
接下来要做的第一步是解析请求行,拿到方法、路径、版本。
我见过很多教程用strtok去切割,但strtok会修改原字符串,非常危险。我建议用sscanf做格式化提取,简单又安全:
char method[16] = {0}; char path[256] = {0}; char version[16] = {0}; if (sscanf(buffer, "%15s %255s %15s", method, path, version) != 3) { const char *bad = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; send(client_fd, bad, strlen(bad), 0); close(client_fd); continue; }sscanf会跳过空白字符,所以请求行GET /index.html HTTP/1.1会被拆成method=GET、path=/index.html、version=HTTP/1.1。%15s里的宽度限制是为了防溢出,永远要给读入的字符串留一个终止符的位置。
解析完请求行,我只支持GET,如果进来的是其他方法,就回一个501 Not Implemented。对于简易服务器,这种做法是完全合理的。处理逻辑也很简单:
if (strcmp(method, "GET") != 0) { const char *not_impl = "HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n"; send(client_fd, not_impl, strlen(not_impl), 0); close(client_fd); continue; }3.3 根据路径找到文件,并处理“根目录”
解析出路径之后,我们要把路径映射成文件系统里的资源。这里有一个必须处理的细节:当浏览器访问http://127.0.0.1:8080/时,我们拿到的path是/,但文件系统里并没有一个文件叫/。传统做法是:如果路径是/,就默认返回index.html。
这一步看似简单,却隐藏着一个安全大坑。如果你直接做字符串拼接,比如"static/" + path,当path是/../etc/passwd的时候,拼接结果就变成了static/../etc/passwd,顺着路径逃逸出去了,服务器上的敏感文件就全裸奔了。防护办法至少有两个:一是拒绝任何包含..的路径,二是对路径做规范化。对于简易服务器,我选择拦截:
if (strstr(path, "..") != NULL) { const char *forbidden = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"; send(client_fd, forbidden, strlen(forbidden), 0); close(client_fd); continue; }然后根据路径去打开文件:
char filepath[512]; if (strcmp(path, "/") == 0) { snprintf(filepath, sizeof(filepath), "static/index.html"); } else { snprintf(filepath, sizeof(filepath), "static%s", path); } FILE *fp = fopen(filepath, "rb"); if (fp == NULL) { const char *not_found = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; send(client_fd, not_found, strlen(not_found), 0); close(client_fd); continue; }我在项目的当前目录下设了一个static/目录,把网页资源都放在里面。路径映射时统一加上这个前缀,避免访问到代码文件本身。
3.4 拼装HTTP响应并返回内容
文件打开成功,下一步就是构建一个完整的HTTP响应。响应头需要包含这么几个字段:状态行、Content-Type、Content-Length、Connection。
Content-Type我建议做个简单映射表:
.html→text/html.css→text/css.js→application/javascript.png→image/png.jpg→image/jpeg- 其他默认 →
application/octet-stream
为了把Content-Length算对,最简单可靠的方案是先把文件内容全部读进内存,拿到字节数,再拼装响应。这个方案对超大文件不友好,但在简易服务器里完全够用。
fseek(fp, 0, SEEK_END); long file_size = ftell(fp); rewind(fp); char *body = malloc(file_size); if (body == NULL) { fclose(fp); close(client_fd); continue; } size_t read_bytes = fread(body, 1, file_size, fp); fclose(fp); char header[512]; int header_len = snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\n" "Content-Type: text/html\r\n" "Content-Length: %ld\r\n" "Connection: close\r\n" "\r\n", read_bytes); send(client_fd, header, header_len, 0); send(client_fd, body, read_bytes, 0); free(body); close(client_fd);注意看,Content-Length填的是read_bytes,也就是实际读到的字节数。这个数值必须精确,多一个少一个都会引发浏览器异常。
另外,我特意加了Connection: close。这意味着服务器在处理完一个请求后就主动关闭连接。HTTP/1.1默认是keep-alive,如果你不处理长连接,最简单的方法就是明明白白告诉浏览器:我不保持连接。这样虽然每次握手都多花一点时间,但换来的是实现复杂度大幅降低。这个取舍对小项目完全划算,初学阶段没必要纠结性能。
4. 完整代码与编译运行实录
4.1 把上面的碎片组装成一个完整文件
上面各段代码大家看得七零八落,容易失去整体感。我这里贴一份完整可运行版本,把前面所有片段按照执行顺序拼接好。你直接复制就能用。
#include <arpa/inet.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <sys/types.h> #include <unistd.h> #define PORT 8080 #define BUFFER_SIZE 4096 int main() { int server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } int opt = 1; if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { perror("setsockopt"); close(server_fd); exit(EXIT_FAILURE); } struct sockaddr_in address; address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons(PORT); if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { perror("bind"); close(server_fd); exit(EXIT_FAILURE); } if (listen(server_fd, 10) < 0) { perror("listen"); close(server_fd); exit(EXIT_FAILURE); } printf("Server listening on http://127.0.0.1:%d\n", PORT); while (1) { struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len); if (client_fd < 0) { perror("accept"); continue; } char buffer[BUFFER_SIZE] = {0}; int bytes_read = read(client_fd, buffer, sizeof(buffer) - 1); if (bytes_read < 0) { perror("read"); close(client_fd); continue; } char method[16] = {0}; char path[256] = {0}; char version[16] = {0}; if (sscanf(buffer, "%15s %255s %15s", method, path, version) != 3) { const char *bad = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; send(client_fd, bad, strlen(bad), 0); close(client_fd); continue; } if (strcmp(method, "GET") != 0) { const char *not_impl = "HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\n\r\n"; send(client_fd, not_impl, strlen(not_impl), 0); close(client_fd); continue; } if (strstr(path, "..") != NULL) { const char *forbidden = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"; send(client_fd, forbidden, strlen(forbidden), 0); close(client_fd); continue; } char filepath[512]; if (strcmp(path, "/") == 0) { snprintf(filepath, sizeof(filepath), "static/index.html"); } else { snprintf(filepath, sizeof(filepath), "static%s", path); } FILE *fp = fopen(filepath, "rb"); if (fp == NULL) { const char *not_found = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; send(client_fd, not_found, strlen(not_found), 0); close(client_fd); continue; } fseek(fp, 0, SEEK_END); long file_size = ftell(fp); rewind(fp); char *body = malloc(file_size); if (body == NULL) { fclose(fp); close(client_fd); continue; } size_t read_bytes = fread(body, 1, file_size, fp); fclose(fp); char header[512]; int header_len = snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\n" "Content-Type: text/html\r\n" "Content-Length: %ld\r\n" "Connection: close\r\n" "\r\n", read_bytes); send(client_fd, header, header_len, 0); send(client_fd, body, read_bytes, 0); free(body); close(client_fd); } close(server_fd); return 0; }4.2 编译运行,并用浏览器直视结果
在项目目录下先创建一个static文件夹,在里面放一个index.html,内容随意,哪怕就写一句话也可以。
mkdir static echo '<h1>Hello from my own server!</h1>' > static/index.html gcc -o server server.c ./server正常情况下,你会看到一行输出:
Server listening on http://127.0.0.1:8080这时候打开浏览器,访问http://127.0.0.1:8080/,页面就会显示你写的那句h1标题。再用命令行验证一次更准确的响应内容,在另一个终端窗口执行:
curl -i http://127.0.0.1:8080/-i参数会把响应头也打出来,你能看到类似这样的完整输出:
HTTP/1.1 200 OK Content-Type: text/html Content-Length: 30 Connection: close <h1>Hello from my own server!</h1>我强烈建议你在浏览器开发者工具的“网络”标签里再看一眼:点开那个文档请求,查看响应头,你会发现这些字段就是我们代码里写的那几行。看到自己写的字段出现在真实浏览器里,那种成就感跟写算法题完全不是一个量级。
4.3 加上日志:让服务器“开口说话”
一个没有日志的服务器,就像一台没有仪表的汽车,出了问题你完全不知道发生了什么。我在调试时习惯在每次请求结束前打印一行访问日志,格式不重要,关键是要能看到来源IP、请求方法和状态码。
加一段代码:
printf("%s %s %s\n", inet_ntoa(client_addr.sin_addr), method, path);把这一行放在响应发送完成之后。这样当你用手机或者另一台电脑访问时,服务器终端就会实时滚出访问记录,很有“运维上线”的感觉。如果你连状态码也想记,就在每个分支里把状态码打印出来。这是后续排查问题的基础设施,非常值得现在花两分钟加上。
5. 实战中的坑:并发、粘包、还有安全性
5.1 单进程服务器的致命弱点:一次只能服务一个人
上面这份代码有个很尴尬的缺陷:它是单线程阻塞式的。处理完第一个请求、关闭连接之后,才会回到accept等下一个。如果我在浏览器里打开你的服务器页面,同时再开一个新标签页去访问第二个资源,第二个请求就会一直卡着,直到第一个连接被关闭。这是因为accept只是从队列里取连接,但处理方式本身是一次一个。
解决办法有很多。一个极端传统但特别好懂的方式是fork一个子进程去处理每次连接:
pid_t pid = fork(); if (pid == 0) { close(server_fd); // 子进程在这里处理client_fd // 处理完exit(0) } else { close(client_fd); }fork之后,子进程继承父进程的所有文件描述符,包括server_fd和client_fd。子进程必须把server_fd关掉,否则监听socket一直被占用,后续就没法接受新连接;父进程则必须把client_fd关掉,不然这个连接永远不释放。
如果你不想用进程,另一个选择是pthread_create创建线程。但要记得处理并发访问共享资源的问题。还有一种很现代的方案是非阻塞IO加select/poll/epoll事件循环,这套是目前高性能服务器的主流方向,但明显超出了“简易版”的讨论范围。我的建议是:先fork,它简单、直观、能好地帮你理解多进程网络模型,等有需求了再学事件驱动。
5.2 浏览器一直转圈:八成是Content-Length不对
新手最容易踩的坑就是响应长度跟实际内容对不上。HTML本身是文本,你看着它不多不少,但一旦里面含中文或特殊字符,问题就来了。如果你用strlen去算中文字符串的长度,那根本没有意义,因为中文在UTF-8下是三个字节,而char单位是字节。所以一定要用文件读到的字节数,不要自己去数长度。
还有一种情况是我自己踩过的:有些文档写Content-Length: strlen(html),但html里含换行,不同操作系统的换行符存储方式不同,实际字节数完全不一样。只要长度错一个字节,浏览器就会一直等,直到超时。
另外,如果你在响应里用了Connection: keep-alive,但你的服务器又没有真正处理同一个连接上的多个请求,浏览器就会一直复用这个连接等待下一个响应,表现同样是页面转圈。所以我不止一次强调,简易版老老实实用Connection: close,让连接处理完一个请求就关闭,把状态机复杂度降为零。
5.3 你真的不能对客户端数据掉以轻心
我在前面的代码里做了三件保护性的事:限制sscanf的长度、拦截..路径、拒绝非GET方法。这三招只是最低限度,真正的服务器还应该有更多防御。比如,你读请求时如果只读了一次read,而浏览器发的请求因为网络延迟被拆成了两截,你可能读到的是残缺的报文。正确做法是循环读取,直到读到空行才算头部完整。虽然我们的简易版遇到这种情况的概率不高,但你要知道有这个隐患。
还有一点是拒绝过大的请求。BUFFER_SIZE我设成了4096字节,如果对方发来10MB的垃圾数据,read只会读走4KB,剩下的还留在内核缓冲区里。如果你后面写了循环读取,就更容易被拖垮。生产环境的服务器都会有请求体大小限制,我们这个简易版直接只读一次、只处理头部,反而天然规避了这个问题。
在HTTP响应头里,Connection: close还会触发一个衍生的坑:HTTP响应结束的判定有两种方式,一种靠Content-Length,另一种靠连接关闭。既然我们明确给出了Content-Length,浏览器正常情况下会按长度截断。但如果网络发生异常,连接先断开,浏览器可能也会给出“连接重置”的错误提示。这类问题在局域网里不太会出现,但在公网环境下千万别忽略。
5.4 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 浏览器显示“无法访问此网站” | 服务器没启动 / 端口错了 | 检查进程、检查./server输出 |
bind报“Address already in use” | 上次进程没退出,端口被占用 | 用pkill server或换端口,或者加SO_REUSEADDR |
| 页面能打开但资源加载不出来 | 路径映射不对,静态文件没找到 | 检查static/目录结构,查看服务器日志 |
| 浏览器一直转圈 | 响应头缺Content-Length或长度不对 | 核对read_bytes;确保是实际字节数 |
curl正常但浏览器异常 | 响应头格式有问题 | 检查空行和\r\n是否完整 |
| 中文乱码 | 没加charset=utf-8 | 在Content-Type后追加; charset=utf-8 |
我想特别解释一下SO_REUSEADDR的作用。你如果服务器崩了或者按Ctrl+C停了,操作系统不会立刻释放端口,当你马上重启时会遇到Address already in use。加了SO_REUSEADDR后,系统允许你在端口尚未完全释放时重新绑定,调试体验会好非常多。这段代码我加在完整版里面了,不建议去掉。
6. 项目做完之后的下一步实验方向
6.1 给服务器增加一点点功能的先后顺序
代码能跑起来以后,你看它就再也不是“一堆看不懂的字母”了。你可以给它加新东西,建议按这个顺序来。
第一件事,把Content-Type做全。现在不管什么文件都回text/html,浏览器拿到CSS和JS也会尝试当HTML解析,常常导致页面样式崩掉。做个简单后缀映射表,你就能正确服务CSS、JS、图片。
第二件事,支持HEAD请求。HEAD只要响应头,不要响应体。改动很小,但在一些健康检查场景里很实用。第三件事,做请求头里的Host校验,让服务器知道别人用哪个域名访问你,为以后做虚拟主机打基础。
6.2 哪些地方值得你停下来深入学习
做完这个项目,你会遇到一个新的认知分水岭:原来HTTP协议不神秘,socket也不神秘。但如果你想继续深入,我建议往几个方向走:去读一下epoll的事件驱动模型,看看Nginx为什么能处理上万并发;了解一下Connection: keep-alive对连接复用的优化;再尝试用非阻塞IO重写一遍服务器,对比性能差异。
这些进阶方向背后共同的主题,是“如何在资源有限的情况下服务更多人”。你现在这个fork版本,每来一个连接就fork一个进程,连接一多就扛不住,但正好能让你体会到多进程模型的瓶颈在哪里。带着真实痛点去学异步编程,效率比你空读一本《Unix网络编程》高得多。
我自己的经验是,项目往深处走时最容易碰到的不是C语言语法问题,而是“不知道下一步该做点什么”。解决的办法是:把你日常用到的网站功能逐个拆解,每拆出一个就想想“我能不能用这个服务器实现”,然后动手去试。比如上传文件功能,就需要你解析multipart/form-data;比如动态页面,就需要你引入CGI或者写一个简单的模板引擎。以你现在这个底座,加这些功能的过程就是一次非常扎实的系统训练。
最后再分享一个我从调试中悟出来的技巧:遇到诡异问题,先不要急着改代码,先用curl把请求和响应完整打出来,再配合浏览器开发者工具过滤一遍。很多时候,问题不在你的逻辑,而在于你对协议的理解和实现之间差了一个字段。你能亲手抓到自己服务器回给浏览器的原始报文,就说明你已经跨过“只会用框架”的门槛了。