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

🔥 星光编译者 · 个人主页

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

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


☕ 写在开篇

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

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

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

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


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

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

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

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

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


文章目录


一、为什么 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 先定义五次到底怎么算

本文约定:

  1. NEW 触发一次首次尝试;
  2. 首次失败后,最多追加 5 次尝试;
  3. 每次追加尝试前等待 1 秒;
  4. 某次成功就立即回到通信;
  5. 第 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:服务器在预算内恢复

  1. 按上节启动两个终端,输入 hello 并确认回显;
  2. 在服务器终端按 Ctrl+C,结束这个服务进程;
  3. 在客户端继续输入一条消息,触发实际收发;
  4. 观察通信错误、EOF 或超时,以及随后出现的 RETRY;
  5. 在五次恢复尝试结束之前,重新运行 ./echo_server 8888;
  6. 客户端再次出现 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 处理保证错误能被正确交给控制流程。

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

相关推荐
Zucker n2 小时前
英语“论文”语法检查工具
linux·windows
2401_868534783 小时前
局域网络中信息安全方案设计及攻击防范技术(制造企业园区网)
服务器·网络
苏宸啊3 小时前
Reactor反应堆模式
linux
懂点Tech3 小时前
ctfshow MISC入门图片篇(信息附加):misc21解题思路
网络
骇客野人4 小时前
Linux 全套实操方案(含目录详解、文件传输、日志、文件规范、环境变量、Java 应用部署运维)
java·linux·运维
_菜鸟果果4 小时前
高德地图 JS API 2.0 在国产 Linux/ARM 电脑上渲染失败 的排查与解决
linux·开发语言·javascript
GeW4 小时前
飞行汽车交付阿联酋,真正先“起飞”的是低空数据基座——红帽Linux与数据库高可用为什么成了低空运营的隐形跑道
linux
CS_Zero4 小时前
计算机网络通信基础2:带宽和吞吐量、客户端和服务器、网络基础设施
网络·计算机网络
盛世宏博智慧档案4 小时前
算力机房多协议融合:Modbus TCP 温湿度变送器与 SNMP 设备混合组网方案
网络·网络协议·tcp/ip·传感器·温湿度