很多人在学习网络编程时会写一个"一锤子买卖"的客户端:connect 成功,发几条消息,close,结束。这在课程作业里完全够用,但一旦面临真实的客户端软件——比如游戏客户端、聊天工具、上报数据的守候进程——你马上就会撞上一堵墙:服务器会崩溃、会重启、会被运维拔了网线,而你的客户端如果只会 connect 一次,就永远站不起来了。

这一篇我们专门拆解"断线重连"这件事。我会从 connect 失败为什么发生讲起,讲到连接断开之后客户端如何"发现"、如何设计一套带退避和次数上限的重连策略,再补上 TCP 自带的心跳 keepalive 与更常用的应用层心跳,最后落到一份能真正编译运行、带状态机的重连客户端完整代码上。

这会是一篇很长、也比较"硬核"的文章,但每一段都能直接指导你写生产级代码。准备好了,我们就从最基本的 connect 讲起。

一次 connect 究竟发生了什么

先回忆一下 connect 的底层本质。它不是一个普普通通的函数调用,而是让操作系统代表你的进程,去和对端完成一次 TCP 三次握手(TCP three-way handshake)。站在客户端的视角,这次握手大致是:

  1. 你的进程调用 connect(),内核向对端发送一个 SYN 报文,意思是"我想和你建立连接";
  2. 对端如果愿意,回一个 SYN+ACK;
  3. 你的内核收到后,再回一个 ACK,连接建立。

当这段握手在操作系统层面全部顺利走完,connect() 才会返回 0,你的 _sockfd 才真正变"活",可以去 write/read 了。

把握手的画面记牢,后面讲失败原因就顺理成章了——因为握手任何一个环节断了,connect 都会失败;而不同的断法,对应的失败原因(errno)也不同。

也要提前说一句可能让你疑惑的点:connect 的第一个参数是 socket 返回的文件描述符,但我们并没有主动调用 bind。这是允许的——当发起连接需要一个本地地址和端口时,操作系统会在 connect 内部自动为你做一次隐式的本地绑定,选择本机的一个空闲端口作为源端口。所以客户端通常不需要(也不会)显式 bind,这就是课代码里那句"自动进行 bind 哦"的含义。只有服务端才必须显式 bind 到它要监听的端口上。

思考题:为什么客户端源码里看不到 bind,却还能有端口号去访问服务端?

解答:connect 发起握手前,内核会为这个还没有本地地址的套接字自动选择一个本机可用的 IP 与一个随机的空闲端口(常被称为"临时端口",ephemeral port)作为源地址和源端口,并在后续报文中使用。这一过程由内核自动完成,不需要你在用户态显式调用 bind。相对的,服务端要被人稳定地找到,就必须把自己"钉"在固定端口上,所以服务端必须显式 bind。一句话:客户端靠内核随机分配临时端口,服务端靠显式 bind 固定监听端口。

connect 会失败成什么样:errno 里藏着的真相

connect 失败时返回 -1,同时把错误码写进全局变量 errno。不同的失败场景对应不同的 errno,这是排障的第一手线索。下面把最常见的情形挨个讲清楚。

服务端没起来:ECONNREFUSED

ECONNREFUSED(Connection refused,数字是 111)大概是初学者最常撞上的一个。它的含义是:你的 SYN 报文确实送到了目标主机的某个端口,但那里根本没有进程在监听,于是内核直接回了一个 RST(reset,重置)报文,把你的握手请求怼了回来,connect 立刻失败。

这等价于你敲门,但门里压根没人,连"请稍后"的空档都没有,对面直接"啪"地把门甩上。ECONNREFUSED 通常是快速且确定的失败——主机在线、端口可达,但服务没跑。

思考题:ECONNREFUSED 一定是"服务端进程死了"吗?还有哪些情况会产生它?

解答:不一定。ECONNREFUSED 的本质是"目标端口收到了 SYN 但拒绝连接",常见的来源包括:1)目标主机的目标端口上没有进程在监听(服务真没起,或被防火墙策略直接丢弃+回 RST);2)被对端防火墙规则明确丢弃连接请求并发送 RST;3)对端收到报文后因资源或策略直接重置。所以看到这个错误,思路应当是"目标端口不可用",而不一定是"进程崩溃"——也可能是它压根没在这台机器、这个端口上监听,或者被安全策略拦了。

对方"不在家":超时 ETIMEDOUT

ETIMEDOUT(Connection timed out,数字是 110)是另一个典型。它和 ECONNREFUSED 的区别在于:这次你的 SYN 送出去了,但石沉大海,没有任何回音。可能是网络不通、对端主机宕机、或中间某个路由器把报文悄悄丢弃而防火墙策略又不回 RST。

内核会在超时后重发 SYN(Linux 上默认会重试 6 次,即 tcp_syn_retries),全部无响应后 connect 才返回 -1,errno 置为 ETIMEDOUT。这个过程是慢的——默认可能要好几十秒甚至更久。想象你敲门,门内没回应,你站在门外"咚咚咚"敲了好几分钟,最终放弃。

网络不通:ENETUNREACH 等

还有一种情况:对端主机所在网络根本不可达,这时可能是 ENETUNREACH(Network is unreachable,网络不可达)或 EHOSTUNREACH(Host is unreachable)。通常出现在路由表里根本没有去往目标网络的路径时。这类错误多见于本机网络配置问题,或是目标 IP 是私有网段而你压根不在同一网络里。

统一的理解方式

把上面几种放在一起,你就有了一个清晰的心智模型:connect 失败 = 三次握手的某个环节断了。

  • 端口没人监听 → 对方回 RST → ECONNREFUSED(快)
  • 报文发出去没回音 → 重试后放弃 → ETIMEDOUT(慢)
  • 路由都不通 → ENETUNREACH / EHOSTUNREACH

思考题:同样是服务端不可用,为什么 ECONNREFUSED 往往立刻返回,而 ETIMEDOUT 要等很久?

