欢迎光临
我们一直在努力

【Linux 网络编程】connect 的断线重连:用状态机写一个能恢复连接的 TCP 客户端

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把“为什么会这样”和“应该怎样解决”说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

星光编译者博客开场动画


🔥 本文定位:在已经理解 TCP 的 socket / connect / read / write 之后,继续处理服务器暂时不可用、连接中途断开,以及客户端应该怎样重新建立连接的问题。

💡 学习目标:看懂五种应用状态;明确重连时 Socket 的生命周期;区分 EOF、连接错误与超时;实现有限重试,并通过停止、重启服务器观察恢复过程。

📌 实现范围:Linux、IPv4、C++17,单线程交互式客户端,换行分隔的回显协议。采用阻塞 connect,建连成功后设置收发超时;不自动补发结果未知的请求。

学 TCP 的时候,第一次看到终端里出现 hello 的回显,很容易觉得通信程序已经完成了。可真实的客户端不会只运行几十秒:服务器可能重启,进程可能异常结束,网络也可能暂时不可用。

于是问题从“怎样连上服务器”,变成了“原来能通信的连接,现在不能用了,客户端接下来做什么”。


文章目录

  • 一、为什么 connect 成功还需要重连
  • 二、客户端怎样发现连接不能继续使用
  • 三、用五种状态表达连接生命周期
  • 四、重连之前先处理旧 Socket
  • 五、有限重试怎样控制次数与等待
  • 六、把课程示例里的几个坑补齐
  • 七、完整的 TCP 重连客户端
  • 八、回显服务器与编译运行
  • 九、停止与重启服务器的实验
  • 十、从教学重连走向实际业务
  • 总结

一、为什么 connect 成功还需要重连

典型 TCP 客户端的调用顺序很短:

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 重连解决哪一层问题

重连首先解决“重新获得一条可通信的通道”。登录状态、订阅关系、游戏角色、未完成的业务请求,都属于更上层的恢复过程。

因此,完整业务通常是:

建立新连接 → 身份验证 → 恢复会话或订阅 → 对齐业务状态 → 继续处理请求

本文先把第一步做清楚,用回显协议观察通道恢复,不提前把业务恢复隐藏在一个 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 更容易表达“还没连过”“正在尝试”“可以通信”“允许重试”“彻底退出”的区别。

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、管理资源、执行收发并更新状态。

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,只执行:

status = Status::DISCONNECTED;

这只改变一个枚举,不会调用 close(3)。旧的内核 Socket 仍然由当前进程持有。

如果后续直接写:

fd = socket(AF_INET, SOCK_STREAM, 0);

新 Socket 可能得到 fd = 4,变量里的 3 被覆盖。程序丢失了旧 fd 的入口,又没有释放它;反复断线就可能反复泄漏。

PDF 在连接失败路径里做过 Disconnect(),但通信中收到 EOF 后只是改状态,再次 Connect() 时没有统一先清理旧资源。因此,“连接失败时有 close”仍然不能保证所有重连路径都安全。

4.2 每次尝试都从没有旧资源开始

本文统一采用:

释放旧 fd → fd 设为 -1 → 创建新 Socket → 发起 connect

释放资源函数只负责资源,不擅自决定客户端是不是永久退出:

void ReleaseSocket() {
if (fd_ >= 0) {
close(fd_);
fd_ = –1;
}
}

