ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux TCP Socket编程实战:自定义协议实现网络计算器

Linux TCP Socket编程实战:自定义协议实现网络计算器 这个项目算是 Linux 网络编程里一个特别经典的练手题基于 TCP 的 Socket 编程通过协议定制实现一个网络计算器。我第一次完整跑通它的时候最大感触不是“我会用 socket API 了”而是终于搞明白了一条消息在 TCP 字节流里到底怎么切。这篇文章我会把需求怎么拆、协议怎么定、服务端客户端怎么写、排错怎么排一条线完整讲下来适合刚学完 socket 基础、准备用项目把知识点串起来的同学也适合面试前想快速回顾网络编程核心要点的人。1. 项目定位与通信模型拆解任何一个网络项目第一件事不是写代码而是先把“谁和谁说话、说什么、说多久”定义清楚。这个项目表面上看是做一个计算器实际上是在做一个典型的一问一答式 C/S 应用客户端发一条“1234”这样的表达式服务端算完把“46”返回给客户端。只有把业务模型拆清楚后面协议设计和坑位排查才不会乱。1.1 为什么选 TCP业务要的是可靠不是快刚开始学网络编程的人很容易纠结 TCP 和 UDP 怎么选。这个场景直接选 TCP理由非常朴素表达式和计算结果是核心数据少一个字节、乱一个顺序结果就错了。TCP 提供的可靠性、有序性、面向连接这三件事正好命中这个业务的核心诉求。TCP 在传输层做的事情可以简单理解为“对方没收到就重传收乱了就排序”。客户端发一个“1234”TCP 会保证服务端最终拿到的是完整且顺序正确的“1234”不丢也不乱。中间那个连接建立过程就是常说的三次握手客户端调用 connect 时发起 SYN服务端内核回 SYNACK客户端再回一个 ACK三次握手完成之后双方才知道“你准备好了我也准备好了”。反过来当客户端或服务端调用 close 时会触发四次挥手来完成连接释放。这个项目里你不需要手动构造任何握手包这些动作全部由内核协议栈完成。但你应该清楚手指按下 connect 的那一刻三次握手已经在路上了程序里 close 之后连接也不是立刻消失它会在内核里经历一个 TIME_WAIT 状态。后面第 5 节我们会专门聊 TIME_WAIT 引发的“端口被占用”问题那是无数初学者第一个真实踩到的网络故障。1.2 业务拆解客户端发表达式服务端返回结果网络计算器的业务逻辑本身极其简单。客户端从标准输入读一行比如“1234”把它发送给服务端服务端解析出两个操作数和运算符算出结果后返回“46”。我把支持范围先限定在整数四则运算加、减、乘、除。这样能避开一上来就做表达式求值、括号优先级这类复杂的解析问题把注意力集中在网络部分。这一版我对输入做了几个明确约束表达式不含空格比如直接用“1234”不要写“12 34”操作数是整数暂不支持小数和负数除法是整数除法如果除数为 0服务端返回一个“div zero”的文本提示。这些约定不是偷懒而是业务边界。真正开发一个系统的时候第一步就是定义清楚“哪些情况是合法输入、哪些是非法的”否则网络程序和计算逻辑会纠缠在一起问题特别难排查。除了正常计算我还要求服务端能返回错误信息。比如客户端发一个“abc”过来服务端解析失败不能直接把进程搞崩而是要返回“expr error”。这是网络程序的一个基本素养你永远不能假设对端是靠谱的请求数据首先要当成不可信数据来处理。1.3 这个项目的难点不在计算在“切数据”很多人误以为这个项目的核心是写计算逻辑实际上真正卡人的地方是数据处理。TCP 是流式协议不是消息协议。你能保证字节不丢、不乱、不错但你不能保证一次 write 对应一次 read。举个例子。客户端连续发送两个表达式“1234”和“562”。理想情况下服务端希望能分别读到这两条消息。但真实环境下服务端可能一次 read 就拿到了“1234562”这一整段数据。两个报文被 TCP 缓冲区和网卡的传输行为拼在了一起这种现象就是常说的“粘包”。反过来如果一条消息很大网络比较慢服务端一次 read 可能只读到消息的前半段这叫“拆包”或者“半包”。粘包和拆包不是 TCP 协议的 bug而是它字节流特性的自然结果。问题出在业务上双方没有约定好“一条消息从哪里开始、到哪里结束”。所以这个项目的真正核心是自定义协议用协议在无限流动的字节流里画出一个个清晰的边界。2. 协议定制给字节流画边界自定义协议就是要解决“消息边界”问题。协议的本质很简单通信双方预先约定一种格式发送方按照格式打包接收方按照格式拆包。格式不约束内容但明确告诉接收方数据怎么切分、怎么解析。2.1 一个实用的协议样式定长头 可变负载文本类协议比如 HTTP 用空行分隔头部和正文直观、容易调试但逐字节解析起来不够高效。二进制协议紧凑、解析快但肉眼不好读。教学项目我推荐一个折中的方案定长头部 可变负载。头部长 4 字节存放一个长度字段表示紧随其后的正文长度正文放实际数据在这个项目里就是表达式字符串或者结果字符串。请求格式这样约定[4字节长度 len][len 字节的表达式文本]服务端收到数据后分两步走先读满 4 字节拿到 len再去读 len 字节那才是完整的表达式。响应格式和请求格式完全一样只是正文内容变成了计算结果。这个协议非常简单但它已经具备了一个正式二进制协议的基本骨架——很多工业协议包括一些工控领域的现场总线应用都是类似的“定长头 负载”思路。2.2 协议字段细节类型、长度、字节序用表格把协议字段说清楚字段类型长度字节序说明请求头 lenuint32_t4 字节网络字节序大端指请求体长度请求体char[]len 字节无所谓例如 “1234”响应头 lenuint32_t4 字节网络字节序大端指响应体长度响应体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: 1234, 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启动服务端后再运行客户端输入几个表达式测试 1234 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怎么办服务端程序运行一段时间后CtrlC 结束进程立刻重新启动经常听到这句报错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分别代表什么如果客户端发完一条消息后直接按 CtrlD 结束输入服务端这边的 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 框架都会顺畅很多。
返回列表