文章目录
- [一次串行 TCP 通信的实验复盘:从代码到运行,亲手验证同步交互模型](#一次串行 TCP 通信的实验复盘:从代码到运行,亲手验证同步交互模型)
-
- [1. 实验目的](#1. 实验目的)
- [2. 架构与设计思路](#2. 架构与设计思路)
-
- [2.1 服务端:单进程串行模型](#2.1 服务端:单进程串行模型)
- [2.2 客户端:串行短连接模型](#2.2 客户端:串行短连接模型)
- [3. 完整代码实现](#3. 完整代码实现)
-
- [3.1 服务端代码 (`server.cpp`)](#3.1 服务端代码 (
server.cpp)) - [3.2 客户端代码 (`client.cpp`)](#3.2 客户端代码 (
client.cpp))
- [3.1 服务端代码 (`server.cpp`)](#3.1 服务端代码 (
- [4. 运行实录与现象观察](#4. 运行实录与现象观察)
-
- [4.1 服务端输出](#4.1 服务端输出)
- [4.2 客户端输出](#4.2 客户端输出)
- [5. 深度解析:现象背后的底层逻辑](#5. 深度解析:现象背后的底层逻辑)
-
- [💡 现象 1:客户端的 `fd` 为什么总是 3?](#💡 现象 1:客户端的
fd为什么总是 3?) - [💡 现象 2:客户端的源端口为什么每次都不一样?](#💡 现象 2:客户端的源端口为什么每次都不一样?)
- [💡 现象 3:`SO_REUSEADDR` 到底在保护什么?](#💡 现象 3:
SO_REUSEADDR到底在保护什么?)
- [💡 现象 1:客户端的 `fd` 为什么总是 3?](#💡 现象 1:客户端的
- [6. 进阶思考:从串行走向并发](#6. 进阶思考:从串行走向并发)
- [7. 结语](#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)
cpp
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
#include <unistd.h>
using namespace std;
// 处理单个客户端连接的函数
void do_connect(int cfd, const sockaddr_in &client_addr);
int main() {
// 1. 创建 TCP 监听套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1) {
cerr << "socket() failed" << endl;
return -1;
}
// 2. 设置端口复用 (核心:防止重启时 TIME_WAIT 导致 bind 失败)
int opt = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
cerr << "setsockopt() failed" << endl;
close(lfd);
return -1;
}
// 3. 绑定地址和端口
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
addr.sin_port = htons(8888);
if (bind(lfd, (sockaddr *)&addr, sizeof(addr)) == -1) {
cerr << "bind() failed (port 8888 may be in use)" << endl;
close(lfd);
return -1;
}
// 4. 开始监听,设置 backlog 为 5
if (listen(lfd, 5) == -1) {
cerr << "listen() failed" << endl;
close(lfd);
return -1;
}
cout << "Server listening on port 8888..." << endl;
// 5. 主循环:持续接受客户端连接
sockaddr_in client_addr{};
socklen_t len = sizeof(client_addr);
while (true) {
// accept 会阻塞,直到有新的连接进入已完成队列
int cfd = accept(lfd, (sockaddr *)&client_addr, &len);
if (cfd == -1) {
cerr << "accept() failed" << endl;
continue;
}
// 处理当前连接 (串行阻塞,处理完才会 accept 下一个)
do_connect(cfd, client_addr);
}
close(lfd);
return 0;
}
// 处理客户端连接的具体逻辑
void do_connect(int cfd, const sockaddr_in &client_addr) {
// 解析并打印客户端 IP 和端口
char ip_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;
// 读取客户端消息
char buf[64] = {};
ssize_t n = read(cfd, buf, sizeof(buf) - 1);
if (n == -1) {
cerr << "[-] read() failed" << endl;
} else if (n == 0) {
cout << "[-] Client closed connection unexpectedly" << endl;
} else {
buf[n] = '\0'; // 确保字符串安全终止
cout << "[>] Received: " << buf << endl;
// 发送回复消息
const char *msg = "Hello from server";
ssize_t sent = write(cfd, msg, strlen(msg));
if (sent == -1) {
cerr << "[-] write() failed" << endl;
} else {
cout << "[<] Sent: " << msg << endl;
}
}
// 关闭连接,触发四次挥手
close(cfd);
cout << "[x] Connection closed.\n" << endl;
}
3.2 客户端代码 (client.cpp)
cpp
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
#include <unistd.h>
using namespace std;
int main() {
const int CLIENT_NUM = 3; // 模拟 3 次独立的连接请求
cout << "=== Serial Client: Connect, Communicate, Disconnect ===" << endl;
for (int idx = 0; idx < CLIENT_NUM; ++idx) {
cout << "\n--- Client " << idx << " starting ---" << endl;
// 1. 创建新的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
cerr << "Client " << idx << " socket() failed" << endl;
continue;
}
// 2. 配置服务端地址
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(8888);
// 3. 发起连接 (三次握手)
if (connect(fd, (sockaddr *)&addr, sizeof(addr)) == -1) {
cerr << "Client " << idx << " connect() failed" << endl;
close(fd);
continue;
}
cout << "[+] Connected (fd=" << fd << ")" << endl;
// 4. 发送数据
char msg[64];
snprintf(msg, sizeof(msg), "Hello from client %d", idx);
ssize_t sent = write(fd, msg, strlen(msg));
if (sent == -1) {
cerr << "[-] write() failed" << endl;
close(fd);
continue;
}
cout << "[>] Sent: " << msg << endl;
// 5. 接收回复
char buf[64] = {};
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n == -1) {
cerr << "[-] read() failed" << endl;
} else if (n == 0) {
cout << "[-] Server closed connection" << endl;
} else {
buf[n] = '\0';
cout << "[<] Received: " << buf << endl;
}
// 6. 关闭连接 (四次挥手)
close(fd);
cout << "[x] Connection closed." << endl;
// 间隔 1 秒,给服务端留出处理时间
if (idx < CLIENT_NUM - 1) {
sleep(1);
}
}
cout << "\n=== All clients finished ===" << endl;
return 0;
}
4. 运行实录与现象观察
4.1 服务端输出
text
$ ./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 客户端输出
text
$ ./client
=== Serial Client: Connect, Communicate, Disconnect ===
--- Client 0 starting ---
[+] Connected (fd=3)
[>] Sent: Hello from client 0
[<] Received: Hello from server
[x] Connection closed.
--- Client 1 starting ---
[+] Connected (fd=3)
[>] Sent: Hello from client 1
[<] Received: Hello from server
[x] Connection closed.
--- Client 2 starting ---
[+] Connected (fd=3)
[>] 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状态的严重影响(因为端口池很大)。
💡 现象 3:SO_REUSEADDR 到底在保护什么?
如果去掉服务端的 setsockopt(SO_REUSEADDR),当你 Ctrl+C 强杀服务端并立即重启时,大概率会遇到 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放入新线程中执行,亲手实现一个多线程并发服务器!