再用不同函数表达不同决策:

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,默认行为是终止进程。这时还没来得及按照返回值进入重连,客户端就结束了。

    本文使用:

    send(fd, data, size, MSG_NOSIGNAL);

    MSG_NOSIGNAL 抑制本次发送的 SIGPIPE,相关错误仍然通过返回值与 errno 表达。Linux send 手册

    这不代表发送一定成功,只是让程序有机会执行自己的错误处理逻辑。

    6.2 正数返回值,也可能只发送了一部分

    课程里 write 返回正数就继续读取响应。如果原本要发送 100 字节,实际只写了 40 字节,服务器可能还在等待剩余内容。

    因此需要保存偏移量,把尚未完成的字节继续发送。同一连接上接着发送未完成部分,与断线后把整条业务请求重发,是完全不同的动作。

    如果发送中途最终失败,本文关闭连接,并报告请求结果未知。即使还没把整条消息成功发完,也不能把本地看到的错误简单等同于“对端绝对没有处理”。

    6.3 TCP 不保留应用消息边界

    原文一次 write 对应一次 read,短文本演示可能看起来正常,但 TCP 不保证这种一一对应关系。

    在这里插入图片描述

    本文用一个简单的行协议:客户端给输入补上 \\n,服务器读到换行后回显同一行,客户端也读到换行才显示响应。

    用户输入 hello
    客户端发送 hello\\n
    服务器解析完整一行,再回发 hello\\n
    客户端解析完整一行,显示 hello

    换行承担消息分隔符的职责,不允许一条正文包含多个换行。正文上限是 1024 字节,不含分隔符;UTF-8 中文往往占多个字节。

    空行也能发送:空正文加上分隔符,在线路上仍然有一个换行字节。它和 TCP 的 recv == 0 没有关系,后者是流结束。

    为了便于阅读,示例每次接收一个字节直到换行。这样能明确展示拆包后的消息拼接,也不需要把另一条消息多读进来;正式程序可用接收缓冲区批量读取并解析分隔符,提高效率。

    6.4 标准输入 EOF 应该结束客户端

    原文只判断输入字符串是否为空,没有检查 getline 是否成功。输入关闭后,循环可能反复读取失败,形成空转。

    本文先判断输入操作本身:

    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、错误、超长或半条消息,避免把不完整响应显示为一次成功。

    #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。

    #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

    #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 目录执行:

    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 启动服务器:

    ./echo_server 8888

    终端 B 启动客户端:

    ./tcp_client 127.0.0.1 8888

    本例是 Linux Socket 程序。Windows 主机可以把整个 code 目录放到 WSL 的 Linux 环境中运行;它没有提供原生 Winsock 版本。

    8.3 先确认正常通信

    依次输入普通文本、中文和空行:

    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。

    日志可能表现为:

    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 没有其他监听服务,直接执行:

    ./tcp_client 127.0.0.1 8888
    echo $?

    客户端进行首次尝试,失败后追加五次恢复尝试,随后打印:

    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 为什么本文不重发上一条请求

    考虑下面的顺序:

    客户端发送请求 → 服务器收到并执行 → 响应返回途中连接中断

    客户端没有拿到完整响应,但服务器可能已经完成操作。重新连接后直接重发,就可能再次执行同一笔业务。

    在这里插入图片描述

    回显内容重复一次通常无关紧要,可换成订单提交、扣款、道具领取,就不能只凭 Socket 错误决定重试业务。

    所以本文把失败消息明确记为“结果未知”,重连成功后等待下一条用户输入。恢复通道与恢复业务请求采用不同决策。

    10.2 有请求 ID,也要设计服务端去重

    实际协议可以给业务请求分配稳定 ID。客户端重试同一笔业务时携带同一个 ID,服务器识别已经执行过的请求,返回此前结果或可查询状态。

    但“加一个编号”本身不等于完成了幂等设计:去重记录怎样持久化,业务操作和结果记录怎样保持一致,失败后怎样查询,都需要明确约定。

    这也是重连组件应该向业务层报告结果不确定性的原因:底层并没有足够信息独自保证业务只执行一次。

    10.3 更合理的等待:退避、上限与抖动

    少量教学客户端每秒尝试一次容易理解。大量客户端在服务器重启后同时重连,就可能集中产生连接压力。

    常见工程设计是逐渐增加等待时间,例如 1、2、4、8 秒,达到上限后不再继续增长;再加入随机抖动,让不同客户端错开时间。

    同时决定哪些错误值得继续等待。短暂连接拒绝可能适合恢复,非法配置、认证失败或本地资源耗尽则不应机械地套用同一套策略。

    10.4 建连超时应该单独实现

    要让一次建连严格服从应用预算,基本流程是:

    创建非阻塞 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() 就已经具备。


    总结

    这一篇可以沿着一条主线回看:

    NEW:进行一次首次连接
    → 成功:CONNECTED,收发完整应用消息
    → 失败或通信中断:释放旧 fd,进入 DISCONNECTED
    → 按间隔进行有限恢复
    → 恢复成功:创建了新通道,继续等待新请求
    → 次数耗尽或主动退出:CLOSED,结束客户端

    状态机让程序的下一步变得清楚;资源管理保证重连不会把旧 Socket 一直留在进程里;收发返回值、消息边界与 SIGPIPE 处理保证错误能被正确交给控制流程。

    重连成功只确认建立了一条新的连接。上一条请求是否执行、会话是否还在、业务状态是否一致,都需要应用层协议继续回答。把这些边界理解清楚,才能从“断开后再连一下”走向可解释、可验证的恢复逻辑。

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux 网络编程】connect 的断线重连:用状态机写一个能恢复连接的 TCP 客户端
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址