解答:因为它们来自不同的 TCP 行为。ECONNREFUSED 是对方主机主动回了一个 RST,收到了明确拒绝,内核自然立刻把结果给你;而 ETIMEDOUT 是 SYN 发出后没有任何响应,内核无法得知对端状态,只能按约定重发 SYN、等待一个较长的超时才能判断"真没戏"。所以只要是"对面的主机还活着、只是端口没人接"就是快的拒绝;只要"对端失联"就是慢的超时。

断线后如何察觉:read 与 write 的返回值

服务端可能在我们连接成功之后、通信到一半时突然崩掉。此时已经建立好的连接,客户端怎么知道它断了?答案藏在 read 和 write 的返回值里。这里的细节非常反直觉,值得掰开揉碎。

情况一:对端"优雅地"关闭——read 返回 0

如果服务端进程打算关连接,它通常会先调用 close,这会向客户端发一个 FIN 报文,完成 TCP 四次挥手的前半段。FIN 到达客户端后,内核知道"对端不会再给我发数据了"。

此时客户端 read 的返回值为:

  • > 0:正常读到长度那么多字节;
  • == 0:对端已经关闭了写方向(发了 FIN),这是"连接被对方正常关闭"的信号;
  • < 0:出错。

所以 read 返回 0 是非常重要的一条判断:它不叫"出错",它叫"对方关门了"。这是我们能立刻、确定地知道连接已经不能继续用的最可靠信号。课代码里 else if (m == 0) 的分支就在处理这个情况——把它设为 DISCONNECTED,进入重连。

情况二:对端"粗暴地"消失——read 出错

如果服务端是直接崩溃、断电、或者网络瞬间断开,它根本没来得及发 FIN。此时客户端这边的情况是:连接看起来"还活着",本地内核还认为连接是好的。你调用 read 会是什么结果?分两种:

  • 如果对方所在地路由器回了 RST,read 返回 -1,errno 往往是 ECONNRESET(connection reset by peer,数字 104);
  • 如果连 RST 都没有(纯粹的物理断网),read 可能会一直阻塞在那里,既不到 0 也不报错——因为内核不知道连接已死。

这就是断线检测真正难的地方:"对方不吭声"不等于"对方不在了",也许只是睡着了。 这个问题后面讲到 keepalive 和心跳时再来解。

情况三:写方向——write 也会暴露断线

read 能发现问题,write 也一样。但你不会"立刻"看到——因为 write 只是把数据拷进内核的发送缓冲区就算完成了,真正的传输是异步的。这意味着:

  • 第一次往一个刚断开的连接 write,数据进缓冲区了,write 可能返回成功(>0),你啥也没发现;
  • 但数据要真的发出去时,内核发现对方不可达,会尝试重传;最终重传失败或收到 RST,后面的 write 才会失败,报 EPIPE 或 ECONNRESET。

所以 write 的报错是"滞后的",它不能用来做及时的断线检测。而它还有个更狠的杀手,单独拿出来讲。

思考题:read 返回 0 和返回负数,在语义上有什么本质区别?分别应该怎么处理?

解答:返回 0 表示"对端发送了 FIN,正常关闭连接",它是一种确定的、可预期的状态,不是错误,正确的做法是把它当作"这条连接寿终正寝"来对待(关闭 fd、视业务决定是否重连)。返回负数才是错误,具体要看 errno:ECONNRESET 表示对方收到了数据但回了个 RST(通常因对端崩溃残留数据被丢弃),EAGAIN/EWOULDBLOCK 在非阻塞模式下表示"暂时没数据可读"(是正常情况,不该当错误)。区分这两种,是写出健壮网络代码的第一步。

SIGPIPE:那个悄悄杀掉你进程的信号

write 到一条已经对端关闭的连接上,Linux 还会触发一个默认很致命的动作:发送 SIGPIPE 信号。SIGPIPE 的默认行为是终止进程。

这造成一个非常经典、也极其隐蔽的坑:你的程序写得很完整,有各种错误处理,但一碰到"对端已关闭,你还往它写数据"的场景,进程直接没了,连 write 返回负数的机会都没有——因为信号比返回值的优先级高得多,进程先在信号处理里被杀了。

解决的办法有两个:

  1. 在进程启动时屏蔽或忽略 SIGPIPE:
#include <csignal>   // 引入信号相关头文件,提供 signal、SIGPIPE 等
 
// 把 SIGPIPE 信号的处理方式设成 SIG_IGN(忽略),
// 这样之后写已断开连接时进程不会被默认行为杀死,
// write 才能以返回 -1 的方式把错误交还给我们处理
signal(SIGPIPE, SIG_IGN);
  1. 在 send 系统调用上加 MSG_NOSIGNAL 标志位:
// send 的 flags 传 MSG_NOSIGNAL,表示发送失败时不要产生 SIGPIPE 信号,
// 而是用返回值和 errno 告诉我们结果,从而避免进程被信号杀死
ssize_t n = send(sockfd, buf, len, MSG_NOSIGNAL);

绝大多数网络库和服务器框架都会在启动时忽略 SIGPIPE。你在写客户端重连代码时,也建议在 main 一开始就 signal(SIGPIPE, SIG_IGN);,让 write/send 的错误能走正常的返回值分支,而不是让进程无声无息地消失。

思考题:我刚往断开的连接上 write,第一次为什么没报错,第二次进程直接死了?

解答:这是"发送缓冲区 + 信号滞后"共同导致的。第一次 write 时,数据被成功拷进了内核发送缓冲区,write 立即返回成功,所以看不出问题;随后内核尝试把数据发出,才发现对端早已关闭,此时内核回 RST 并往你的进程投递 SIGPIPE(前提是你没有忽略它)。于是第二次 write 时,SIGPIPE 的默认处理直接终止了进程,你根本等不到 write 的返回值。可见:SIGPIPE 的出现本身就说明"连接已经死亡",防止它对进程造成致命伤害,就是要忽略它或使用 MSG_NOSIGNAL。

复用 socket 还是重新 open?

在写重连代码之前,必须先搞清楚一个关键决策:重连时,是继续用当初那个 _sockfd,还是重新 socket() 一个新描述符?

