🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位 :在已经理解 TCP 的
socket / connect / read / write之后,继续处理服务器暂时不可用、连接中途断开,以及客户端应该怎样重新建立连接的问题。💡 学习目标:看懂五种应用状态;明确重连时 Socket 的生命周期;区分 EOF、连接错误与超时;实现有限重试,并通过停止、重启服务器观察恢复过程。
📌 实现范围 :Linux、IPv4、C++17,单线程交互式客户端,换行分隔的回显协议。采用阻塞
connect,建连成功后设置收发超时;不自动补发结果未知的请求。
学 TCP 的时候,第一次看到终端里出现 hello 的回显,很容易觉得通信程序已经完成了。可真实的客户端不会只运行几十秒:服务器可能重启,进程可能异常结束,网络也可能暂时不可用。
于是问题从"怎样连上服务器",变成了"原来能通信的连接,现在不能用了,客户端接下来做什么"。
文章目录
- [一、为什么 connect 成功还需要重连](#一、为什么 connect 成功还需要重连)
- 二、客户端怎样发现连接不能继续使用
- 三、用五种状态表达连接生命周期
- [四、重连之前先处理旧 Socket](#四、重连之前先处理旧 Socket)
- 五、有限重试怎样控制次数与等待
- 六、把课程示例里的几个坑补齐
- [七、完整的 TCP 重连客户端](#七、完整的 TCP 重连客户端)
- 八、回显服务器与编译运行
- 九、停止与重启服务器的实验
- 十、从教学重连走向实际业务
- 总结
一、为什么 connect 成功还需要重连
典型 TCP 客户端的调用顺序很短:
text
socket → connect → 发送请求 → 接收响应 → close
如果服务器一直运行、网络一直正常,这条流程足够完成简单实验。但只要服务器中途退出,原来的连接就不能保证继续完成业务通信。
1.1 connect 成功表达的是当时的结果
connect 成功返回 0,说明本次 TCP 建连成功。应用于是把自己的状态记成 CONNECTED,随后开始收发。
这个状态不是操作系统持续刷新给应用的"在线证明"。服务器后来关闭连接、网络后来中断,应用保存的枚举不会自行改变。程序需要依据后续观察到的收发结果,主动进行状态迁移。
可以类比为:拨通电话说明刚才接通了,不能保证十分钟后对方还在线。
1.2 服务器重启,不会接着使用旧连接
即使服务器重新监听同一个 IP 和端口,客户端之前那条失效的 TCP 连接也不会因此复活。服务入口相同,只是表示新客户端仍然可以连接到这个入口。
我们的恢复方式是:结束旧 Socket 的生命周期,创建新的 Socket,再调用 connect,建立新的连接。
这里讨论的是 TCP 客户端。前面 UDP 的 connect 主要设置默认对端,其通信语义不同,不要把两者的"连接"机械地混为一谈。
1.3 客户端为什么没有显式 bind
PDF 在 Connect() 中提醒:客户端也需要本地 IP 和端口,只是通常不需要自行指定。未显式绑定的 TCP 客户端发起建连时,内核会结合路由选择本地地址,并从临时端口范围分配可用端口。
不要把这个过程理解成"客户端没有端口",也不要要求每次重连必然得到不同的端口。是否显式 bind 取决于是否需要固定本地入口;普通回显客户端让系统分配即可。
1.4 重连解决哪一层问题
重连首先解决"重新获得一条可通信的通道"。登录状态、订阅关系、游戏角色、未完成的业务请求,都属于更上层的恢复过程。
因此,完整业务通常是:
text
建立新连接 → 身份验证 → 恢复会话或订阅 → 对齐业务状态 → 继续处理请求
本文先把第一步做清楚,用回显协议观察通道恢复,不提前把业务恢复隐藏在一个 Reconnect() 名字里。
二、客户端怎样发现连接不能继续使用
课程代码通过 read、write 的返回值决定后续动作。这个方向是对的,不过要把不同返回值的含义分开。

2.1 recv 返回正数:本次收到一些字节
对 TCP 流式 Socket,recv 返回正数表示读取到了相应数量的字节。它不保证这些字节刚好组成一个完整应用消息。
例如服务器发送 hello\n,客户端可能一次读全,也可能先读到 he,之后才读到 llo\n。读取边界与应用消息边界是两件事。
2.2 recv 返回 0:对端发送方向已经有序结束
在本文请求正长度数据的 TCP 场景里,recv 返回 0 表示读到了 EOF:对端已经有序关闭发送方向,且此前排队的数据已读完。Linux recv 手册
这比"服务器一定崩溃了"更准确。对端可能正常关闭,也可能只是调用 shutdown(fd, SHUT_WR) 结束发送,另一方向仍然存在。
本文采用简单请求/响应协议:没有继续等待完整响应的意义时,就由应用关闭整条连接并进入重连状态。这是一项应用策略,不是所有 TCP 程序都必须采用的行为。
2.3 recv 或 send 返回 -1:先看 errno
不能把所有负数都翻译成"服务器掉线"。常见情况如下:
| 情况 | 代表什么 | 本文的处理 |
|---|---|---|
EINTR |
系统调用被信号中断 | 在收发循环中继续当前操作 |
ECONNRESET |
连接被重置 | 释放 Socket,进入重连 |
EPIPE |
当前连接不能再按预期写入 | 避免 SIGPIPE,再进入重连 |
EAGAIN / EWOULDBLOCK |
本文阻塞收发的单次等待超时 | 放弃当前连接,重连;请求结果未知 |
| 地址、参数或本地资源错误 | 可能根本不是远端故障 | 参数先校验;本地创建或配置失败退出 |
如果以后改为非阻塞 Socket,EAGAIN 往往只是"现在暂时没有数据或暂时不能写",应该返回事件循环等待就绪,不能照搬本文的超时处理策略。
2.4 网络断开不一定马上产生 EOF
把网线拔掉、路由中断,与对端明确关闭发送方向不一样。客户端可能没有收到 FIN 或 RST,只是在等待数据或由 TCP 继续重传。
所以"服务器没回消息"和"已经确认服务器崩溃"不能画等号。超时给应用一个停止等待的理由,不能证明远端没有执行请求。
还有一个特别容易忽略的阻塞点:客户端停在 std::getline(std::cin, ...) 时,正在等待用户输入,没有进行网络读取。 即使服务器已经关闭连接,程序也不会自动跳出输入提示。
三、用五种状态表达连接生命周期
PDF 用 enum class 定义状态,再让外层 switch 调用对应动作。这种组织方式比散落在各处的 bool connected 更容易表达"还没连过""正在尝试""可以通信""允许重试""彻底退出"的区别。
cpp
enum class Status {
NEW,
CONNECTING,
CONNECTED,
DISCONNECTED,
CLOSED
};

3.1 状态名称要有稳定含义
| 状态 | 本文约定 | 下一步 |
|---|---|---|
NEW |
首次启动,尚未尝试连接 | Connect() |
CONNECTING |
当前正在创建 Socket 或执行建连 | 本次尝试结束后决定结果 |
CONNECTED |
本次建连成功,可以尝试收发 | Process() |
DISCONNECTED |
当前没有有效连接,允许有限重试 | Reconnect() |
CLOSED |
客户端已经决定结束 | 返回退出码 |
这些是我们给应用程序 定义的状态,与 TCP 内核的 SYN_SENT、ESTABLISHED、CLOSE_WAIT 等协议状态不属于同一套枚举。
PDF 在 DISCONNECTED 注释中写到重连失败。整理时把它统一解释为"当前没有可用连接,但还允许尝试恢复",这样首次连接失败和通信中断就可以进入相同处理路径。
3.2 外层控制,内层执行
外层 TcpClient::Execute() 决定调用哪个动作;内层 ClientConnection 持有 fd、管理资源、执行收发并更新状态。
text
TcpClient:看到 DISCONNECTED → 调用 Reconnect
ClientConnection:尝试恢复 → 更新为 CONNECTED 或 CLOSED
TcpClient:再次读取状态 → 通信或退出
外层不需要自行关闭内部 fd,内部也不需要知道"客户端所在游戏页面应该怎么显示"。这就是课程组合式封装值得保留的地方。
3.3 CONNECTING 在同步程序里能做什么
本文的 Connect() 是同步调用:它设置 CONNECTING 后执行阻塞建连,返回之前就已经写入成功或失败状态。
所以外层循环通常不会观察到 CONNECTING。保留它是为了描述一次动作期间的状态,不意味着已经具备异步查询或后台连接能力。
原文 switch 的 default: break 会掩盖异常状态,甚至可能形成空转。完整示例明确处理所有枚举;同步版本如果意外把 CONNECTING 留给外层,就报告错误并退出。
四、重连之前先处理旧 Socket
这里是课程代码最值得认真推演的部分。

4.1 改状态,不等于释放资源
假设最初创建出的 Socket 是 fd = 3。通信中发现 EOF,只执行:
cpp
status = Status::DISCONNECTED;
这只改变一个枚举,不会调用 close(3)。旧的内核 Socket 仍然由当前进程持有。
如果后续直接写:
cpp
fd = socket(AF_INET, SOCK_STREAM, 0);
新 Socket 可能得到 fd = 4,变量里的 3 被覆盖。程序丢失了旧 fd 的入口,又没有释放它;反复断线就可能反复泄漏。
PDF 在连接失败路径里做过 Disconnect(),但通信中收到 EOF 后只是改状态,再次 Connect() 时没有统一先清理旧资源。因此,"连接失败时有 close"仍然不能保证所有重连路径都安全。
4.2 每次尝试都从没有旧资源开始
本文统一采用:
text
释放旧 fd → fd 设为 -1 → 创建新 Socket → 发起 connect
释放资源函数只负责资源,不擅自决定客户端是不是永久退出:
cpp
void ReleaseSocket() {
if (fd_ >= 0) {
close(fd_);
fd_ = -1;
}
}
再用不同函数表达不同决策:
text
LoseConnection:释放资源,然后设为 DISCONNECTED
Stop:释放资源,然后设为 CLOSED
这样就不用像原文一样,先在 Disconnect() 中写 CLOSED,随后又在调用者那里覆盖为 DISCONNECTED。资源动作与程序决策更加清楚。
4.3 connect 失败后也应该创建新 Socket
建连失败不等于 socket 没有创建成功。失败的连接尝试仍可能留下已经分配的 fd,需要清理。
Linux 手册建议:connect 失败后把 Socket 状态视为未指定,可移植程序应关闭它,并在再次建连时创建新的 Socket。Linux connect 手册
本文也采用这条路径。失败后不继续在原 Socket 上盲目重复建连。
4.4 fd 数字不代表连接身份
fd 是进程里的整数入口,关闭后这个数字可以重新分配。旧连接是 3,新连接也可能是 3,但它们指向的是两次创建的 Socket。
检查合法性要使用 fd >= 0,因为 0 也是合法 fd。析构函数兜底释放资源,并删除复制构造与复制赋值,防止两个对象同时以为自己拥有同一个描述符。
五、有限重试怎样控制次数与等待
网络暂时不可用时可以恢复,但一直无限尝试会占用资源,也会让用户很难判断程序是否还能结束。课程采用"间隔 1 秒,最多重试 5 次",适合作为入门策略。

5.1 先定义五次到底怎么算
本文约定:
NEW触发一次首次尝试;- 首次失败后,最多追加 5 次尝试;
- 每次追加尝试前等待 1 秒;
- 某次成功就立即回到通信;
- 第 5 次仍失败,直接进入
CLOSED。
因此首次启动时,最多执行 1 + 5 = 6 次 connect。已经连接后再次断开,会获得一轮新的、最多 5 次的恢复预算。
不要把"最多重试五次"含糊地写成"总共只尝试五次",否则代码与日志很容易对不上。
5.2 等待在尝试之前,日志表示实际尝试编号
本文选择先等待,再打印 RETRY 1/5 并调用 Connect()。这样服务器恢复有一个短暂窗口,也避免连接刚失败就立刻再次打过去。
PDF 采用尝试后等待,并在等待后递增、打印次数。两种顺序都可以,但需要解释清楚日志是在说"准备第几次尝试"还是"已经失败多少次"。最后一次已经失败且不再继续时,也没有必要再额外等待。
5.3 五次重试,不等于五秒结束
应用等待 1 秒与 connect 本身花多少时间,是两个维度。
同机没有监听服务时,常见结果是很快返回 ECONNREFUSED;网络静默丢包时,阻塞 connect 可能等待较长时间。因此总耗时包含每次建连的时间,不能把本文描述成"任何故障都在五秒内退出"。
如果需要明确的建连截止时间,应改为非阻塞建连,处理 EINPROGRESS,等待可写事件后再检查 SO_ERROR。这一扩展放到最后讨论,本篇完整代码仍保留阻塞建连,方便先看清状态机。
六、把课程示例里的几个坑补齐
原文主线足够演示状态变化,但可复现的完整程序还需要补齐下面这些边界。
6.1 写入断开的连接,可能先收到 SIGPIPE
原文直接使用 write。某些连接关闭情况下,进程可能收到 SIGPIPE,默认行为是终止进程。这时还没来得及按照返回值进入重连,客户端就结束了。
本文使用:
cpp
send(fd, data, size, MSG_NOSIGNAL);
MSG_NOSIGNAL 抑制本次发送的 SIGPIPE,相关错误仍然通过返回值与 errno 表达。Linux send 手册
这不代表发送一定成功,只是让程序有机会执行自己的错误处理逻辑。
6.2 正数返回值,也可能只发送了一部分
课程里 write 返回正数就继续读取响应。如果原本要发送 100 字节,实际只写了 40 字节,服务器可能还在等待剩余内容。
因此需要保存偏移量,把尚未完成的字节继续发送。同一连接上接着发送未完成部分,与断线后把整条业务请求重发,是完全不同的动作。
如果发送中途最终失败,本文关闭连接,并报告请求结果未知。即使还没把整条消息成功发完,也不能把本地看到的错误简单等同于"对端绝对没有处理"。
6.3 TCP 不保留应用消息边界
原文一次 write 对应一次 read,短文本演示可能看起来正常,但 TCP 不保证这种一一对应关系。

本文用一个简单的行协议 :客户端给输入补上 \n,服务器读到换行后回显同一行,客户端也读到换行才显示响应。
text
用户输入 hello
客户端发送 hello\n
服务器解析完整一行,再回发 hello\n
客户端解析完整一行,显示 hello
换行承担消息分隔符的职责,不允许一条正文包含多个换行。正文上限是 1024 字节,不含分隔符;UTF-8 中文往往占多个字节。
空行也能发送:空正文加上分隔符,在线路上仍然有一个换行字节。它和 TCP 的 recv == 0 没有关系,后者是流结束。
为了便于阅读,示例每次接收一个字节直到换行。这样能明确展示拆包后的消息拼接,也不需要把另一条消息多读进来;正式程序可用接收缓冲区批量读取并解析分隔符,提高效率。
6.4 标准输入 EOF 应该结束客户端
原文只判断输入字符串是否为空,没有检查 getline 是否成功。输入关闭后,循环可能反复读取失败,形成空转。
本文先判断输入操作本身:
cpp
if (!std::getline(std::cin, input) || input == "/quit") {
Stop(0);
return;
}
用户输入 /quit 或标准输入到达 EOF,属于本地主动退出,进入 CLOSED。这时不应该继续重连。
6.5 超时是等待策略,不能充当业务确认
本文在建连成功后设置 SO_RCVTIMEO / SO_SNDTIMEO 为 3 秒。对应单次阻塞收发没有进展而超过等待时间时,可以报告超时并放弃连接。Linux Socket 手册
这个设置不是整条请求的严格三秒截止时间:如果响应每两秒来一个字节,每次 recv 都有进展,整条消息仍可能花很久。生产程序需要整体 deadline 时,应使用单调时钟计算剩余预算,并配合事件等待。
同样,这里是在 connect 成功之后设置,没有为建连本身增加三秒截止时间。收发超时、重试间隔、建连超时,是三个不同参数。
七、完整的 TCP 重连客户端
项目不依赖课程中的其他头文件,文件都保存在本篇 code 目录。下面按公共收发函数、客户端主程序分别列出,复制到同一目录即可编译。
7.1 公共辅助文件 tcp_io.hpp
SendAll 处理短写与信号中断;ReadLine 返回完整消息、EOF、错误、超长或半条消息,避免把不完整响应显示为一次成功。
cpp
#pragma once
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <stdexcept>
#include <string>
constexpr std::size_t kMaxLine = 1024; // 正文字节数,不含换行
inline uint16_t ParsePort(const std::string& text) {
if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos)
throw std::invalid_argument("port must contain digits only");
std::size_t used = 0;
unsigned long value = std::stoul(text, &used);
if (used != text.size() || value == 0 || value > 65535)
throw std::invalid_argument("port must be in 1..65535");
return static_cast<uint16_t>(value);
}
inline bool SendAll(int fd, const std::string& data) {
std::size_t offset = 0;
while (offset < data.size()) {
ssize_t n = send(fd, data.data() + offset,
data.size() - offset, MSG_NOSIGNAL);
if (n > 0) { offset += static_cast<std::size_t>(n); continue; }
if (n < 0 && errno == EINTR) continue;
if (n == 0) errno = EIO;
return false;
}
return true;
}
enum class ReadResult { Line, Eof, Error, TooLong, Truncated };
// 为便于阅读每次读取一个字节。生产实现可改成接收缓冲 + 换行解析。
inline ReadResult ReadLine(int fd, std::string& line) {
line.clear();
for (;;) {
char ch = 0;
ssize_t n = recv(fd, &ch, 1, 0);
if (n > 0) {
if (ch == '\n') return ReadResult::Line;
if (line.size() == kMaxLine) return ReadResult::TooLong;
line.push_back(ch);
continue;
}
if (n == 0) return line.empty() ? ReadResult::Eof : ReadResult::Truncated;
if (errno == EINTR) continue;
return ReadResult::Error;
}
}
inline bool SetIoTimeouts(int fd) {
timeval timeout{};
timeout.tv_sec = 3;
return setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO,
&timeout, sizeof(timeout)) == 0 &&
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO,
&timeout, sizeof(timeout)) == 0;
}
接收已经开始、却在读到换行前出现 EOF 时,返回 Truncated。这种情况下本文关闭连接,不拿半条字符串冒充完整回显。
7.2 客户端主程序 tcp_client.cpp
这份代码沿用课程里的 ClientConnection + TcpClient 组合关系,以及五种状态。主要变化是统一资源清理、明确重试次数、检查用户输入,并把所有通信中断路径都收敛到 DISCONNECTED。
cpp
#include "tcp_io.hpp"
#include <chrono>
#include <cstring>
#include <iostream>
#include <thread>
enum class Status { NEW, CONNECTING, CONNECTED, DISCONNECTED, CLOSED };
class ClientConnection {
public:
ClientConnection(const std::string& ip, uint16_t port) {
server_.sin_family = AF_INET;
server_.sin_port = htons(port);
if (inet_pton(AF_INET, ip.c_str(), &server_.sin_addr) != 1)
throw std::invalid_argument("invalid IPv4 address");
}
~ClientConnection() { ReleaseSocket(); }
ClientConnection(const ClientConnection&) = delete;
ClientConnection& operator=(const ClientConnection&) = delete;
Status GetStatus() const { return status_; }
int ExitCode() const { return exit_code_; }
void Connect() {
// 必须先释放旧资源,再让 fd_ 指向新 Socket。
ReleaseSocket();
status_ = Status::CONNECTING;
fd_ = socket(AF_INET, SOCK_STREAM, 0);
if (fd_ < 0) {
std::cerr << "socket: " << std::strerror(errno) << '\n';
Stop(1); // 本地资源问题,演示中选择立即退出
return;
}
// 教学版使用阻塞 connect;没有应用层建连截止时间。
if (connect(fd_, reinterpret_cast<sockaddr*>(&server_),
sizeof(server_)) < 0) {
int error = errno;
std::cerr << "connect: " << std::strerror(error) << '\n';
LoseConnection();
return;
}
// 建连成功后才设置收发超时,避免改变上面的 connect 语义。
if (!SetIoTimeouts(fd_)) {
std::cerr << "setsockopt: " << std::strerror(errno) << '\n';
Stop(1);
return;
}
status_ = Status::CONNECTED;
std::cout << "CONNECTED" << std::endl;
}
void Reconnect() {
for (int attempt = 1; attempt <= kMaxRetries; ++attempt) {
// 每次追加尝试前等待一秒;最后一次失败后直接结束。
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "RETRY " << attempt << '/' << kMaxRetries << std::endl;
Connect();
if (status_ == Status::CONNECTED || status_ == Status::CLOSED) return;
}
std::cout << "RETRY_EXHAUSTED" << std::endl;
Stop(2);
}
void Process() {
while (status_ == Status::CONNECTED) {
std::cout << "Please Enter# " << std::flush;
std::string input;
if (!std::getline(std::cin, input) || input == "/quit") {
Stop(0); // 本地主动退出,不触发重连
return;
}
if (input.size() > kMaxLine) {
std::cout << "input too long (max 1024 bytes)" << std::endl;
continue;
}
if (!SendAll(fd_, input + '\n')) {
int error = errno;
std::cerr << "send: " << std::strerror(error) << '\n';
ReportUnknown();
LoseConnection();
return;
}
std::string reply;
ReadResult result = ReadLine(fd_, reply);
if (result == ReadResult::Line) {
std::cout << "echo -> " << reply << std::endl;
continue;
}
if (result == ReadResult::Error)
std::cerr << "recv: " << std::strerror(errno) << '\n';
else if (result == ReadResult::Eof)
std::cerr << "peer closed its sending direction\n";
else if (result == ReadResult::Truncated)
std::cerr << "peer closed before a complete reply\n";
else
std::cerr << "reply exceeded 1024 bytes\n";
ReportUnknown();
LoseConnection();
return;
}
}
private:
void ReleaseSocket() {
if (fd_ >= 0) {
close(fd_); // 本例没有共享所有权;Linux 下不盲目重试 close
fd_ = -1;
}
}
void LoseConnection() {
ReleaseSocket();
status_ = Status::DISCONNECTED;
}
void Stop(int code) {
ReleaseSocket();
status_ = Status::CLOSED;
exit_code_ = code;
}
static void ReportUnknown() {
std::cerr << "request outcome unknown; it will NOT be replayed\n";
}
int fd_ = -1;
sockaddr_in server_{};
Status status_ = Status::NEW;
int exit_code_ = 0;
static constexpr int kMaxRetries = 5;
};
class TcpClient {
public:
TcpClient(const std::string& ip, uint16_t port) : connection_(ip, port) {}
int Execute() {
for (;;) {
switch (connection_.GetStatus()) {
case Status::NEW: connection_.Connect(); break;
case Status::CONNECTED: connection_.Process(); break;
case Status::DISCONNECTED: connection_.Reconnect(); break;
case Status::CLOSED: return connection_.ExitCode();
case Status::CONNECTING:
// 同步 Connect 在返回前已确定结果,这里不应到达。
std::cerr << "unexpected CONNECTING state\n";
return 1;
}
}
}
private:
ClientConnection connection_;
};
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " server_ip server_port\n";
return 1;
}
try {
TcpClient client(argv[1], ParsePort(argv[2]));
return client.Execute();
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
7.3 三个函数分开读
Connect() 只完成一次尝试。 它不偷偷循环五次,也不负责读取用户输入。进入动作前先释放旧 fd,连接成功后设置收发超时,再把状态切成 CONNECTED。
Reconnect() 执行有限恢复。 每次等待一秒,再调用一次 Connect()。成功就返回,五次失败就结束;本地创建或配置 Socket 失败也立即返回结束状态。
Process() 完成请求与响应。 它只在 CONNECTED 状态运行。遇到发送错误、响应 EOF、响应超时或协议错误,就释放连接并交回外层控制循环。
所有路径都回到明确的状态,不靠到处增加嵌套 while 来决定程序接下来做什么。
7.4 退出码表示什么
| 退出码 | 含义 |
|---|---|
0 |
用户输入 /quit,或标准输入正常结束 |
1 |
参数无效、本地资源配置失败,或出现不应到达的状态 |
2 |
恢复预算耗尽,仍无法建立连接 |
命令行程序返回状态有助于脚本区分正常退出与重连失败,避免所有情况都返回 0,却让使用者只能猜日志。
八、回显服务器与编译运行
重连实验需要一个可以随时停止和重启的服务端。下面提供最小配套版本,同样采用换行协议,不用额外下载课程仓库才能跑通。
8.1 echo_server.cpp
cpp
#include "tcp_io.hpp"
#include <cstring>
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port\n";
return 1;
}
int listener = -1;
try {
uint16_t port = ParsePort(argv[1]);
listener = socket(AF_INET, SOCK_STREAM, 0);
if (listener < 0) throw std::runtime_error(std::strerror(errno));
// 便于同地址重启实验,不表示能随意启动两个监听服务。
int yes = 1;
if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) < 0)
throw std::runtime_error(std::strerror(errno));
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(port);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) < 0 ||
listen(listener, 8) < 0)
throw std::runtime_error(std::strerror(errno));
std::cout << "LISTEN 127.0.0.1:" << port << std::endl;
for (;;) {
int peer = accept(listener, nullptr, nullptr);
if (peer < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::strerror(errno));
}
std::cout << "ACCEPT" << std::endl;
// 服务端允许用户长时间停在输入提示,只限制发送等待。
timeval timeout{};
timeout.tv_sec = 3;
if (setsockopt(peer, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)) < 0) {
close(peer);
throw std::runtime_error(std::strerror(errno));
}
std::string line;
while (ReadLine(peer, line) == ReadResult::Line) {
if (!SendAll(peer, line + '\n')) break;
}
close(peer);
}
} catch (const std::exception& error) {
if (listener >= 0) close(listener);
std::cerr << "server: " << error.what() << '\n';
return 1;
}
}
服务端采用单线程顺序处理:处理完当前连接,才继续 accept 下一个。它用于受控本地实验,不是面向大量并发用户的服务器。
绑定地址使用 127.0.0.1,两个终端在同一台 Linux 主机上运行即可。监听 Socket 开启 SO_REUSEADDR,方便停止后在同一地址重启;这不是忽略任意端口冲突的开关。
服务端没有给接收操作设置三秒空闲超时,因为客户端可能停在输入提示很久。客户端三秒接收等待发生在发送请求之后,两者承担的角色不同。
8.2 编译命令
在 code 目录执行:
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 tcp_client.cpp -o tcp_client
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 echo_server.cpp -o echo_server
# 或直接使用随文 Makefile:
make
终端 A 启动服务器:
bash
./echo_server 8888
终端 B 启动客户端:
bash
./tcp_client 127.0.0.1 8888
本例是 Linux Socket 程序。Windows 主机可以把整个 code 目录放到 WSL 的 Linux 环境中运行;它没有提供原生 Winsock 版本。
8.3 先确认正常通信
依次输入普通文本、中文和空行:
text
CONNECTED
Please Enter# hello
echo -> hello
Please Enter# 你好,TCP
echo -> 你好,TCP
Please Enter#
echo ->
Please Enter# /quit
上面是交互形式示意。空正文依旧是一条由换行分隔的消息;/quit 是客户端本地命令,不会发给服务器。
先把普通通信跑通,再停止服务端。否则第一次就遇到"连不上",很难区分代码错误与实验设置问题。
九、停止与重启服务器的实验
课程最后一页演示了重连成功与重连失败。下面把实验条件补齐,让结果可以重复观察。

