这个项目算是 Linux 网络编程里一个特别经典的练手题:基于 TCP 的 Socket 编程,通过协议定制,实现一个网络计算器。我第一次完整跑通它的时候,最大感触不是“我会用 socket API 了”,而是终于搞明白了一条消息在 TCP 字节流里到底怎么切。这篇文章我会把需求怎么拆、协议怎么定、服务端客户端怎么写、排错怎么排,一条线完整讲下来,适合刚学完 socket 基础、准备用项目把知识点串起来的同学,也适合面试前想快速回顾网络编程核心要点的人。
1. 项目定位与通信模型拆解
任何一个网络项目,第一件事不是写代码,而是先把“谁和谁说话、说什么、说多久”定义清楚。这个项目表面上看是做一个计算器,实际上是在做一个典型的一问一答式 C/S 应用:客户端发一条“12+34”这样的表达式,服务端算完把“46”返回给客户端。只有把业务模型拆清楚,后面协议设计和坑位排查才不会乱。
1.1 为什么选 TCP:业务要的是可靠,不是快
刚开始学网络编程的人很容易纠结 TCP 和 UDP 怎么选。这个场景直接选 TCP,理由非常朴素:表达式和计算结果是核心数据,少一个字节、乱一个顺序,结果就错了。TCP 提供的可靠性、有序性、面向连接这三件事,正好命中这个业务的核心诉求。
TCP 在传输层做的事情,可以简单理解为“对方没收到就重传,收乱了就排序”。客户端发一个“12+34”,TCP 会保证服务端最终拿到的是完整且顺序正确的“12+34”,不丢也不乱。中间那个连接建立过程就是常说的三次握手:客户端调用 connect 时发起 SYN,服务端内核回 SYN+ACK,客户端再回一个 ACK,三次握手完成之后,双方才知道“你准备好了,我也准备好了”。反过来,当客户端或服务端调用 close 时,会触发四次挥手来完成连接释放。
这个项目里你不需要手动构造任何握手包,这些动作全部由内核协议栈完成。但你应该清楚:手指按下 connect 的那一刻,三次握手已经在路上了;程序里 close 之后,连接也不是立刻消失,它会在内核里经历一个 TIME_WAIT 状态。后面第 5 节我们会专门聊 TIME_WAIT 引发的“端口被占用”问题,那是无数初学者第一个真实踩到的网络故障。
1.2 业务拆解:客户端发表达式,服务端返回结果
网络计算器的业务逻辑本身极其简单。客户端从标准输入读一行,比如“12+34”,把它发送给服务端;服务端解析出两个操作数和运算符,算出结果后返回“46”。我把支持范围先限定在整数四则运算:加、减、乘、除。这样能避开一上来就做表达式求值、括号优先级这类复杂的解析问题,把注意力集中在网络部分。
这一版我对输入做了几个明确约束:表达式不含空格,比如直接用“12+34”,不要写“12 + 34”;操作数是整数,暂不支持小数和负数;除法是整数除法,如果除数为 0,服务端返回一个“div zero”的文本提示。这些约定不是偷懒,而是业务边界。真正开发一个系统的时候,第一步就是定义清楚“哪些情况是合法输入、哪些是非法的”,否则网络程序和计算逻辑会纠缠在一起,问题特别难排查。
除了正常计算,我还要求服务端能返回错误信息。比如客户端发一个“abc”过来,服务端解析失败,不能直接把进程搞崩,而是要返回“expr error”。这是网络程序的一个基本素养:你永远不能假设对端是靠谱的,请求数据首先要当成不可信数据来处理。
1.3 这个项目的难点不在计算,在“切数据”
很多人误以为这个项目的核心是写计算逻辑,实际上真正卡人的地方是数据处理。TCP 是流式协议,不是消息协议。你能保证字节不丢、不乱、不错,但你不能保证一次 write 对应一次 read。
举个例子。客户端连续发送两个表达式:“12+34”和“562”。理想情况下,服务端希望能分别读到这两条消息。但真实环境下,服务端可能一次 read 就拿到了“12+34562”这一整段数据。两个报文被 TCP 缓冲区和网卡的传输行为拼在了一起,这种现象就是常说的“粘包”。反过来,如果一条消息很大,网络比较慢,服务端一次 read 可能只读到消息的前半段,这叫“拆包”或者“半包”。
粘包和拆包不是 TCP 协议的 bug,而是它字节流特性的自然结果。问题出在业务上:双方没有约定好“一条消息从哪里开始、到哪里结束”。所以这个项目的真正核心是自定义协议,用协议在无限流动的字节流里画出一个个清晰的边界。
2. 协议定制:给字节流画边界
自定义协议就是要解决“消息边界”问题。协议的本质很简单:通信双方预先约定一种格式,发送方按照格式打包,接收方按照格式拆包。格式不约束内容,但明确告诉接收方数据怎么切分、怎么解析。
2.1 一个实用的协议样式:定长头 + 可变负载
文本类协议(比如 HTTP 用空行分隔头部和正文)直观、容易调试,但逐字节解析起来不够高效。二进制协议紧凑、解析快,但肉眼不好读。教学项目我推荐一个折中的方案:定长头部 + 可变负载。头部长 4 字节,存放一个长度字段,表示紧随其后的正文长度;正文放实际数据,在这个项目里就是表达式字符串或者结果字符串。
请求格式这样约定:
[4字节长度 len][len 字节的表达式文本]服务端收到数据后,分两步走:先读满 4 字节,拿到 len;再去读 len 字节,那才是完整的表达式。响应格式和请求格式完全一样,只是正文内容变成了计算结果。这个协议非常简单,但它已经具备了一个正式二进制协议的基本骨架——很多工业协议,包括一些工控领域的现场总线应用,都是类似的“定长头 + 负载”思路。
2.2 协议字段细节:类型、长度、字节序
用表格把协议字段说清楚:
| 字段 | 类型 | 长度 | 字节序 | 说明 |
|---|---|---|---|---|
| 请求头 len | uint32_t | 4 字节 | 网络字节序(大端) | 指请求体长度 |
| 请求体 | char[] | len 字节 | 无所谓 | 例如 “12+34” |
| 响应头 len | uint32_t | 4 字节 | 网络字节序(大端) | 指响应体长度 |
| 响应体 | char[] | len 字节 | 无所谓 | 例如 “46” 或 “div zero” |
这里最容易被忽略的是字节序。x86 机器是小端存储,而网络传输规定使用大端序,也就是“网络字节序”。所以在写入长度字段时,要用 htonl 把主机字节序转成网络字节序;读取时用 ntohl 转回来。如果不做这一层转换,在本机自测可能一切正常,一旦跨机器、跨架构通信,长度字段就会解析成完全错误的值,客户端和服务端会彻底失联。
正文是文本,不用担心字节序,但长度字段和未来可能出现的数值型负载,必须要显式转换。这是网络编程里一个“不做不出事、做了不出错”的规范动作。
2.3 长度字段为什么是协议防呆的关键
长度字段不仅是切分消息的标尺,还是防御非法数据的第一道门。服务端 read 拿到一个长度值之后,不能无脑按这个值去申请内存和读取数据。网络对端可能是恶意的,也可能因为 bug 发来一个异常长度,比如 0,或者 40 亿字节。如果不做校验就去分配内存,轻则程序崩溃,重则内存被耗尽。
所以我在代码里做了两个硬性限制:长度不能为 0,长度不能超过 4096。一旦收到非法长度,直接认为这条连接已经不可信,关闭连接。客户端响应也是同样的处理,响应长度超过 4096 就断开。这种“先校验再使用”的习惯,放到任何网络服务里都很重要,它会直接决定你的程序能不能在复杂环境里稳定存活。
3. Socket 编程核心流程与实战代码
协议定了,Socket 编程的核心框架反而简单了。Linux 下面用 C 语言做 Socket 编程,套路非常固定。
3.1 服务端和客户端各自的生命周期
服务端的生命周期可以概括为五步加一个循环:
- socket():创建监听套接字。
- bind():绑定 IP 和端口,把套接字固定到一个可访问的地址上。
- listen():开启监听,内核开始接受外部连接请求。
- accept():从已完成连接队列里取一个连接,得到一个专用于通信的新套接字 fd。
- 对 accept 返回的 fd 做 read/write 读写。
- close():通信结束关闭套接字。
客户端的流程更短:socket() 创建套接字,connect() 发起连接,连接建立后直接 read/write,最后 close()。connect 是主动发起三次握手的动作,accept 是服务端被动握手完成后的收获动作,两者在内核里自然配合。
3.2 服务端 server.c 核心实现
服务端我采用最简单的“每来一个连接,就创建一个线程去处理”的并发模型。这种模型不适合超大规模连接场景,但把网络主逻辑表现得非常清晰,适合学习。
关键代码可以这样写:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <pthread.h> #include <stdint.h> #define PORT 8888 #define MAX_MSG_LEN 4096 // 读满 n 字节,返回实际读到的字节数 static ssize_t read_full(int fd, void *buf, size_t n) { size_t already = 0; while (already < n) { ssize_t ret = read(fd, (char *)buf + already, n - already); if (ret == 0) { break; // 对端关闭 } if (ret < 0) { if (errno == EINTR) { continue; } return -1; } already += ret; } return already; } static ssize_t write_full(int fd, const void *buf, size_t n) { size_t already = 0; while (already < n) { ssize_t ret = write(fd, (const char *)buf + already, n - already); if (ret < 0) { if (errno == EINTR) { continue; } return -1; } already += ret; } return already; } static int handle_client(int client_fd) { while (1) { uint32_t len = 0; if (read_full(client_fd, &len, sizeof(len)) <= 0) { break; } len = ntohl(len); if (len == 0 || len > MAX_MSG_LEN) { break; } char expr[MAX_MSG_LEN + 1] = {0}; if (read_full(client_fd, expr, len) <= 0) { break; } expr[len] = '\0'; char result[64] = {0}; int a = 0, b = 0; char op = 0; if (sscanf(expr, "%d%c%d", &a, &op, &b) != 3) { snprintf(result, sizeof(result), "expr error"); } else { switch (op) { case '+': snprintf(result, sizeof(result), "%d", a + b); break; case '-': snprintf(result, sizeof(result), "%d", a - b); break; case '*': snprintf(result, sizeof(result), "%d", a * b); break; case '/': if (b == 0) { snprintf(result, sizeof(result), "div zero"); } else { snprintf(result, sizeof(result), "%d", a / b); } break; default: snprintf(result, sizeof(result), "unsupported op"); break; } } uint32_t resp_len = htonl((uint32_t)strlen(result)); write_full(client_fd, &resp_len, sizeof(resp_len)); write_full(client_fd, result, strlen(result)); } close(client_fd); return 0; }主函数里完成 socket、bind、listen 和 accept 循环:
static void *thread_main(void *arg) { int fd = (int)(intptr_t)arg; handle_client(fd); return NULL; } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } 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) { perror("bind"); return 1; } if (listen(listen_fd, 8) < 0) { perror("listen"); return 1; } printf("server listening on 0.0.0.0:%d\n", PORT); while (1) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len); if (client_fd < 0) { perror("accept"); continue; } printf("new client: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); pthread_t tid; pthread_create(&tid, NULL, thread_main, (void *)(intptr_t)client_fd); pthread_detach(tid); } close(listen_fd); return 0; }这里有个很容易忽略的细节:accept 返回的 fd 和监听 fd 是两回事。listen_fd 只负责监听,不能用来收发数据;accept 返回的 client_fd 才是专门为这次连接创建的套接字,它的缓冲区与客户端独立打通。每次连接都是一个独立的 fd,这也是 Linux“一切皆文件”思路的体现。
3.3 客户端 client.c 核心实现
客户端要简单很多,核心是发送一条表达式、读取一条响应。但发送和读取都要遵守协议:先写 4 字节长度,再写正文;先读 4 字节响应长度,再读响应体。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <stdint.h> #define PORT 8888 #define MAX_RESP_LEN 4096 static ssize_t read_full(int fd, void *buf, size_t n) { size_t already = 0; while (already < n) { ssize_t ret = read(fd, (char *)buf + already, n - already); if (ret == 0) { break; } if (ret < 0) { if (errno == EINTR) { continue; } return -1; } already += ret; } return already; } static ssize_t write_full(int fd, const void *buf, size_t n) { size_t already = 0; while (already < n) { ssize_t ret = write(fd, (const char *)buf + already, n - already); if (ret < 0) { if (errno == EINTR) { continue; } return -1; } already += ret; } return already; } int main(int argc, char *argv[]) { const char *server_ip = "127.0.0.1"; if (argc > 1) { server_ip = argv[1]; } int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { perror("socket"); return 1; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); inet_pton(AF_INET, server_ip, &addr.sin_addr); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); return 1; } printf("connected to %s:%d, example: 12+34, quit with q\n", server_ip, PORT); char line[256]; while (1) { printf("> "); fflush(stdout); if (!fgets(line, sizeof(line), stdin)) { break; } line[strcspn(line, "\n")] = '\0'; if (strcmp(line, "q") == 0 || strcmp(line, "quit") == 0) { break; } if (line[0] == '\0') { continue; } size_t len = strlen(line); uint32_t net_len = htonl((uint32_t)len); write_full(fd, &net_len, sizeof(net_len)); write_full(fd, line, len); uint32_t resp_len = 0; if (read_full(fd, &resp_len, sizeof(resp_len)) <= 0) { printf("connection closed by server\n"); break; } resp_len = ntohl(resp_len); if (resp_len == 0 || resp_len > MAX_RESP_LEN) { printf("invalid response length\n"); break; } char buf[MAX_RESP_LEN + 1] = {0}; if (read_full(fd, buf, resp_len) <= 0) { printf("connection closed by server\n"); break; } buf[resp_len] = '\0'; printf("= %s\n", buf); } close(fd); return 0; }客户端注意一点:读取响应时,也必须用和发送一致的协议来拆包。先读 4 字节长度字段,再根据长度读响应体。这体现了协议的对称性——发送方和接收方对格式的理解完全一致,才能顺利完成通信。
3.4 编译运行,看真实效果
编译命令分两个终端执行:
gcc -o server server.c -lpthread gcc -o client client.c ./server ./client 127.0.0.1启动服务端后,再运行客户端,输入几个表达式测试:
> 12+34 = 46 > -5*3 = expr error > 100/4 = 25 > 100/0 = div zero > q可以看到“-5*3”被判定为表达式错误,因为我们约定的表达式格式是“数字 运算符 数字”,默认不支持负数开头。如果你不提醒客户端注意格式,它就会得到“expr error”。这种行为是符合预期的:协议约束了表达式的表达范围,服务端守住边界,拒绝一切不满足协议格式的输入。
4. 可靠读写实现:read_full 是网络编程的地基
这个项目里我最想强调的不是 socket 创建,也不是 bind/listen,而是 read_full 和 write_full 这两个函数。可以说,看懂这两个函数,才算真正摸到网络编程的门槛。
4.1 为什么一次 recv 不等于一条消息
很多新手写 Socket 程序会这样写循环:
int n = read(fd, buf, sizeof(buf));然后期望 buf 里装的就是一条完整消息。这种想法在本地回环测试时常常成立,但在真实网络环境里非常危险。TCP 是字节流,内核的接收缓冲区会持续把到达的数据拼在一起。假设客户端发送了“hello”和“world”两条消息,服务端第一次 read 可能拿到“helloworld”,也可能只拿到“hel”,具体取决于 TCP 分片、Nagle 算法、网络延迟和调度时机。
没有协议边界时,你永远不知道 buf 里的数据到底是一条、半条,还是多条。这也是为什么我们必须在协议里放一个长度字段,并用“先读满长度字段,再读满正文”的方式收包。
4.2 read_full/write_full 的实现思路
read_full 的逻辑用一句话说就是:循环 read,直到读满要求的字节数。每次 read 返回后,把已读字节累加,调整指针位置和剩余长度,继续读下一轮。直到读取总数等于目标长度,或者对端关闭返回 0,或者出现错误返回 -1。
这个函数解决了“半包”问题:尽管 read 一次拿不完整条消息,循环多次后总能凑齐。它同时也配合协议解决了粘包问题的前半部分:服务端严格按照长度字段去读,不多读一个字节,剩下的数据留在内核缓冲区里等待下一次 read_full 来取。
write_full 同理。write 系统调用并不保证一次调用就把全部数据写出去,它可能只写了一部分。如果不循环,后续数据会丢。这里有一个常见的误解:TCP 是可靠传输,是不是 write 一次就全部送达了?答案是否定的。write 成功只代表数据拷贝到了内核发送缓冲区,不代表对端应用程序已经收到,更不保证一次调用处理完整个用户缓冲区。写大量数据时,必须循环 write。
4.3 read 返回值状态判断
read 返回值的语义值得背下来:
| 返回值 | 含义 | 处理方式 |
|---|---|---|
| 大于 0 | 读到 n 字节 | 累加长度,继续读取,直到读满 |
| 等于 0 | 对端关闭连接 | close 当前 fd,退出循环 |
| -1 且 errno == EINTR | 读取被信号打断 | 重新调用 read |
| -1 且 errno 为其他值 | 真实错误 | 按错误处理,一般关闭连接 |
EINTR 这个分支很容易被忽略。慢速系统调用(read、write、accept 等)在等待数据期间被信号打断时,会返回 -1 并且 errno 被设为 EINTR。如果程序里没有处理这个情况,read 就会误判为网络错误,直接中断一条本来还健康的连接。处理方式很简单:看到 EINTR 就重试。
阻塞模式下一般不会遇到 EAGAIN,非阻塞模式下一次 read 如果没数据会返回 -1 且 errno 为 EAGAIN,含义是“暂时没有数据,请稍后再试”,这不是错误,不应该关闭连接。本项目只探讨阻塞模式,所以代码里没有单独处理 EAGAIN。
5. 常见问题与排查技巧实录
下面这些坑,是我在实际跑这个项目时一个一个踩出来的。每一个都有真实的报错现场,查起来也都有规律可循。
5.1 bind 报 Address already in use,怎么办
服务端程序运行一段时间后,Ctrl+C 结束进程,立刻重新启动,经常听到这句报错:
bind: Address already in use第一次遇到时,我以为是被别的进程占了端口,用 netstat 查了半天,发现端口已经没被监听。真正的原因是 TIME_WAIT。主动关闭连接的一方会进入 TIME_WAIT 状态,默认等待 2MSL(约两分钟),在这段时间内,该连接的四元组(源 IP、源端口、目的 IP、目的端口)还不能立即复用。
服务端在 accept 后主动 close 某些连接时,可能就扮演了主动关闭方的角色,导致端口处于 TIME_WAIT。解决办法很标准:bind 之前设置 SO_REUSEADDR 套接字选项。上面代码里已经有这一行:
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));这个选项的意义就是允许本地端口复用在 TIME_WAIT 状态下的地址,让服务端可以快速重启。容器场景下常见的“端口暴露失败”报错,根因也往往和端口占用、地址不可复用有关,排查思路是一致的。
5.2 粘包现场与复现方法
初学者最担心的“粘包”,其实复现方法很简单。把客户端代码临时改成连续发送两条表达式,不读响应,中间只 sleep 一个很短的间隔。然后看服务端日志,你大概率会看到一次 read_full 读到的不仅是一条消息,而是一个拼接字符串。
粘包的本质不是 TCP 出了问题,而是业务没有给消息划定边界。只要使用了“长度字段 + 按长度读取”的收包方式,无论内核缓冲区里拼进来多少条消息,都能被一条一条准确切分出来。流程是这样的:第一次 read_full 先读 4 字节长度字段,得到第 1 条消息的 len;再按照这个 len 去读正文,刚好用光第 1 条消息的数据;随后读取第 2 条消息的长度字段。因为长度字段也是固定 4 字节,就算两条消息在缓冲区里紧紧挨着,也能通过固定步长找到下一条的起点。
5.3 recv 返回 0 和 -1,分别代表什么
如果客户端发完一条消息后直接按 Ctrl+D 结束输入,服务端这边的 read 返回 0。返回 0 意味着对端执行了 close,此时内核会通知我:这个连接结束了。程序必须在 read_full 返回 0 时主动 close 客户端 fd,退出处理线程,否则会造成 fd 泄漏。
还有一种情况是 read 返回 -1。如果 errno 是 EINTR,直接重试;如果 errno 是 ECONNRESET,说明对端异常关闭,连接被强制重置,服务器需要关闭这个 fd。处理网络错误时,先看一眼 errno 再决定是重试还是放弃,是最基本的排查习惯。
还有一个容易被忽略的问题:recv 到 0 字节表示对端已经优雅关闭,但如果你还在这个连接上继续调用 write,第一次可能成功(因为数据进入了缓冲),第二次就会收到 SIGPIPE 信号,默认动作是终止进程。所以写完后发现对方已经 close,程序可能悄无声息就没了。生产环境通常要忽略或处理 SIGPIPE,避免服务进程被一个普通断连干掉。
5.4 listen 的 backlog 参数到底在限制什么
listen(fd, 8) 里第二个参数 8 是 backlog,它限制的是“尚未被 accept 取走”的已完成连接队列长度。当队列满了,新到的连接会被内核直接拒绝,客户端 connect 会得到 Connection refused。
测试方法很直观:写一个脚本,不启动 accept 循环,只创建 socket、bind、listen,然后用客户端批量发起连接。超过 backlog 的那部分连接会失败。实际上 Linux 里还涉及半连接队列、全连接队列以及系统参数 somaxconn 的限制,这里不展开。你只需要记住:生产服务如果要扛大量连接,backlog 不能开得太小,但也不能超过内核上限,合理范围内尽量调大。
5.5 线程资源回收与断连清理
代码里用 pthread_create 给每个连接创建一个线程,并调用了 pthread_detach(tid)。detach 的意义是让线程结束后自动释放资源。如果不 detach,也不 pthread_join,线程结束后的资源不会被回收,每来一个连接就泄漏一点,最终整个进程内存不断上涨。
断连清理同样重要。无论 read_full 返回 0 还是出错,都要在一处统一 close(client_fd)。我写代码时习惯把“关闭 fd”放在函数出口,保证所有分支都执行 close。曾经见过另一个版本,在多个 break 分支里漏了 close,跑一晚上测试进程 fd 数量涨到几千个,最后系统报 Too many open files,这种问题查起来非常耗时间。
6. 从网络计算器到生产级服务,还差什么
这个项目跑通之后,它能给你的还不只是“我写过一个 socket 项目”的成就感。顺着它往下走,能延伸到不少真实系统关心的方向。这里分享几条最值得继续投入的路线,也是我个人在实践中体会最深的部分。
6.1 协议演进:从二进制结构到成熟序列化框架
当前的“4 字节长度 + 文本负载”已经能解决边界问题,但真实系统还会关心更多:如何表达复杂结构、如何做数据校验、如何向前向后兼容。你可以把协议体改成 JSON、XML 这类自描述文本,牺牲一点性能换取可读性和灵活性;也可以用 Protobuf、Thrift 这类跨语言序列化框架,在结构化和紧凑性之间取平衡。无论选哪种,长度头 + 负载的基本框架都不会消失,几乎所有带负载的业务协议都逃不开这条主干。
另一条值得加的是校验字段。比如在头里加上一个 magic number,用于快速识别协议格式;在负载后面加 CRC 或者哈希,用于数据完整性校验。面对不可信的传输环境,这些不是可有可无的花哨功能,而是基本的安全网。
6.2 服务端模型演进:从一连接一线程到事件驱动
当前每来一个连接就创建一个线程,在客户端数量少时很轻松,但连接数上千就会吃力,线程创建销毁开销大、上下文切换频繁。生产环境通常会选线程池、select/poll/epoll 多路复用模型。epoll 是 Linux 平台处理高并发连接的主流方案,它让一个线程同时管理成千上万个连接成为可能。
练习的方向可以这样定:先实现 epoll + 非阻塞 socket,再引入线程池处理计算密集任务。这个计算器业务本身计算量极小,但“网络读取 + 任务分发 + 线程池处理”的骨架一旦搭起来,你对接后续任何服务端框架都会轻松很多。
6.3 个人实操体验与建议
我个人的体会是,这类项目最有价值的不是把它跑通的那一下,而是之后故意把它改坏、再修好的过程。曾经我为了省事,用 sizeof(struct) 直接把结构体发过去,结果换了一台机器后,解析出来的全是乱码,那次之后我才真正把字节序问题记进肌肉记忆。还有一次我漏了检测 read 返回 0,写了个死循环把自己的服务器拖到 CPU 满负载,整个下午都在查为什么连接释放不了。
如果你也准备动手做这个项目,我的建议是严格按这个顺序推进:先定义协议,再实现收包,再写业务逻辑,最后再考虑并发模型。每一步做完都实际跑一遍测试。遇到卡点就回头检查 read_full 和 write_full,网络程序里大半问题最后都绕不开这两个函数。把它吃透,后面学 epoll、学 HTTP 协议、学各种 RPC 框架,都会顺畅很多。