结论先给:标准、稳妥的做法永远是重新 socket()。 原因在于,TCP 的一次连接状态是和这个 socket 描述符强绑定的,而且是一次性的:

  • 一旦 connect 走入失败流程,这个 socket 已经绑定过本地端口、并且带着一段"连接尝试失败"的残留状态(可能是半开、可能是刚收到 RST)。在同一个 fd 上立刻再次 connect,很可能直接得到 EINVAL(Invalid argument,无效参数),而不是重新开始握手;
  • 即使 connect 失败返回负值后 fd 的状态看似能再用,其底层的又一次握手尝试结果也高度依赖内核实现与失败发生的具体时机,完全不值得赌;
  • TCP 本身还规定了一个 socket 描述符对应的本地四元组(源 IP、源端口)在 TIME_WAIT 等阶段不能立即复用,这更增加了"复用旧 fd"的不确定性。

所以,正确姿势是在每次(初次 + 每次重连)时都新建 fd,connect 失败就 close 掉再重来。我下面给的重连代码里,Reconnect 的每次循环都调用 CreateSocket() 新建描述符,就是这个原因。这也呼应了课件里 Connect 失败时调用 Disconnect()(把 _sockfd 复位为 -1)的设计——它在语义上就是在告诉系统"这条连接作废,我准备走新的流程了"。

思考题:connect 失败后在同一 fd 上再次 connect,为什么可能得到 EINVAL,而不得不重建 socket?

解答:因为这个描述符在一次失败的连接尝试后,TCP 状态机里已经留下了"该套接字试图建立过连接但失败/收到 RST"的记录,其本地四元组(尤其源端口)也因此被占用或处于不可立即复用的状态。系统检测到你要对这样一个已"脏"的套接字重新发起连接时,就会直接拒绝而非重启握手。配置了 SO_REUSEADDR 也无法绕过这一个"fd 内的状态残留",因为它管的是"端口重用"层面,而不是"同一 fd 上重连"。因此干净的工程做法是关闭旧描述符并重新创建,让每一次握手都从"全新的套接字"开始。

SO_KEEPALIVE:内核替你检查死连接

现在回到那个老大难:连接"看起来活着"但其实对方已经不在了,怎么检测?正规答案(之一)是 TCP 自带的心跳机制,叫 keepalive,对应的 socket 选项是 SO_KEEPALIVE。

它做的事情是:当连接在一个时间窗口内没有任何数据往来时,内核会自己向对端发送探测报文(probe),并按策略重试,若一直无响应就判定连接无效。开启它的方式很简单:

#include <sys/types.h>
#include <sys/socket.h>   // 提供 socket、setsockopt 与相关常量
 
int keepAlive = 1;                                     // 1 表示开启 SO_KEEPALIVE
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE,
           &keepAlive, sizeof(keepAlive));             // 设置到 socket 上

Linux 上还可以精调三个参数(定义在 <netinet/tcp.h>),用来改默认的探测节奏:

#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>   // TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT 所在头文件
 
int idle = 30;    // 连续 30 秒没有任何数据往来时,才开始发第一个探测包
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
 
int intvl = 5;    // 每次探测包之间的间隔为 5 秒
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
 
int cnt = 3;      // 连续 3 次探测没有回应,才判定对方死亡
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));

不过要泼一盆冷水:默认情况下这个机制几乎没啥用。 在多数 Linux 发行版上,tcp_keepalive_time 默认是 7200 秒(整整两个小时),探测间隔 tcp_keepalive_intvl 默认 75 秒、tcp_keepalive_probes 默认 9 次。也就是说,默认配置下需要两个小时才发第一个探测包,真要等它判死一条假连接,黄花菜都凉了。这也是为什么很多教程说 "keepalive 不适合做快速断线检测"。

你可以用下面命令查看本机默认值:

# 查看 TCP keepalive 的三个默认参数(单位:秒)
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes

若要让它有实战价值,要么调小系统参数(全局影响所有 socket,要谨慎),要么像上面那样为单个 socket 用 TCP_KEEPIDLE 等选项精细控制。但即便精细调过,TCP keepalive 也只是"检测不活跃的死连接"的一种手段,它和下面要讲的应用层心跳定位不同、侧重点不同。

思考题:既然 TCP 自带 keepalive,为什么生产环境往往还要自己再做一层应用层心跳?

解答:原因有几条:1)默认探测太慢(默认 idle 两小时),不适合对延迟敏感的业务;2)系统层面修改 tcp_keepalive_time 是全局的,会影响这台机器上所有 socket,代价高、风险大;3)TCP keepalive 只能告诉你"TCP 层这个连接还通着",却无法保证"服务端的应用层还活着、还能正常处理你的业务请求"——比如服务端进程卡死但 TCP 栈还在正常工作,keepalive 探测照样通过;4)keepalive 探测不由你控制,没法携带业务语义(比如把超时时间做成业务参数)。所以需要快速、可控、能反映"应用是否真正可用"的判活时,人们会额外用应用层心跳(自己的心跳包 + 自己的超时判定)。

应用层心跳:自己掌控断线判定的节奏

既然 TCP keepalive 那么慢、又只反映 TCP 层,实战中最常用的其实是应用层心跳(heartbeat)。思路朴素:双方约定,在一段时间内没有任何业务数据时,由一方定时发送一个"我还活着"的探测包(heartbeat packet),对方在收到后回一个确认包(或无需确认)。如果在约定时间内既没收到业务数据、也没收到心跳回报,就断定连接已不可用。

实现心跳有两种常见手段,它们在效果上互补:

  • 主动探测 + 接收超时:用一个定时器定时向对端发送心跳包;同时给 read/recv 设置接收超时,一旦超过 N 秒没有任何数据(业务数据或心跳回报)进来,就判定对端失联,触发重连。
  • 被动看门狗(watchdog):客户端自己不主动发包,而是在每次收到任何对端数据后"喂狗"(刷新一个最近活跃时间戳);由一个定时检查线程发现"距上次收到数据已超过阈值"就宣告连接失效。

设置接收超时是应用层心跳的关键基础设施,它把一个可能无限阻塞的 read 变成了"最多等 N 秒":