9.1 实验 A:服务器在预算内恢复
- 按上节启动两个终端,输入
hello并确认回显; - 在服务器终端按
Ctrl+C,结束这个服务进程; - 在客户端继续输入一条消息,触发实际收发;
- 观察通信错误、EOF 或超时,以及随后出现的
RETRY; - 在五次恢复尝试结束之前,重新运行
./echo_server 8888; - 客户端再次出现
CONNECTED后,输入一条新的消息,确认新的回显。
停止进程后的底层表现与待收发数据、关闭时序有关,可能看到 EOF,也可能看到连接重置等错误。不要要求所有机器都必须打印同一个 errno。
日志可能表现为:
text
peer closed its sending direction
request outcome unknown; it will NOT be replayed
RETRY 1/5
connect: Connection refused
RETRY 2/5
CONNECTED
Please Enter# after-reconnect
echo -> after-reconnect
以上为行为示意,重试编号和错误顺序取决于重启时机。关键是"检测通信中断 → 有限尝试 → 建立新连接 → 新消息成功"。
9.2 为什么输入提示不会自动变化
客户端停在 Please Enter# 时执行的是 getline,不是后台监控。停止服务端后,输入一条消息,程序才会尝试收发并有机会发现旧连接已经不能使用。
想在输入期间也及时感知网络事件,需要同时管理标准输入与 Socket,例如事件循环、独立接收线程,或其他异步设计。这个需求超出了当前单线程同步示例。
9.3 实验 B:服务器始终不启动
先确认本机 8888 没有其他监听服务,直接执行:
bash
./tcp_client 127.0.0.1 8888
echo $?
客户端进行首次尝试,失败后追加五次恢复尝试,随后打印:
text
RETRY 5/5
RETRY_EXHAUSTED
2
2 是客户端退出后由 echo $? 显示的退出码。针对这个受控回环场景,连接拒绝通常很快返回;不要据此推断所有网络条件都能同样快地结束。
9.4 实验 C:服务器接收请求却不响应
可在另一个空闲端口启动一个临时服务,仅接收数据而不回显,再让客户端连接并发出一行请求。客户端会在一次接收等待超时后放弃连接,并进入恢复路径。
随文 verify.py 自动构造了这个场景,也构造了 FIN、RST、分段响应、半条响应以及多个连续恢复轮次,不需要为了测试手动破坏网络配置。
注意:单次超时说明"本次等待没有等到想要的响应",不是"远端确定没收到请求"。这个区别会直接影响下一节的业务策略。
9.5 本篇验证范围
项目已在本机 WSL Linux 环境中,以 g++ 的 -std=c++17 -Wall -Wextra -Wpedantic -Werror 选项编译,通过 15 项验证:正常文本与 UTF-8 回显、空行、消息长度边界、非法参数、标准输入 EOF、五次重试耗尽、延迟启动服务、真实服务进程停止与重启、FIN、RST、接收超时、半条与超长响应、分段响应、连续四轮重连的 fd 数量,以及 SIGPIPE 抑制。
这些实验验证的是回环网络中的教学实现;没有把公网故障、内核建连等待上限或严格的业务去重当作已经验证的能力。
具体验证命令、环境与每项结果见 code/验证记录.md。自动化脚本 code/verify.py 用于复现实验,不需要额外 Python 第三方库。
十、从教学重连走向实际业务
回显实验跑通以后,最容易出现的误解是:"只要自动重连,再自动重发上一条消息,业务就一定能恢复。"
10.1 为什么本文不重发上一条请求
考虑下面的顺序:
text
客户端发送请求 → 服务器收到并执行 → 响应返回途中连接中断
客户端没有拿到完整响应,但服务器可能已经完成操作。重新连接后直接重发,就可能再次执行同一笔业务。

