问题

故障情景

  • 业务背景:
    • 技术栈:C++线程池 + FTP 协议长连接
    • 分布式系统中的文件 FTP 同步服务
  • 故障描述:
    • 本地FTP目录同步服务存在非正常进程终止缺陷。当远程FTP服务器发生非计划性停机(服务崩溃/网络中断/强制kill进程)时,本地同步进程发生静默崩溃(无退出状态码、无异常抛出)

故障分析

首先通过打印的 LOG 信息,快速定位到了 tcpSocket 的 Send() 方法中,Send() 方法的实现如下:

ssize_t TcpSocket::Send(const void *buf, int len) const {
if (!connected_) {
return 0;
}
return ::write(skt_, buf, len);
}

添加 try-catch 语句

首先先对其添加了 try-catch 语句尝试捕获异常,发现程序仍然直接退出
事后查阅资料发现,POSIX 的系统调用的错误以返回值出现,因此这里 try-catch 完全无效,应该针对返回值进行 DEBUG

查看系统内核 LOG

既然程序直接退出,没有任何错误出现,这里怀疑有可能是程序抛出错误到了内核层 (程序运行在一个定制版的 Linux 系统上),使用 dmesg 查看内核日志,发现不存在任何相关的错误。
于是尝试更换运行环境,编译到了另一台设备上,发现仍然出现这个错误。

使用 VSCode Debugger 进行单步调试

Send() 处打上了断点,经过调试发现这里出现了 Broken PIPE Error,上网进行相关搜索,得知这是一个信号的相关问题。

对一个对端已经关闭的socket调用两次write, 第二次将会生成SIGPIPE信号, 该信号默认结束进程。

TCP 过程详解

当远程 FTP 服务端发生非正常断开时,TCP 连接状态变化时序:

  1. 正常建立连接阶段
    本地客户端与服务端完成三次握手,connected_ 标志为 true
  2. 服务端异常中断阶段
    服务端进程被强制 kill 或发生网络中断:
    • 服务端未发送 FIN:直接触发 RST(如进程被 kill)
    • 服务端发送 FIN:进入四次挥手流程(如正常关闭)
  3. 客户端首次发送数据阶段
    当本地线程池中的工作线程调用 TcpSocket::Send() 时:
    • 若连接已收到 RST:
      • 第一次 ::write() 将返回 -1errno=ECONNRESET
    • 若连接处于半关闭状态(收到 FIN):
      • 第一次 ::write() 成功返回(数据进入发送缓冲区)
      • 第二次 ::write() 触发 SIGPIPE(内核检测到无效写入)

半关闭状态

半关闭状态(Half-Close State)是 TCP 协议栈实现的特殊连接状态,其特征表现为:通信双方中某一端主动关闭数据发送通道,同时保持数据接收通道开放。这种机制使得 TCP 连接具备非对称数据传输能力,类似现实中的单行道交通模式。

技术原理剖析

全双工信道解构

TCP 协议的全双工特性本质上是将物理信道虚拟划分为两条独立的单工信道:

  1. 发送信道
    • 本地端口 → 远程端口
    • 由本地端点完全控制
  2. 接收信道
    • 远程端口 → 本地端口
    • 由远程端点完全控制

操作指令不可见性

TCP 协议栈存在以下重要设计特征:

  • 系统调用屏蔽:无法通过协议层直接判断对端调用的是:
    • close():完全关闭套接字(双通道)
    • shutdown(SHUT_WR):仅关闭发送通道
操作 系统调用层面 协议层表现 文件描述符状态
close() 释放文件描述符引用计数 当引用计数归零时发送 FIN 彻底销毁
shutdown(SHUT_WR) 立即关闭发送通道(无视引用计数) 立即发送 FIN 保留可用

SIGPIPE 的产生条件

由于 TCP 是全双工通道,因此在应用层来看,即使对端调用了 close() 也只能关闭对方的发送信道,我方仍能发送数据。
当对端 close 一个连接时,若本地接着发数据。根据 TCP 协议的规定,会收到一个 RST 响应,本地再往对端发送数据时,系统会发出一个 SIGPIPE 信号给进程,告诉进程这个连接已经断开了,不要再写了。

对一个已经收到FIN包的socket调用read方法, 如果接收缓冲已空, 则返回0, 这就是常说的表示连接关闭. 但第一次对其调用write方法时, 如果发送缓冲没问题, 会返回正确写入(发送). 但发送的报文会导致对端发送RST报文, 因为对端的socket已经调用了close, 完全关闭, 既不发送, 也不接收数据. 所以, 第二次调用write方法(假设在收到RST之后), 会生成SIGPIPE信号, 导致进程退出.

修复

在原有的基础上添加了链接的检查机制

ssize_t TcpSocket::Send(const void *buf, int len) {
if (!connected_) {
return 0;
}
ssize_t ret = 0;
struct pollfd pollfd;
pollfd.fd = skt_;
pollfd.events = POLLOUT;
int poll_result = poll(&pollfd , 1 , 1000);
if (poll_result == 0) {
// timeout can not write
Close();
return 0;
}
else if ((pollfd.revents & (POLLERR | POLLHUP | POLLNVAL)) > 0 ||
poll_result < 0) {
Close();
return 0;
} else {
ret = ::write(skt_, buf, len);
}
return ret;
}

反思与更多的改进

线程池对信号的处理机制

统一信号处理器

  • 在进程启动时注册全局信号处理函数(如 SIGPIPE/SIGSEGV),禁止在线程池工作线程中单独设置信号处理,避免不可控行为
    线程信号屏蔽
  • 使用 pthread_sigmask 屏蔽工作线程对关键信号的响应,仅允许主线程处理信号

日志监控增强

应当增加系统调用错误日志与线程池事件日志