#include <sys/time.h>     // struct timeval
#include <sys/types.h>
#include <sys/socket.h>   // setsockopt、SO_RCVTIMEO
 
struct timeval timeo;
timeo.tv_sec  = 30;      // 超过 30 秒没有数据进来就超时返回
timeo.tv_usec = 0;       // 微秒部分为 0
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO,
           &timeo, sizeof(timeo));   // 应用于后续的所有 recv/read

设置之后,read 在超时未收到数据时会返回 -1,且 errno 为 EAGAIN 或 EWOULDBLOCK(表示"暂时没数据,但还不算错误")。于是你的循环逻辑可以写成:

ssize_t m = read(sockfd, buf, sizeof(buf));
if (m > 0) {
    // 收到数据(可能是业务数据,也可能是心跳回报),刷新"最近活跃"时间戳
    UpdateLastActive();
} else if (m == 0) {
    // 对端发了 FIN,连接被对端正常关闭,立即进入重连流程
    HandleDisconnect();
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
    // 接收超时:太久没数据,判定对端可能失联,进入重连流程
    HandleTimeoutReconnect();
} else {
    // 其他真实错误,如 ECONNRESET
    HandleError();
}

这里最需要记牢的坑是:EAGAIN/EWOULDBLOCK 不是错误,它只是"非阻塞/超时模式下暂时没拿到数据"。新手常把它当成错误直接返回,结果连接一正常空闲就误判、疯狂重连。一定要把它和真正的错误区分开。

对比一下就能理解分工了:

  • TCP keepalive(SO_KEEPALIVE):内核级、免费、但默认极慢且只看 TCP 层是否通;
  • 应用层心跳:你自己定节奏、可调超时、能反映"应用是否真的还活着",但要多写代码、占用一点点带宽(心跳包通常都很小)。

对绝大多数客户端重连场景,应用层心跳是主力,TCP keepalive 通常作为"最后兜底"或与心跳配合使用。

思考题:给 read 设置了 SO_RCVTIMEO 后,超时返回的 errno 是什么?为什么说它不是错误,反而是一种常见的误判来源?

解答:超时时 read 返回 -1,errno 为 EAGAIN(在 Linux 上数值与 EWOULDBLOCK 相同,常写作 EAGAIN/EWOULDBLOCK)。它不是错误,而是内核在"非阻塞或带超时"模式下告诉你"现在没有可读数据,请稍后再来"的一种正常结果。把它误当错误处理是经典坑之一:比如连接空闲时没有数据,程序以为对端断了就立刻重连,从而陷入无意义的"连接-空闲-误判-重连"循环。正确做法是把它作为"触发一次心跳检查 / 计数空闲时长"的信号,只有连续多次超时(超过你自己设定的判活阈值)才真正判定失联。

重连策略:间隔、次数与退避

断线检测的目的,是为了触发重连。而重连不是无脑 while(true) 一直打,那样会把自己和对端都打成一个"重连风暴"。一个合格的重连策略至少要设计两件事:间隔和上限。

立即重连的问题

最朴素的想法是"断了立刻重连"。但立刻重连往往没用——服务器刚崩,马上要求建立连接,多半还是失败。更糟的是,失败越频繁,攻击性越强,不仅白白浪费 CPU 和网络资源,还可能给自己和对端造成压力。所以重连必须要"等一等"。

固定间隔

最简单实用的就是固定间隔:断了之后,每隔 n 秒重试一次,累计到上限次数后放弃。课件里的 Retry 就是典型的固定间隔实现,_retry_interval 默认 1 秒,_max_retries 默认 5 次。

固定间隔的好处是简单、行为可预期;坏处是它不懂"缓和"——即便服务器要很久才能恢复,它也始终保持同一个频率去撞门。

指数退避(Exponential Backoff)

更成熟的做法是指数退避。每次失败后,间隔时间翻倍:1 秒、2 秒、4 秒、8 秒……这样一开始很积极(服务器可能马上回来),后面越来越克制(服务器可能长时间不在),既保证了"尽快发现服务恢复",又避免了持续高频打扰。

为了保护程序自身和其他资源,指数一般还要封顶(cap),比如最多加到 30 秒就不要再翻倍了;有些实现还会加一点随机抖动(jitter),避免大量客户端同时重试时恰好步调一致、在同一个时间点一起扑向服务器(所谓"惊群/重连风暴")。

次数上限:该认输时认输

重连不能永远进行下去,必须设置上限。课件里的 _max_retries 就是干这个的。重试次数用尽后,程序进入 CLOSED 状态,可以选择告知用户、退出,或者做别的降级处理。无限重试在很多场景是危险的:进程会一直占着资源却毫无进展,用户也得不到任何反馈。

思考题:为什么要用指数退避(间隔翻倍)而不是一直用固定 1 秒的间隔?"封顶"和"抖动"又是为了解决什么?

解答:固定间隔忽略了"服务器恢复时长不确定"这一现实:如果服务器 5 分钟才能恢复,固定 1 秒重连会造成 5 分钟内几百次无效尝试,消耗双方资源。指数退避让重试频率随连续失败次数递减(1、2、4、8 秒…),既能在服务器短暂抖动时快速重连(一开始间隔小),又能在长时间宕机时避免高频骚扰。封顶(上限)防止间隔无限放大、避免恢复后还要白白等很久;抖动(jitter)则把一批同时掉线的客户端在重试时间上错开,避免它们在同一时刻集体重连、把刚恢复的服务端瞬间压垮。

用状态机组织连接状态:一份可编译的重连客户端

前面講了那么多原则,现在用一份真正能编译运行、带状态机的客户端把它们落地。这个例子的主体思路来自课件,我在此基础上做了几处修正,让它在真实场景下更健壮:

  • 每次重连都新建描述符(不复用旧 fd);
  • 重连从固定间隔升级为指数退避;
  • 忽略 SIGPIPE,避免进程被信号误杀;
  • 重连失败后进入 CLOSED 正常退出,而不是 exit(1) 粗暴终止。