回显内容重复一次通常无关紧要,可换成订单提交、扣款、道具领取,就不能只凭 Socket 错误决定重试业务。
所以本文把失败消息明确记为"结果未知",重连成功后等待下一条用户输入。恢复通道与恢复业务请求采用不同决策。
10.2 有请求 ID,也要设计服务端去重
实际协议可以给业务请求分配稳定 ID。客户端重试同一笔业务时携带同一个 ID,服务器识别已经执行过的请求,返回此前结果或可查询状态。
但"加一个编号"本身不等于完成了幂等设计:去重记录怎样持久化,业务操作和结果记录怎样保持一致,失败后怎样查询,都需要明确约定。
这也是重连组件应该向业务层报告结果不确定性的原因:底层并没有足够信息独自保证业务只执行一次。
10.3 更合理的等待:退避、上限与抖动
少量教学客户端每秒尝试一次容易理解。大量客户端在服务器重启后同时重连,就可能集中产生连接压力。
常见工程设计是逐渐增加等待时间,例如 1、2、4、8 秒,达到上限后不再继续增长;再加入随机抖动,让不同客户端错开时间。
同时决定哪些错误值得继续等待。短暂连接拒绝可能适合恢复,非法配置、认证失败或本地资源耗尽则不应机械地套用同一套策略。
10.4 建连超时应该单独实现
要让一次建连严格服从应用预算,基本流程是:
text
创建非阻塞 Socket
→ connect 立即成功:完成
→ connect 返回 EINPROGRESS:等待可写或错误事件
→ getsockopt(SOL_SOCKET, SO_ERROR) 检查建连结果
→ SO_ERROR 为 0:成功;非 0:失败
→ 截止时间到:关闭本次 Socket
Socket 可写不等于连接成功 ,失败的建连也可能产生就绪通知,所以要读取 SO_ERROR。Linux connect 手册
等待被信号中断时还需要依据单调时钟重新计算剩余时间,不能每次都重新给出一整段超时预算。本文不把这套事件管理混进主要示例,后续学习非阻塞 IO 和多路复用时可以自然扩展。
10.5 连接恢复后还需要恢复会话
新连接通常需要重新认证、恢复订阅或重新同步数据。服务器重启后旧会话是否还存在,必须通过应用协议确认,不能根据本地 fd 数字或 CONNECTED 枚举推断。
此外,本文没有实现后台心跳、输入阶段的立即取消、多线程共享 fd、DNS 解析、TLS 或多客户端并发服务端。这些能力需要围绕各自职责继续设计,不宜默认为调用 Reconnect() 就已经具备。
总结
这一篇可以沿着一条主线回看:
text
NEW:进行一次首次连接
→ 成功:CONNECTED,收发完整应用消息
→ 失败或通信中断:释放旧 fd,进入 DISCONNECTED
→ 按间隔进行有限恢复
→ 恢复成功:创建了新通道,继续等待新请求
→ 次数耗尽或主动退出:CLOSED,结束客户端
状态机让程序的下一步变得清楚;资源管理保证重连不会把旧 Socket 一直留在进程里;收发返回值、消息边界与 SIGPIPE 处理保证错误能被正确交给控制流程。
重连成功只确认建立了一条新的连接。上一条请求是否执行、会话是否还在、业务状态是否一致,都需要应用层协议继续回答。把这些边界理解清楚,才能从"断开后再连一下"走向可解释、可验证的恢复逻辑。