搞Linux下C/C++网络编程的人,迟早都会碰到这个经典需求:服务端程序监听某个端口,客户端连上来后双方互相收发数据。这个场景抽象成一句话就是“在Linux上用TCP协议实现客户端/服务端网络通信”,但真正动手时会发现里面藏着socket编程最核心的一整套API调用流程。从创建套接字到bind、listen、accept,再到connect、send、recv,每一步背后都有对应的内核行为和状态变化,把这套流程吃透了,后面再去看多线程并发、epoll、异步网络库都会轻松很多。
这篇文章我按自己实际撸代码的顺序来写,会从最关键的基础概念讲起,然后分别给出完整的服务端和客户端C语言实现,附上编译命令、运行方法和联调测试全过程,最后专门整理我在真实环境中反复踩过的坑。无论你是刚接触Linux网络编程的新手,还是想快速回顾核心API的开发者,照着这个流程走一遍,至少能把“两端怎么连起来、数据怎么传过去”这件事彻底弄明白。
1. 项目概述与前置知识盘点
1.1 这个项目要解决什么问题
很多初学者学网络编程时,喜欢直接背代码,背完能跑就觉得会了。但一旦环境变了、端口冲突了、客户端连不上了,就完全不知道从哪里排查。这个项目存在的意义,就是通过一个最小可用的完整demo,把TCP通信最核心的调用链彻底讲清楚,让读者不只是会敲代码,还能理解每一步为什么这么写。
本项目的核心目标是实现一个回显服务:客户端在终端输入什么内容,服务端收到后原样返回。虽然功能简单,但它覆盖了TCP编程中绝大部分关键环节:套接字创建、地址绑定、监听、接受连接、数据收发、连接关闭判断、资源释放。也就是说,你把这个demo里的每个函数、每个返回值都搞懂,就已经掌握了几乎所有同步TCP网络程序的基本骨架。
1.2 TCP协议通信核心概念速览
TCP(Transmission Control Protocol)是面向连接的、可靠的、基于字节流的传输层协议。“面向连接”意味着通信双方在正式收发数据之前,必须先通过三次握手建立一条逻辑链路;“可靠”意味着数据不丢失、不重复、按序到达;“字节流”意味着它不关心消息边界,只把数据看作一串连续的字节,应用层在解析时自己负责切分消息。
用生活化的类比来理解:TCP就像打电话。先拨号(connect),对方接听(服务端正常accept),双方确认接通后开始说话(send/recv),最后任何一方挂断(close),链路断开。而UDP更像是发短信,写完地址直接扔到邮筒里,对方能不能收到、什么时候收到,发件人并不了解。
对于初学者,我建议先把几个状态与API的对应关系建立起来:未连接到已连接靠connect配合内核完成三次握手;数据传输阶段靠send和recv收发字节流;断开连接靠close发送FIN触发四次挥手。这些状态在操作系统的网络栈中都有对应数据结构,初学阶段不用深入内核细节,但用户态的API行为一定要对上号。
1.3 开发环境与工具准备
推荐环境是任意主流的Linux发行版,本文示例在 Ubuntu 22.04 和 CentOS 7 上都能直接编译运行。核心工具只需要gcc和make,没有的话一条命令装上即可:
# Debian/Ubuntu 系列 sudo apt update && sudo apt install -y gcc make # RHEL/CentOS 系列 sudo yum install -y gcc make如果没有Linux物理机,用虚拟机或者云主机都行。为了测试跨主机通信,最好准备两台机器;如果只有一台机器,在同一台机器上开两个终端,通过127.0.0.1环回地址测试也完全可以。环回地址在本地测试时基本模拟了真实网络通信的完整过程,只是数据在内核内部完成转发,没有真正经过网卡和物理链路,对学习API流程没有任何影响。
2. 核心API与设计思路拆解
2.1 socket编程的五个核心函数一次讲透
socket编程的服务端和客户端,本质上用的是同一套API家族,只是调用顺序不同。先把这张函数调用地图刻进脑子里,后面理解任何代码都不会跑偏:
- 服务端调用链:socket() -> bind() -> listen() -> accept() -> recv()/send() -> close()
- 客户端调用链:socket() -> connect() -> send()/recv() -> close()
每个函数的具体职责,我用一个表来总结,方便对照记忆:
| 函数 | 服务端 | 客户端 | 核心作用 | 常见失败原因 |
|---|---|---|---|---|
| socket | 必调 | 必调 | 创建套接字描述符 | 文件描述符耗尽 |
| bind | 必调 | 非必须 | 绑定本地地址与端口 | 端口被占用 |
| listen | 必调 | 不调 | 进入监听状态,内核维护连接队列 | 未先绑定地址 |
| accept | 必调 | 不调 | 从已完成握手队列取出连接 | 无新连接到达 |
| connect | 不调 | 必调 | 主动发起三次握手 | 目标端口拒绝连接 |
| send/recv | 必调 | 必调 | 在已建立连接上收发数据 | 对端已关闭 |
socket(int domain, int type, int protocol)是最底层的入口,负责创建一个套接字描述符。它可以类比为申请一条电话线,参数分别是协议族(IPv4用AF_INET)、套接字类型(流式TCP用SOCK_STREAM)和具体协议(TCP直接填0,让内核根据前两个参数自动推断)。bind是把地址和端口绑定到套接字上,服务端必须调用,否则客户端不知道去访问哪个端口;客户端一般不显式调用,由内核在connect时自动分配临时端口。listen宣告套接字进入监听状态,内核会在该端口上维护连接队列,backlog参数表示已完成连接队列的最大长度。accept从队列中取出一个已完成握手的连接,返回一个全新的已连接套接字描述符,后续收发数据都走这个新描述符,原来的监听描述符仍继续接受新连接。connect在客户端发起三次握手,调用成功就代表逻辑链路已建立。
2.2 地址结构体与字节序转换:容易忽略但必须吃透
socket接口接收的地址参数类型是struct sockaddr *,实际使用时绝大多数情况是struct sockaddr_in(IPv4专用)。这个结构体里有三个关键字段需要留意:sin_family协议族固定填AF_INET,sin_addr.s_addr存放32位IP地址,sin_port存放16位端口号。后两个字段在网络编程中有一个容易踩坑的细节:它们必须使用网络字节序。
网络字节序是大端模式,而x86等主流CPU在小端模式下工作,内存中整数的高低字节存放顺序正好相反。所以给sin_port赋值时会写htons(SERVER_PORT),htons就是“Host TO Network Short”,把主机字节序的端口号转换到网络字节序。同理,取出端口时用ntohs转回主机字节序。IP地址字段长度是32位,用htonl/ntohl处理,例如htonl(INADDR_ANY)表示监听本机所有网卡的IP地址000.0.0.0。
还有一个常见的坑:很多人在客户端填了127.0.0.1,connect仍然失败,排查半天发现是inet_pton没有正确转换IP。推荐统一使用inet_pton(AF_INET, ip字符串, &server_addr.sin_addr),不要在旧代码里使用已经废弃的inet_addr,后者没有错误判断能力。inet_pton的返回值大于0表示转换成功,等于0表示IP字符串格式不合法,小于0表示协议族不支持,这三个结果都要显式处理。
2.3 为什么服务端必须bind+listen+accept三步走
服务端的三个函数,初学者最容易混淆。我建议继续用“电话接听”的场景来理解:bind相当于把电话号码登记在册,让别人找得到你;listen相当于摘机待命,内核开始记录打进来的电话;accept相当于接通一个来电并拿起听筒,接通后你手里就多了一个独立的通话通道。
从内核角度看,一个端口上的监听套接字维护着半连接队列和全连接队列。客户端发送SYN后进入半连接队列,内核完成三次握手后把连接移入全连接队列,而accept只是从全连接队列里取出一个已完成握手的连接,并分配新的描述符。也就是说,accept不会主动去和客户端握手,握手在客户端connect配合内核协议栈时已经完成,accept只是“取货”。
这也解释了一个令很多人困惑的问题:为什么accept的后两个参数不填目标端口和IP?因为建立连接所需的地址信息在握手阶段已经协商完成,accept只是把结果通过参数回填出来,方便服务端打印客户端来源或者做访问控制。回填的信息包括客户端IP、客户端临时端口,这些正是排查问题时的关键线索。
3. 服务端程序完整实现
3.1 创建套接字:socket函数的三个参数怎么填
先给出这一步的完整代码,文件保存为server.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h> #define SERVER_PORT 8888 #define BUFFER_SIZE 1024 int main(void) { int listen_fd, conn_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; ssize_t n; int opt = 1; // 1. 创建TCP套接字 listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 允许端口复用,避免TIME_WAIT状态下重启失败 if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { perror("setsockopt"); close(listen_fd); exit(EXIT_FAILURE); } // 3. 初始化服务器地址结构 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(SERVER_PORT); // 4. 绑定地址与端口 if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } // 5. 进入监听状态,backlog为已完成连接队列长度 if (listen(listen_fd, 5) < 0) { perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } printf("服务端已启动,监听端口: %d\n", SERVER_PORT); // 6. 接受客户端连接 conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len); if (conn_fd < 0) { perror("accept"); close(listen_fd); exit(EXIT_FAILURE); } printf("客户端接入: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); // 7. 循环收发数据,实现一个最简单的回显服务 while (1) { memset(buffer, 0, sizeof(buffer)); n = recv(conn_fd, buffer, sizeof(buffer) - 1, 0); if (n <= 0) { printf("客户端断开连接\n"); break; } printf("客户端说: %s", buffer); if (strncmp(buffer, "quit", 4) == 0) { printf("收到退出指令,关闭连接\n"); break; } if (send(conn_fd, buffer, n, 0) < 0) { perror("send"); break; } } close(conn_fd); close(listen_fd); return 0; }socket函数本身很简单,但开发时要搞清楚三个问题。第一个参数为什么是AF_INET而不是AF_INET6?AF_INET对应IPv4,AF_INET6对应IPv6,两者用到的地址结构体、地址长度都不一样。本文按IPv4展开,实际项目中还要考虑双栈支持。第二个参数SOCK_STREAM表示流式套接字,TCP协议对应的就是它;SOCK_DGRAM对应UDP;SOCK_RAW是裸网络层套接字,普通应用碰不到。第三个协议参数直接传0,内核会推断为TCP,这个写法最稳妥。返回值是一个非负整数描述符,注意它和文件描述符共用同一套资源池,理解这点对排查“描述符耗尽”问题很有帮助。
3.2 绑定端口:bind函数与SO_REUSEADDR的前置处理
服务端必须先绑定地址再监听,因为内核需要知道把发往哪个端口的数据分发给哪个套接字。这一步代码的核心就是初始化struct sockaddr_in并调用bind,但有三处细节我特意强调。
第一,memset清零是必须的。struct sockaddr_in里除了重要字段外还有填充字节,不清零可能导致bind行为不确定,甚至在不同编译器下出现诡异差异。这段代码里我先memset整个结构体,再把字段逐个赋值,是最稳妥的顺序。
第二,INADDR_ANY表示监听本机所有网卡的连接请求。如果这里指定某个具体IP,服务端就只接受发往该IP的请求,其他网卡上的连接一律不响应。对内网穿透、多网卡服务器这些场景来说,INADDR_ANY通常是最省心的选择。对应字段可以写成htonl(INADDR_ANY),也可以直接写成INADDR_ANY再让系统转换,但显式htonl更清楚。
第三,SO_REUSEADDR就是那个“老手都知道”的选项。当服务端程序异常退出或者主动关闭连接后,端口可能停留在TIME_WAIT状态,立即重启会报Address already in use,导致bind失败。设置SO_REUSEADDR允许内核在端口处于TIME_WAIT时直接复用地址端口。我在开发阶段遇到过好多次,加了这行之后再也没为频繁重启发过愁。教学代码经常忽略它,导致新手照着教程写完第二天启动就报错,这里提前打上补丁。
3.3 监听与接受连接:listen和accept的分工逻辑
listen这一步只需要两行代码,但backlog参数的含义值得展开。backlog传5表示全连接队列最多容纳5个已完成握手的连接。如果队列满了,后续connect会阻塞或失败,由内核的SYN cookie机制决定具体行为。这个数字不是越大越好,过大会让客户端长时间等待而服务端没及时accept,反而掩盖了服务端处理能力不足的问题。生产环境会结合并发模型调优,本文学习场景取5足够了。
accept这一步同样只有几行,但返回值的意义必须理解透。accept返回一个全新的描述符conn_fd,之后所有针对这个客户端的数据收发都走conn_fd,而listen_fd继续用于监听后续连接。也就是说,一个套接字监听,一个套接字通信,职责完全不同。很多新手误以为accept返回的还是原来那个描述符,于是往listen_fd上收发数据,结果行为完全不符合预期。
代码里我打印了inet_ntoa(client_addr.sin_addr)和ntohs(client_addr.sin_port),前者把32位IP转回点分十进制字符串,后者把网络字节序端口转回主机字节序。这行日志不是可有可无的装饰,联调时能立刻确认客户端是否真的接入,以及接入来源是哪里。
3.4 数据收发循环与完整服务端代码
连接建立后进入数据收发循环。我选择实现回显服务,原因很简单:收到什么就原样返回什么,能最直观地验证链路是否双向畅通。循环体内有几个必须掌握的细节。
recv的返回值是本次实际收到的字节数。返回0表示对端已经关闭连接,返回-1表示出错,所以统一用n <= 0作为退出循环的条件。这是网络编程里最容易忽略但绝对核心的判据,很多线上事故都是因为没检查recv返回值,导致对端断开后程序还在循环里空转。buffer在recv前先清零,能防止上一次残留数据混淆本次内容。recv的第四个参数flags通常传0即可,特殊用途才涉及MSG_WAITALL这类标志。
send的返回值是实际发送的字节数,可能小于传入长度,严谨做法是循环发送直到全部发送完毕。本demo为了方便直接发一次,但至少检查了是否返回-1,否则对端断开后send会触发SIGPIPE信号,默认情况下直接把进程干掉,新手往往一头雾水。
strncmp(buffer, "quit", 4) == 0用于判断客户端输入的前4字符是否为quit。这里特意用strncmp而不是strcmp,因为fgets会保留换行符,buffer实际内容是"quit\n",strncmp比较前4个字符恰好匹配;如果用strcmp会因为多一个换行符而匹配失败。这种小地方就是“看起来能跑但实际有坑”的典型。
完整代码已经在上文给出。最后close(conn_fd)和close(listen_fd)分别关闭通信描述符和监听描述符。很多人写服务端时只记得close通信描述符,忘掉监听描述符,长跑服务最终会因文件描述符耗尽而崩溃,这也是一个隐蔽的坑。
4. 客户端程序完整实现
4.1 客户端主流程:socket与connect连接服务器
客户端的socket创建与服务端几乎一样,区别在于它不需要bind和listen。客户端不关心自己用哪个端口,内核会在connect时自动分配一个临时端口,这个端口可以从accept回填的client_addr里看到。客户端更关键的是connect这一步,它会触发三次握手,在默认阻塞模式下会一直等到握手完成或者超时失败。完整代码保存为client.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h> #define SERVER_PORT 8888 #define BUFFER_SIZE 1024 int main(int argc, char *argv[]) { if (argc != 2) { fprintf(stderr, "用法: %s <服务器IP>\n", argv[0]); exit(EXIT_FAILURE); } int sock_fd; struct sockaddr_in server_addr; char buffer[BUFFER_SIZE]; ssize_t n; // 1. 创建TCP套接字 sock_fd = socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 初始化服务器地址,注意字节序转换 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(SERVER_PORT); if (inet_pton(AF_INET, argv[1], &server_addr.sin_addr) <= 0) { fprintf(stderr, "无效的IP地址: %s\n", argv[1]); close(sock_fd); exit(EXIT_FAILURE); } // 3. 发起连接,触发三次握手 if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("connect"); close(sock_fd); exit(EXIT_FAILURE); } printf("已连接服务器: %s:%d\n", argv[1], SERVER_PORT); // 4. 循环收发数据 while (1) { printf("> "); fflush(stdout); if (fgets(buffer, sizeof(buffer), stdin) == NULL) { break; } if (send(sock_fd, buffer, strlen(buffer), 0) < 0) { perror("send"); break; } if (strncmp(buffer, "quit", 4) == 0) { break; } memset(buffer, 0, sizeof(buffer)); n = recv(sock_fd, buffer, sizeof(buffer) - 1, 0); if (n <= 0) { printf("服务器断开连接\n"); break; } printf("回显: %s", buffer); } close(sock_fd); return 0; }connect的常见失败原因有两个。服务器没启动时,对方会直接回RST包,客户端立即收到Connection refused;网络不可达时,通常是ETIMEDOUT超时,这时要先检查网络路由和防火墙。另外我在客户端用命令行参数argv[1]作为服务器IP,而不是写死127.0.0.1。学习阶段大多数人在本机测试,写死IP也能跑,但这样的代码一旦拿到两台机器上就废了,参数化是更通用的做法。
4.2 客户端收发数据与完整代码
客户端的收发逻辑和服务端对称,区别在交互方向。客户端先从标准输入读取一行,发给服务端,然后等待回显。这个一收一发的模式恰好演示了TCP上最简单的请求响应模型。
代码里我特意加了fflush(stdout),目的是让“> ”提示符立即显示出来。如果不加,由于stdout默认是行缓冲,交互式输入时提示符可能不会及时刷新到屏幕上,新手容易误以为程序卡死了。这类小细节决定实际使用体验,也是常规教程很少提到的。
fgets读取一行后会保留末尾换行符,send发送的长度是strlen(buffer),包含换行符。服务端那边接收到后直接printf("%s", buffer),显示时自带换行,体验很自然。客户端在发送quit后直接break退出,不等待服务端回显;服务端收到quit后同样break并关闭连接。两边都把退出逻辑放在发送完数据、收到回显之前,这样双方都能干净退出,不会因等待回显而卡死。
需要注意的另一个细节是:服务端收到quit后没有走send回显,直接break退出,所以客户端在quit后也不会进入recv等待。如果将来扩展功能,比如服务端要先回一个“bye”再关闭,那么客户端quit后的逻辑就要相应调整,这种分工协作正是应用层协议设计的一部分。
5. 编译运行与联调测试
5.1 两条编译命令与常见编译错误
服务端和客户端是两个独立程序,分别编译成两个可执行文件:
gcc server.c -o server gcc client.c -o client如果gcc报“storage size of 'server_addr' isn't known”这类错误,百分之百是头文件没引全。struct sockaddr_in、htons等定义都在netinet/in.h里,inet_pton在arpa/inet.h里。我习惯把下面四个头文件一次性全部引入,省去排查依赖的麻烦:
#include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h>编译完成后可以用ls -l确认两个可执行文件生成成功。还有一个验证代码质量的小技巧:编译时加上-Wall让编译器把潜在问题都提示出来。首次编译能零警告通过,说明代码基本功很扎实。如果在调试阶段频繁改代码,可以直接把两个编译命令写在Makefile里,后续只需要执行make,省时间。
5.2 本地回环测试完整过程
打开两个终端。终端A运行服务端:
./server正常输出:
服务端已启动,监听端口: 8888终端B运行客户端:
./client 127.0.0.1正常输出:
已连接服务器: 127.0.0.1:8888 >服务端同时打印出客户端接入信息:
客户端接入: 127.0.0.1:52431这里的52431是内核给客户端临时分配的源端口,每次运行都可能不同,属于正常现象。然后我在客户端输入hello,完整交互过程如下:
> hello 回显: hello > world 回显: world > quit服务端这边同步打印:
客户端说: hello 客户端说: world 客户端说: quit 收到退出指令,关闭连接 客户端断开连接这个结果说明两端收发链路完全打通,退出逻辑也符合预期。我实际测试时还会开第三个终端,用ss命令确认端口监听和连接状态:
ss -ant | grep 8888可以看到LISTEN状态的8888端口,以及ESTAB状态的连接记录。这是排查网络问题时最实用的基本功,比盲目猜测靠谱得多。如果系统里有netstat,效果类似,但新版Linux推荐优先使用ss。
5.3 跨主机通信时的配置要点
如果两台机器在同一局域网内,客户端传服务器的内网IP即可,代码不用改。但有三个前置条件容易被忽略。第一,两台机器必须网络互通,先ping目标IP,ping不通就谈不上TCP通信。第二,服务端防火墙要放行TCP 8888端口,云服务器还要在安全组里放行入方向规则。第三,服务端监听的是INADDR_ANY,也就是所有网卡,客户端通过任意IP都能连上;如果bind时写死了某网卡IP,只有访问那个IP才能连上服务端。
关于防火墙,我习惯这么测:先临时关闭防火墙测试程序能不能通,通了再打开防火墙并配置放行规则。这样能快速判断问题出在程序还是出在防火墙,测试完毕后立刻恢复防火墙配置。云服务器尤其要注意,iptables防火墙和云平台安全组是两层过滤,都要放行对应端口。很多时候代码一点问题都没有,就是安全组没开端口,连不上时先把这两层都查一遍。
6. 常见问题与排查经验
6.1 bind报“Address already in use”:TIME_WAIT与端口复用
最典型的场景:服务端刚Ctrl+C退出,立刻重启,bind就报错。原因是主动关闭连接的一方会进入TIME_WAIT状态,持续约2MSL,也就是几十秒到几分钟不等。虽然这个demo里服务端正常退出时流程简单,但TCP四次挥手的最后一步总会留下TIME_WAIT记录。
排查方法很简单:
ss -ant | grep 8888如果看到TIME-WAIT状态的记录,原因就确定了。解决方案有两个方向:在代码里提前设置SO_REUSEADDR,也就是服务端代码中的setsockopt那一行;或者等TIME_WAIT超时后再重启。我在开发阶段频繁改代码重启,加了SO_REUSEADDR之后就没再为这个问题发过愁。SO_REUSEADDR不会缩短TIME_WAIT的时间,但允许新套接字绑定到同一个地址端口,足以避开开发期最常见的重启冲突。
6.2 connect报“Connection refused”:先查监听,再查防火墙
这个问题十个人里九个人会碰到。Connection refused说明客户端发出的SYN收到了RST响应,常见原因排序如下:
- 服务端程序没启动,端口根本没有监听;
- IP或端口写错了,连到了没有服务的端口;
- 防火墙直接返回RST,有些安全策略会这样配置。
排查顺序建议是:先ps确认服务端进程是否存在,再ss确认端口处于LISTEN状态,最后检查客户端传入的IP和端口是否与服务端一致。如果服务端和客户端在同一台机器上却依然refused,多半是服务端根本没起来,或者两个文件里的SERVER_PORT定义不一致。
还有一种隐蔽情况:服务端程序启动时bind了8888,但客户端传了8889,看起来像是连接被拒绝,其实是端口不匹配。这种低级错误靠打印日志就能发现,所以客户端代码里把连接目标IP和端口都打出来了,联调时务必养成看日志的习惯。
6.3 recv返回0和-1:如何正确判断连接关闭
网络编程里判断连接是否关闭,几乎是所有逻辑的前提。recv返回值必须吃透:
- 返回大于0:收到数据,返回值就是字节数;
- 返回0:对端主动关闭,比如调用close或者进程正常退出,内核发送了FIN;
- 返回-1:出错,需要进一步判断errno,常见的有EINTR被信号中断、EAGAIN非阻塞模式下暂时无数据、ECONNRESET对端异常关闭。
很多人的第一版代码只关心收到的内容,不检查返回值,客户端断开后服务端还在循环里傻等,要么死循环,要么读到一串垃圾数据。严谨的写法就是本文代码里n <= 0退出循环,这是最基本但非常重要的网络编程素养。
另外关于SIGPIPE信号,我再补充一个坑。如果对一个已经关闭的连接继续send,默认行为是触发SIGPIPE,进程会直接退出。很多服务端不明不白地挂了,日志里什么都没有,最后发现是SIGPIPE作祟。生产级代码通常会调用signal(SIGPIPE, SIG_IGN)忽略这个信号,然后靠send的EPIPE错误码感知连接异常。本demo不涉及高并发,但提出来是想让大家知道这里有坑。
6.4 粘包与拆包:TCP字节流特性带来的经典问题
TCP是字节流协议,不保证一次send对应一次recv。客户端连续发送三个消息,服务端可能一次recv就收到全部内容,也可能三次recv才分别收到一部分,这就是粘包和拆包。很多新手第一次遇到时百思不得其解,以为是代码bug,其实是TCP的固有特性。
解决思路都围绕“应用层协议”展开,本质是约定消息边界。常见做法有四种:固定长度,每条消息长度相同,不够用补位填充;特殊分隔符,比如用\r\n分割消息,HTTP协议就是这种思路;长度前缀,每条消息前追加4字节的消息长度;或者直接用序列化框架如protobuf统一处理封装。
本文的回显demo其实也有潜在粘包问题,只是因为服务端收到什么就回什么,客户端也是send一次就recv一次,数据量小的时候问题不暴露。一旦数据量大或者并发上来,就会真实遇到。我的建议是学完基础流程后,把“长度前缀+循环拆包”的改造当作练习,能顺手写出来,才算真正理解了字节流协议。这个练习会逼着你重新审视recv的循环调用和对缓冲区的管理,能力提升非常明显。
说实话,把这一整套流程跑通,只是网络编程万里长征的第一步。但反过来说,后面所有进阶内容,多线程并发、IO多路复用、异步网络库、协议设计,都是在这套基础API上搭起来的。我见过不少同学demo能跑就急着去学高深框架,结果遇到线上问题连基本的状态变化都分析不清楚。我的建议是先把每个函数的返回值想清楚,把每一行代码背后的内核行为琢磨明白,再谈优化和扩展,这条路会顺得多。最后再说个小技巧:把这个回显demo的端口改成自己不常用的高端口跑一遍,再试试两台真实机器之间的通信,你收获的细节会比单纯看本文多得多。