所谓状态机,就是用一个显式的状态枚举(NEW、CONNECTING、CONNECTED、DISCONNECTED、CLOSED),配合一个 switch 循环,根据当前状态决定下一步该做什么。这样一套流程耦合在一起的高级状态,被拆解成"当前是什么状态 → 该做什么 → 切换到什么状态",逻辑清晰、易扩展。

完整代码 TcpClient.cc 如下(逐行注释):

// TcpClient.cc —— 一个带状态机、支持自动重连的 TCP 客户端
// 编译:g++ -std=c++11 -o tcp_client TcpClient.cc
#include <iostream>      // cout / cerr / endl 输出流
#include <string>        // string 字符串
#include <cstring>       // strerror:把 errno 转成可读字符串
#include <cerrno>        // errno:全局错误码变量
#include <cstdlib>       // atoi:字符串转整数
#include <cstdint>       // uint16_t 无符号16位整数
#include <unistd.h>      // socket/connect/close/read/write/sleep 等系统调用声明
#include <sys/socket.h>  // 提供 socket、sockaddr、AF_INET 等
#include <netinet/in.h>  // 提供 sockaddr_in 结构体
#include <arpa/inet.h>   // 提供 inet_pton:字符串 IP 转二进制
#include <csignal>       // signal、SIGPIPE、SIG_IGN
 
using std::cout;
using std::cerr;
using std::endl;
using std::string;
 
// 打印命令行的用法说明
void Usage(const string& process)
{
    cout << "Usage: " << process << " server_ip server_port" << endl;
}
 
// 强类型枚举(C++11):描述一条连接所处的生命周期状态
enum class Status
{
    NEW,           // 新建状态:什么都没做,准备首次连接
    CONNECTING,    // 正在尝试握手:用于查询连接中间态
    CONNECTED,     // 已连接:可以收发数据了
    DISCONNECTED,  // 掉线或首次连失败:等待重连
    CLOSED         // 重连次数用尽或正常退出:彻底放弃
};
 
// 封装一条"面向单个服务端"的连接,负责建连、断线检测、自动重连
class ClientConnection
{
public:
    // 构造:记录服务端 IP/端口,并给重连策略设初始值
    ClientConnection(uint16_t serverport, const string& serverip)
        : _sockfd(-1),                      // 初始没有 fd
          _serverport(serverport),          // 服务端端口
          _serverip(serverip),              // 服务端 IP
          _retry_interval(1),               // 首次重连等待 1 秒
          _max_retries(5),                  // 最多重试 5 次
          _max_interval(30),                // 退避间隔封顶 30 秒
          _status(Status::NEW)              // 初始状态:NEW
    {}
 
    // 创建一份全新的 TCP 流式 socket,失败返回 false
    bool CreateSocket()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd < 0)
        {
            cerr << "socket 创建失败: " << strerror(errno) << endl;
            return false;
        }
        return true;
    }
 
    // 用当前 _sockfd 发起一次 connect;失败返回 false 并打印错误
    bool DoConnect()
    {
        struct sockaddr_in server;              // 对端地址结构
        memset(&server, 0, sizeof(server));     // 先清零,避免残留垃圾数据
        server.sin_family = AF_INET;            // 统一用 IPv4
        server.sin_port = htons(_serverport);   // 端口转成网络字节序(大端)
        // inet_pton:把点分十进制字符串 IP 解析成 4 字节二进制网络序地址
        // 返回值 >0 成功;==0 表示字符串非法;<0 表示地址族不支持
        if (inet_pton(AF_INET, _serverip.c_str(), &server.sin_addr) <= 0)
        {
            cerr << "inet_pton 解析 IP 失败: " << _serverip << endl;
            return false;
        }
 
        // connect 发起三次握手;返回 0 表示成功,-1 表示失败(看 errno)
        int n = connect(_sockfd,
                        (struct sockaddr*)&server,   // 要转成通用 sockaddr*
                        sizeof(server));
        if (n < 0)
        {
            cerr << "connect 失败, errno=" << errno
                 << " (" << strerror(errno) << ")" << endl;
            return false;
        }
        return true;
    }
 
    // 首次连接:建 socket -> 握手。失败则关闭描述符并置为 DISCONNECTED
    void Connect()
    {
        if (!CreateSocket())
        {
            _status = Status::CLOSED;      // 连 socket 都建不了,直接放弃
            return;
        }
        if (!DoConnect())
        {
            Disconnect();                  // 关掉这个失败的 fd
            _status = Status::DISCONNECTED;// 转入"待重连"状态
            return;
        }
        _status = Status::CONNECTED;       // 握手成功
    }
 
    // 核心:自动重连。每次重连都新建 fd,指数退避,带次数上限
    void Reconnect()
    {
        _status = Status::CONNECTING;
        int interval = _retry_interval;    // 从 1 秒开始
        for (int count = 0; count < _max_retries; ++count)
        {
            // 每次都新建描述符,绝不复用旧 fd
            if (!CreateSocket())
            {
                cout << "重连时创建 socket 失败" << endl;
                break;
            }
            if (DoConnect())
            {
                _status = Status::CONNECTED;
                cout << "重连成功" << endl;
                return;
            }
            Disconnect();   // 连接失败,关掉刚建的 fd 再等下一轮
            cout << "重连次数: " << (count + 1)
                 << ", 最大上限: " << _max_retries
                 << ", 等待 " << interval << " 秒" << endl;
            sleep(interval);   // 退避等待
            // 指数退避并封顶:interval*2 一旦超过上限就保持在上限,不再翻倍
            interval = (interval * 2 > _max_interval)
                           ? _max_interval
                           : interval * 2;
        }
        _status = Status::CLOSED;   // 次数用尽仍未成功,认输
    }
 
    int     SocketFd()  const { return _sockfd; }   // 暴露 fd 给上层收发
    Status  GetStatus() const { return _status; }   // 查询当前状态
 
    // 简单的收发流程:从控制台读一行发给服务端,再读回 echo 并打印
    // 返回后,调用方应重新根据 GetStatus() 决定是重连还是退出
    void Process()
    {
        string inbuffer;
        cout << "Please Enter# ";
        while (getline(cin, inbuffer))   // 从标准输入读一行
        {
            if (inbuffer.empty())        // 空行忽略,继续等待输入
            {
                cout << "Please Enter# ";
                continue;
            }
 
            // 发送给服务端;n<0 说明发送失败
            ssize_t n = write(_sockfd, inbuffer.c_str(), inbuffer.size());
            if (n < 0)
            {
                cout << "write 返回 " << n << ", errno=" << errno
                     << " (" << strerror(errno) << ")" << endl;
                _status = Status::DISCONNECTED;  // 发送失败,转入待重连
                break;
            }
 
            // 读取服务端的 echo 回显
            char buffer[1024] = {0};     // 接收缓冲区
            ssize_t m = read(_sockfd, buffer, sizeof(buffer) - 1);
            if (m > 0)
            {
                buffer[m] = 0;           // 手动补 0,便于当作字符串打印
                cout << "echo message -> " << buffer << endl;
            }
            else if (m == 0)
            {
                // 关键判断:read 返回 0 表示对端关闭了连接
                cout << "对端关闭连接,准备重连" << endl;
                _status = Status::DISCONNECTED;
                break;
            }
            else
            {
                cout << "read 返回 " << m << ", errno=" << errno
                     << " (" << strerror(errno) << ")" << endl;
                _status = Status::DISCONNECTED;  // 读出错也视为断开
                break;
            }
            cout << "Please Enter# ";
        }
        // getline 读到文件末尾(Ctrl+D)会退出循环
        if (cin.eof())
        {
            cout << "用户输入结束,正常退出" << endl;
            _status = Status::CLOSED;
        }
    }
 
    ~ClientConnection() { Disconnect(); }   // 析构时确保关闭 fd
 
