文章目录一次串行 TCP 通信的实验复盘从代码到运行亲手验证同步交互模型1. 实验目的2. 架构与设计思路2.1 服务端单进程串行模型2.2 客户端串行短连接模型3. 完整代码实现3.1 服务端代码 (server.cpp)3.2 客户端代码 (client.cpp)4. 运行实录与现象观察4.1 服务端输出4.2 客户端输出5. 深度解析现象背后的底层逻辑 现象 1客户端的 fd 为什么总是 3 现象 2客户端的源端口为什么每次都不一样 现象 3SO_REUSEADDR 到底在保护什么6. 进阶思考从串行走向并发7. 结语一次串行 TCP 通信的实验复盘从代码到运行亲手验证同步交互模型导语在深入 epoll、多线程、高并发之前我们必须先弄懂最基础的 TCP 同步交互模型。本文通过一次极简的 C 串行通信实验带你从代码设计、运行实录到底层原理彻底打通 TCP Socket 编程的“任督二脉”。1. 实验目的在复杂的网络框架如 Netty、Reactor 模型中底层的连接建立与数据流转往往被封装得很深。为了回归本质本次实验用最纯粹的 C 系统调用实现一个串行 TCP 服务端与一个串行 TCP 客户端旨在验证以下核心机制服务端的串行处理通过while(1) accept()循环观察服务端如何依次处理多个客户端连接。客户端的串行请求通过for循环依次建立、通信、断开模拟多个独立的短连接请求。TCP 状态机的自然节奏在代码的运行轨迹中映射三次握手、数据收发、四次挥手的完整生命周期。2. 架构与设计思路2.1 服务端单进程串行模型服务端采用最经典的“主循环 阻塞处理”架构初始化socket()创建监听套接字→ \rightarrow→setsockopt(SO_REUSEADDR)允许端口快速重用→ \rightarrow→bind()绑定0.0.0.0:8888→ \rightarrow→listen()设置 backlog 队列。主循环外层while(1)阻塞调用accept()每次从内核的已完成连接队列中取走一个连接。业务处理进入do_connect()处理当前连接打印信息→ \rightarrow→read()接收→ \rightarrow→write()回复→ \rightarrow→close()断开。循环往复处理完毕后回到accept()等待下一个客户端。核心特征服务端在同一时刻只能处理一个客户端。如果此时有新客户端连接它们会在内核的 backlog 队列中排队等待如果队列满了新的connect()将被拒绝或超时。2.2 客户端串行短连接模型客户端采用“创建→ \rightarrow→通信→ \rightarrow→销毁”的循环架构主循环外层for循环控制请求次数如 3 次。单次请求socket()创建新套接字→ \rightarrow→connect()发起三次握手→ \rightarrow→write()发送数据→ \rightarrow→read()阻塞等待回复→ \rightarrow→close()发起四次挥手。节奏控制每次循环末尾sleep(1)给服务端留出回到accept()状态的时间避免连接堆积。核心特征每次循环都重新创建和销毁套接字属于典型的短连接模型完美匹配服务端的串行处理能力不会产生并发冲突。3. 完整代码实现3.1 服务端代码 (server.cpp)#includearpa/inet.h#includecstring#includeiostream#includesys/socket.h#includeunistd.husingnamespacestd;// 处理单个客户端连接的函数voiddo_connect(intcfd,constsockaddr_inclient_addr);intmain(){// 1. 创建 TCP 监听套接字intlfdsocket(AF_INET,SOCK_STREAM,0);if(lfd-1){cerrsocket() failedendl;return-1;}// 2. 设置端口复用 (核心防止重启时 TIME_WAIT 导致 bind 失败)intopt1;if(setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt))-1){cerrsetsockopt() failedendl;close(lfd);return-1;}// 3. 绑定地址和端口sockaddr_in addr{};addr.sin_familyAF_INET;addr.sin_addr.s_addrhtonl(INADDR_ANY);// 监听所有网卡addr.sin_porthtons(8888);if(bind(lfd,(sockaddr*)addr,sizeof(addr))-1){cerrbind() failed (port 8888 may be in use)endl;close(lfd);return-1;}// 4. 开始监听设置 backlog 为 5if(listen(lfd,5)-1){cerrlisten() failedendl;close(lfd);return-1;}coutServer listening on port 8888...endl;// 5. 主循环持续接受客户端连接sockaddr_in client_addr{};socklen_t lensizeof(client_addr);while(true){// accept 会阻塞直到有新的连接进入已完成队列intcfdaccept(lfd,(sockaddr*)client_addr,len);if(cfd-1){cerraccept() failedendl;continue;}// 处理当前连接 (串行阻塞处理完才会 accept 下一个)do_connect(cfd,client_addr);}close(lfd);return0;}// 处理客户端连接的具体逻辑voiddo_connect(intcfd,constsockaddr_inclient_addr){// 解析并打印客户端 IP 和端口charip_str[INET_ADDRSTRLEN];inet_ntop(AF_INET,client_addr.sin_addr,ip_str,sizeof(ip_str));cout[] Client connected from ip_str:ntohs(client_addr.sin_port)endl;// 读取客户端消息charbuf[64]{};ssize_t nread(cfd,buf,sizeof(buf)-1);if(n-1){cerr[-] read() failedendl;}elseif(n0){cout[-] Client closed connection unexpectedlyendl;}else{buf[n]\0;// 确保字符串安全终止cout[] Received: bufendl;// 发送回复消息constchar*msgHello from server;ssize_t sentwrite(cfd,msg,strlen(msg));if(sent-1){cerr[-] write() failedendl;}else{cout[] Sent: msgendl;}}// 关闭连接触发四次挥手close(cfd);cout[x] Connection closed.\nendl;}3.2 客户端代码 (client.cpp)#includearpa/inet.h#includecstring#includeiostream#includesys/socket.h#includeunistd.husingnamespacestd;intmain(){constintCLIENT_NUM3;// 模拟 3 次独立的连接请求cout Serial Client: Connect, Communicate, Disconnect endl;for(intidx0;idxCLIENT_NUM;idx){cout\n--- Client idx starting ---endl;// 1. 创建新的套接字intfdsocket(AF_INET,SOCK_STREAM,0);if(fd-1){cerrClient idx socket() failedendl;continue;}// 2. 配置服务端地址sockaddr_in addr{};addr.sin_familyAF_INET;addr.sin_addr.s_addrinet_addr(127.0.0.1);addr.sin_porthtons(8888);// 3. 发起连接 (三次握手)if(connect(fd,(sockaddr*)addr,sizeof(addr))-1){cerrClient idx connect() failedendl;close(fd);continue;}cout[] Connected (fdfd)endl;// 4. 发送数据charmsg[64];snprintf(msg,sizeof(msg),Hello from client %d,idx);ssize_t sentwrite(fd,msg,strlen(msg));if(sent-1){cerr[-] write() failedendl;close(fd);continue;}cout[] Sent: msgendl;// 5. 接收回复charbuf[64]{};ssize_t nread(fd,buf,sizeof(buf)-1);if(n-1){cerr[-] read() failedendl;}elseif(n0){cout[-] Server closed connectionendl;}else{buf[n]\0;cout[] Received: bufendl;}// 6. 关闭连接 (四次挥手)close(fd);cout[x] Connection closed.endl;// 间隔 1 秒给服务端留出处理时间if(idxCLIENT_NUM-1){sleep(1);}}cout\n All clients finished endl;return0;}4. 运行实录与现象观察4.1 服务端输出$ ./server Server listening on port 8888... [] Client connected from 127.0.0.1:46638 [] Received: Hello from client 0 [] Sent: Hello from server [x] Connection closed. [] Client connected from 127.0.0.1:46644 [] Received: Hello from client 1 [] Sent: Hello from server [x] Connection closed. [] Client connected from 127.0.0.1:38462 [] Received: Hello from client 2 [] Sent: Hello from server [x] Connection closed.4.2 客户端输出$ ./client Serial Client: Connect, Communicate, Disconnect --- Client 0 starting --- [] Connected (fd3) [] Sent: Hello from client 0 [] Received: Hello from server [x] Connection closed. --- Client 1 starting --- [] Connected (fd3) [] Sent: Hello from client 1 [] Received: Hello from server [x] Connection closed. --- Client 2 starting --- [] Connected (fd3) [] Sent: Hello from client 2 [] Received: Hello from server [x] Connection closed. All clients finished 5. 深度解析现象背后的底层逻辑通过对比运行结果我们可以挖掘出几个非常有价值的底层知识点 现象 1客户端的fd为什么总是 3在客户端输出中每次新建连接的fd都是3。这并不是巧合而是由 Linux 的文件描述符分配机制决定的进程启动时默认占用0(stdin)、1(stdout)、2(stderr)。当调用socket()时内核会分配当前最小的可用描述符即3。每次循环末尾调用了close(fd)内核回收了描述符3。下一次循环再次调用socket()时3又是最小的可用描述符因此被再次分配。 现象 2客户端的源端口为什么每次都不一样在服务端输出中客户端的源端口分别是46638、46644、38462。客户端代码中没有调用bind()因此内核在connect()时会自动为其分配一个临时端口Ephemeral Port。TCP 连接由四元组(源IP, 源端口, 目的IP, 目的端口)唯一标识。为了保证每次连接的四元组不冲突内核每次都会挑选一个未使用的临时端口。这也是为什么客户端可以频繁发起短连接而不会受到TIME_WAIT状态的严重影响因为端口池很大。 现象 3SO_REUSEADDR到底在保护什么如果去掉服务端的setsockopt(SO_REUSEADDR)当你CtrlC强杀服务端并立即重启时大概率会遇到bind() failed: Address already in use。这是因为服务端主动close()连接后会进入TIME_WAIT状态持续约 2MSL即 60 秒。SO_REUSEADDR告诉内核允许新 socket 绑定到处于 TIME_WAIT 状态的端口。它是服务端开发中必不可少的“护身符”。6. 进阶思考从串行走向并发本次实验的串行模型虽然简单清晰但在生产环境中存在致命缺陷一个慢客户端会阻塞整个服务器因为read()是阻塞的。如果要支持并发我们可以沿着以下路径演进演进阶段技术方案特点与适用场景V1. 多进程accept()后fork()子进程处理隔离性好但进程创建/切换开销大。适合连接数较少的场景。V2. 多线程accept()后创建std::thread处理共享内存开销较小。适合中等并发数百到数千连接。V3. 线程池预先创建线程池accept()后丢入任务队列避免频繁创建/销毁线程。适合中高并发。V4. IO 多路复用epoll/select/poll单线程管理数万连接事件驱动。现代高并发网络框架的基石如 Nginx, Redis, Netty。7. 结语万丈高楼平地起。无论是 Nginx 的百万并发还是 Redis 的极速响应其最底层的基石依然是socket、bind、listen、accept、read、write这几个朴素的系统调用。通过这次串行 TCP 通信的实验我们不仅验证了代码的正确性更在微观层面观察了文件描述符的分配、临时端口的变化以及 TCP 状态机的流转。把基础打牢后续学习 epoll 和 Reactor 模型时才能做到知其然更知其所以然。下一步建议尝试修改服务端代码引入pthread或std::thread将do_connect放入新线程中执行亲手实现一个多线程并发服务器