private:
    // 关闭描述符并把 fd 复位,供重连/析构复用
    void Disconnect()
    {
        if (_sockfd != -1)
        {
            close(_sockfd);
            _sockfd = -1;
        }
    }
 
    int      _sockfd;           // 当前连接的文件描述符,-1 表示没有
    uint16_t _serverport;       // 服务端端口
    string   _serverip;         // 服务端 IP
    int      _retry_interval;   // 重连起始间隔(秒),指数退避的底数
    int      _max_retries;      // 最大重试次数
    int      _max_interval;     // 退避间隔封顶(秒)
    Status   _status;           // 当前连接状态
};
 
// 最外层封装的"客户端":持有连接对象,用循环驱动状态机运转
class TcpClient
{
public:
    TcpClient(uint16_t serverport, const string& serverip)
        : _conn(serverport, serverip)
    {}
 
    // 状态机主循环:根据当前状态决定动作,直到 CLOSED 才退出
    void Execute()
    {
        while (true)
        {
            switch (_conn.GetStatus())
            {
            case Status::NEW:          // 从未连过 -> 首次连接
                cout << "首次连接..." << endl;
                _conn.Connect();
                break;
            case Status::CONNECTED:    // 已连接 -> 进入收发流程
                cout << "连接成功, 开始进行通信." << endl;
                _conn.Process();       // Process 内部一旦发现断开会改成其他状态
                break;
            case Status::DISCONNECTED: // 掉线 -> 自动重连
                cout << "连接失败或对方掉线,开始重连." << endl;
                _conn.Reconnect();
                break;
            case Status::CLOSED:       // 重连失败或正常退出 -> 结束
                Disconnect();
                cout << "重连失败, 退出." << endl;
                return;
            default:                   // 其他情况(CONNECTING 等)暂不处理
                break;
            }
        }
    }
 
private:
    // 对外不暴露 fd;为完整性提供一个供内部使用的清理入口
    void Disconnect() {}
 
    ClientConnection _conn;   // 简单组合一个连接对象,就构成了客户端
};
 
// 程序入口:./tcp_client server_ip server_port
int main(int argc, char* argv[])
{
    if (argc != 3)               // 参数必须是 IP 和端口两个
    {
        Usage(argv[0]);
        return 1;
    }
 
    // 忽略 SIGPIPE:防止 write 到已断开连接时进程被默认信号杀死,
    // 这样错误才能以返回负值的方式交还给我们处理
    signal(SIGPIPE, SIG_IGN);
 
    string    serverip   = argv[1];                       // 服务端 IP
    uint16_t  serverport = (uint16_t)atoi(argv[2]);       // 服务端端口
    TcpClient client(serverport, serverip);               // 组装客户端
    client.Execute();                                     // 跑状态机
    return 0;                                             // 正常退出
}

这段代码在主流程上还原了课件思想(状态机 + 重连),并修正了几处实战问题。你要特别留意 Process() 里 read 返回 0 的处理——这是断线检测的入口,它把从"已连接"到"掉线需重连"的状态转换显式画了出来。

思考题:在 Reconnect() 的循环里,为什么每次都要重新 CreateSocket() 而不是复用上次的 _sockfd?反之,如果直接复用,最可能踩到什么坑?

解答:因为上次连接的描述符已经带着失败连接的残留状态(可能刚收到 RST、本地四元组被占用),在同一 fd 上再次 connect 很可能得到 EINVAL 而无法完成新的握手;即使偶尔成功,结果也依赖内核实现细节、不可靠。所以最稳妥的做法是关掉旧描述符、新建一个,让每次握手都从干净状态开始。若直接复用,最常见的坑正是"connect 返回 EINVAL、程序以为网络出问题而反复重试",调试半天才发现是 fd 复用导致的。

跑起来:一台服务端、一个会重连的客户端

光看代码不过瘾,我们把实验跑起来,亲眼观察重连发生在什么时候。为此需要一个最简单的 echo 服务端,这边的服务端代码 EchoServer.cc 如下:

// EchoServer.cc —— 一个极简的 echo 服务端:客户端发什么,就原样回什么
// 编译:g++ -std=c++11 -o echo_server EchoServer.cc
#include <iostream>      // 输入输出
#include <cstring>       // strerror / memset
#include <cstdlib>       // atoi
#include <cstdint>       // uint16_t
#include <unistd.h>      // close / read / write
#include <sys/socket.h>  // socket / bind / listen / accept
#include <netinet/in.h>  // sockaddr_in
#include <arpa/inet.h>   // inet_pton
#include <csignal>       // signal / SIGPIPE / SIG_IGN
 
using std::cout;
using std::cerr;
using std::endl;
 
int main(int argc, char* argv[])
{
    // 忽略 SIGPIPE:对端断开后我们回显 write 时也不至于被信号杀死
    signal(SIGPIPE, SIG_IGN);
 
    if (argc != 3)                      // 用法:./echo_server ip port
    {
        cerr << "Usage: " << argv[0] << " ip port" << endl;
        return 1;
    }
 
    // 创建监听 socket
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd < 0)
    {
        cerr << "socket: " << strerror(errno) << endl;
        return 1;
    }
 
    // SO_REUSEADDR:允许 TIME_WAIT 状态下的端口快速重用,方便反复起服务
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
 
    // 组装本机监听地址
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port   = htons((uint16_t)atoi(argv[2]));
    inet_pton(AF_INET, argv[1], &addr.sin_addr);
 
    // 绑定并开始监听
    if (bind(lfd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
    {
        cerr << "bind: " << strerror(errno) << endl;
        return 1;
    }
    if (listen(lfd, 5) < 0)             // 最多排 5 个等待队列
    {
        cerr << "listen: " << strerror(errno) << endl;
        return 1;
    }
    cout << "echo server listening on " << argv[1] << ":" << argv[2] << endl;
 
    // 主循环:接受连接,读到一行就原样回显,直到对端关闭
    while (true)
    {
        int cfd = accept(lfd, nullptr, nullptr);   // 等待并接受一个连接
        if (cfd < 0)
        {
            cerr << "accept: " << strerror(errno) << endl;
            continue;
        }
        cout << "accept a client" << endl;
        char buf[1024];
        ssize_t m;
        while ((m = read(cfd, buf, sizeof(buf))) > 0)   // 有数据就回显
        {
            write(cfd, buf, m);
        }
        close(cfd);           // 对端关闭,收工
        cout << "client closed" << endl;
    }
    return 0;
}

然后编译、跑起来:

# 编译两端程序
g++ -std=c++11 -o echo_server EchoServer.cc
g++ -std=c++11 -o tcp_client TcpClient.cc
 
# 一个终端开服务端,绑在回环地址的 8888 端口
./echo_server 127.0.0.1 8888

再开一个终端启动客户端:

./tcp_client 127.0.0.1 8888

现在你可以做一组实验,亲眼看到重连逻辑被触发:

  1. 先起客户端、后起服务端:客户端初次 connect 时服务端还没监听,会立刻得到 ECONNREFUSED,进入 DISCONNECTED,随后每隔 1、2、4、8、16 秒(指数退避)自动重连。你这时再把服务端起起来,就会看到"重连成功"。
  2. 正在通信时把服务端 Ctrl+C 掉:客户端下一次 read 会返回 0(对端发 FIN 关闭),或 write 报错,随后进入重连流程。
  3. 重连 5 次都失败:客户端打印完次数后状态转为 CLOSED,正常退出而非崩溃。

这些实验把前面所有原理都串了起来:第一次 ECONNREFUSED 是"对方端口没人监听"的快速拒绝;read 返回 0 是对端正常关闭的镇定判断;而重连的退避节奏、次数上限,都能在日志里看得清清楚楚。

思考题:实验中"先起客户端、后起服务端",客户端第一次 connect 返回的是什么错误?为什么服务端一旦启动,客户端不用重启也能自动连上?

解答:第一次 connect 返回 ECONNREFUSED(对端端口尚无监听,内核回 RST 拒绝)。客户端因为把这个失败解析为"待重连"状态并进入 Reconnect(),使用指数退避循环持续尝试新的握手,所以当服务端启动、端口开始监听后,下一次重连的 SYN 就能得到 SYN+ACK,握手成功,自动转为 CONNECTED。这正是"重连代码要在客户端里自己写"的意义所在——进程本身并没有被终结,只是被状态机驱动着反复重新发起连接。

非阻塞 connect:别让连接卡住整个程序

前面所有代码用到的都是阻塞模式下的 connect:调用它之后,当前线程会一直卡在那里,直到握手成功或失败返回。阻塞 connect 有一个很实际的痛点:对端不可达时(比如 ETIMEDOUT),这个 syscall 可能阻塞几十秒甚至更久,你的线程就被"冻住"了。如果这个线程还要响应其他事情(比如同时处理 UI、管理其他连接),这会非常难受。

解决方案是非阻塞 connect。做法通常是:

  1. 先创建 socket,然后用 fcntl 或 setsockopt 把它设为非阻塞;
  2. 再调用 connect,此时它几乎立刻返回。成功会返回 0;如果握手还没完成、但方向可行,会返回 -1 且 errno == EINPROGRESS(in progress,连接进行中);
  3. 之后用 select/poll/epoll 监视这个 fd,等待它变得可写(writeable)——可写意味着连接已经建立(或失败);
  4. 通过 getsockopt(SO_ERROR) 检查到底是成功还是失败、错误码是什么。

EINPROGRESS 是理解非阻塞 connect 的关键,它不是错误,而是一个信号:表示"连接正在后台进行,请稍后关注它的结果"。

下面是一个非阻塞 connect 并配合 select 等待的示意图(只列出关键片段以防篇幅爆炸,整体思路清晰即可):

#include <sys/types.h>
#include <sys/socket.h>
#include <fcntl.h>      // fcntl、F_SETFL
#include <sys/select.h> // select、fd_set
#include <unistd.h>     // close
 
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
 
// 把描述符设为非阻塞:后续 read/write/connect 都不会卡住线程
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
 
struct sockaddr_in server;
// ...(组装 server,同上,略)
 
// 非阻塞下 connect 几乎立刻返回
int r = connect(sockfd, (struct sockaddr*)&server, sizeof(server));
if (r != 0 && errno != EINPROGRESS)
{
    // 真正的失败,比如 ECONNREFUSED
}
 
// 等待连接结果:最多等 3 秒,用 select 观察"可写"事件
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(sockfd, &wfds);
struct timeval tv = {3, 0};   // 3 秒超时
int s = select(sockfd + 1, NULL, &wfds, NULL, &tv);
if (s == 0)
{
    // 3 秒没结果,可以判定连接超时,关闭并进入重连
}
else if (s > 0 && FD_ISSET(sockfd, &wfds))
{
    int err = 0;
    socklen_t len = sizeof(err);
    // 用 SO_ERROR 取出底层结果:0 表示连接成功,非 0 表示对应 errno
    getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &err, &len);
    if (err == 0)
    {
        // 连接成功
    }
    else
    {
        // 连接失败,err 里就是失败原因(如 ECONNREFUSED)
    }
}

非阻塞 connect 的核心价值,是把"等待握手"这个可能长时间阻塞的过程,变成"可被 select/epoll 统一管理的异步事件",让一个线程能同时照顾多路连接。它是生产级网络库(epoll 事件循环)的基础能力之一。

思考题:非阻塞 connect 立即返回 -1 且 errno == EINPROGRESS,是什么意思?之后要如何得知连接到底成了没成?

解答:EINPROGRESS 表示"三次握手尚未完成,内核正在后台异步推进,调用立即返回以便线程不被阻塞"。之后正确的做法是用 select/poll/epoll 监视这个 fd 的可写(POLLOUT/WRITE)事件;当它变得可写时,再用 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 取出底层结果——值为 0 代表连接成功,非 0 则等于真正的错误码(比如 ECONNREFUSED)。注意:不能只看"可写"就断定成功,因为连接失败也表现为可写;必须再查 SO_ERROR 才能区分成功与失败。

小结与自测

我们把"断线重连"从最底层捋到了工程可实现的程度:connect 会因 ECONNREFUSED(端口没人监听)、ETIMEDOUT(对端失联)、ENETUNREACH(网络不通)等不同原因失败;断线要通过 read 返回 0(对端发 FIN 正常关闭)、read/write 出错(RST 或发送失败)来察觉,而要防住 SIGPIPE 默杀进程、得忽略它或用 MSG_NOSIGNAL;重连要用指数退避控制节奏并设次数上限;检测死连接既要靠 TCP 的 SO_KEEPALIVE(默认太慢),更要靠应用层心跳(SO_RCVTIMEO + 判断 EAGAIN);重连时务必重建 socket 而不是复用旧 fd;connect 本身还可以做成非阻塞,配合 select/epoll 管理多路连接。最终,我们用一份带状态机的完整客户端代码,把这些原则全部落地,并且能直接编译运行、在实验里亲眼看到重连发生。

不要只"看懂"上面的原理,强烈建议你把两端代码都敲下来,把"先客户端后服务端""通信中杀服务端""重连满次数"三个实验挨个亲手跑一遍。很多网络编程的感觉,都是从"死一次、看到日志、改了、再死一次"里沉淀出来的。

下面是五道自测题,作为这一篇的收尾。我们一边作答,一边把这篇文章的骨架再拎一遍。

自测一:服务端没监听某个端口,客户端 connect 该端口,最可能立刻返回哪个 errno?含义是什么?

解答:ECONNREFUSED(连接被拒绝,数值 111)。含义是该端口收到 SYN 后没有任何进程监听,对端内核直接回了 RST 报文拒绝了握手请求。它通常是快速的返回,因为对方已经明确拒绝,不需要等待超时。

自测二:read 返回 0 表示什么?和返回 -1 且 errno == EAGAIN 有什么本质区别?

解答:read 返回 0 表示对端发送了 FIN、主动正常关闭了连接,这是"确定且可预期"的关闭信号,不是错误,应把该连接视为结束来清理并决定是否重连。而返回 -1 且 errno == EAGAIN(或 EWOULDBLOCK)是在非阻塞/带超时模式下"暂时没有数据可读"的正常结果,连接本身还好好的,应当继续等待或触发心跳检查,绝不能当成连接断开。

自测三:明明对端已经死机,为什么第一次 write 还可能成功?又为什么接下来进程可能直接消失?

解答:因为 write 只是把数据拷进本地内核的发送缓冲区就返回了,真正的网络发送由内核异步完成;第一次 write 时数据进缓冲区所以返回成功。随后内核尝试发送才发现对端已死,会投递 SIGPIPE 信号;若你没有忽略 SIGPIPE,其默认行为是终止进程,所以进程会"突然消失",而不是让 write 返回负数。忽略 SIGPIPE 或用 MSG_NOSIGNAL 就能让错误以返回值形式交回给你处理。

自测四:为什么重连普遍采用指数退避并设置次数上限,而不是固定短间隔无限重试?

解答:指数退避让重试频率随连续失败次数递增而递减(1、2、4、8 秒…),既保证了服务端恢复后能尽快连上(早期间隔小、积极),又避免了服务端长期宕机时的持续高频无效打扰,减少双方资源消耗。设置次数上限是为了"该认输时认输":避免进程一直占着资源却毫无进展且用户得不到反馈,次数用尽后程序可以进入 CLOSED 状态正常退出或做降级处理。封顶和抖动则是为了不让间隔无限放大、避免大量客户端在同一时刻集体重连压垮刚恢复的服务端。

自测五:TCP 自带 keepalive 和应用层心跳,各自适合什么场景?为什么往往要同时用?

解答:TCP keepalive(SO_KEEPALIVE)由内核免费维护,能判断"TCP 层连接是否还通",但默认参数极慢(默认空闲两小时后才发第一个探测包),且只能反映 TCP 层的活性,无法反映服务端应用是否真正可用。应用层心跳由你完全控制节奏和语义,能快速发现"应用失联"的问题,适合对延迟敏感、需要快速判活的业务。实战中通常两条腿走路:应用层心跳负责主动、快速的判活与断线检测;TCP keepalive 作为兜底,处理内核层面的异常挂起,两者互补。

这五道题如果都能不看资料答对,那这一篇的硬骨头你就啃下来了——断线重连,从原理到实现,你已经不再是"只会发一条消息"的初学者了。