TCP 连接已接受,但写入数据会导致它使用过时的连接



服务器(192.168.1.5:3001)运行的是Linux 3.2,一次只接受一个连接。客户端 (192.168.1.18) 运行的是 Windows 7。连接是无线连接。这两个程序都是用C++编写的。

它在 9 个连接/断开周期中工作得很好 10 个。第十个(随机发生)连接让服务器接受连接,然后当它后来实际写入它时(通常在 30+ 秒后),根据 Wireshark(见屏幕截图),它看起来像是写入一个旧的过时连接,端口号是客户端已经 FINed(不久前),但服务器尚未 FINed。因此,客户端和服务器连接似乎不同步 - 客户端建立新连接,服务器尝试写入前一个连接。每次后续连接尝试一旦进入此中断状态就会失败。可以通过超出最大无线范围半分钟来启动损坏状态(就像之前 9 种情况下的 10 种一样,这有效,但有时会导致损坏状态)。

Wireshark截图背后的链接

屏幕截图中的红色箭头指示服务器何时开始发送数据 (Len != 0),这是客户端拒绝数据并向服务器发送 RST 的时间点。右边缘下方的彩色点表示所使用的每个客户端端口号的单一颜色。注意一个或两个点在该颜色的其余点之后是如何出现的(并注意时间列)。

问题看起来像是在服务器端,因为如果您终止服务器进程并重新启动,它会自行解决(直到下次发生)。

希望代码不会太不寻常。我将 listen() 中的队列大小参数设置为 0,我认为这意味着它只允许一个当前连接而不允许挂起的连接(我尝试了 1,但问题仍然存在)。所有错误都不会显示为代码中显示"//错误"的跟踪打印。

// Server code
mySocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (mySocket == -1)
{
  // error
}
// Set non-blocking
const int saveFlags = ::fcntl(mySocket, F_GETFL, 0);
::fcntl(mySocket, F_SETFL, saveFlags | O_NONBLOCK);
// Bind to port
// Union to work around pointer aliasing issues.
union SocketAddress
{
  sockaddr myBase;
  sockaddr_in myIn4;
};
SocketAddress address;
::memset(reinterpret_cast<Tbyte*>(&address), 0, sizeof(address));
address.myIn4.sin_family = AF_INET;
address.myIn4.sin_port = htons(Port);
address.myIn4.sin_addr.s_addr = INADDR_ANY;
if (::bind(mySocket, &address.myBase, sizeof(address)) != 0)
{
  // error
}
if (::listen(mySocket, 0) != 0)
{
  // error
}

// main loop
{
  ...
  // Wait for a connection.
  fd_set readSet;
  FD_ZERO(&readSet);
  FD_SET(mySocket, &readSet);
  const int aResult = ::select(getdtablesize(), &readSet, NULL, NULL, NULL);
  if (aResult != 1)
  {
    continue;
  }
  // A connection is definitely waiting.
  const int fileDescriptor = ::accept(mySocket, NULL, NULL);
  if (fileDescriptor == -1)
  {
    // error
  }
  // Set non-blocking
  const int saveFlags = ::fcntl(fileDescriptor, F_GETFL, 0);
  ::fcntl(fileDescriptor, F_SETFL, saveFlags | O_NONBLOCK);
  ...
  // Do other things for 30+ seconds.
  ...
  const int bytesWritten = ::write(fileDescriptor, buffer, bufferSize);
  if (bytesWritten < 0)
  {
    // THIS FAILS!! (but succeeds the first ~9 times)
  }
  // Finished with the connection.
  ::shutdown(fileDescriptor, SHUT_RDWR);
  while (::close(fileDescriptor) == -1)
  {
    switch(errno)
    {
    case EINTR:
      // Break from the switch statement. Continue in the loop.
      break;
    case EIO:
    case EBADF:
    default:
      // error
      return;
    }
  }
}

因此,在accept()调用(假设这正是发送SYN数据包的点)和write()调用之间的某个地方,客户端的端口将更改为以前使用的客户端端口。

所以问题是:服务器如何接受连接(从而打开文件描述符),然后通过以前的(现在过时和死的)连接/文件描述符发送数据?它是否需要缺少的系统调用中的某种选项?

我提交一个答案来总结我们在评论中发现的内容,即使它还没有完成答案。我认为,它确实涵盖了要点。

您有一个一次处理一个客户端的服务器。它接受连接,为客户端准备一些数据,写入数据,然后关闭连接。麻烦的是,准备数据步骤有时比客户端愿意等待的时间更长。当服务器忙于准备数据时,客户端放弃了。

在客户端,

当套接字关闭时,将发送一个 FIN,通知服务器客户端没有更多要发送的数据。客户端的套接字现在进入FIN_WAIT1状态。

服务器接收 FIN 并使用 ACK 进行回复。 (确认由内核完成,无需用户空间进程的任何帮助。服务器套接字进入CLOSE_WAIT状态。套接字现在是可读的,但服务器进程没有注意到,因为它正忙于其数据准备阶段。

客户端收到 FIN 的 ACK 并进入FIN_WAIT2状态。我不知道客户端上的用户空间发生了什么,因为您没有显示客户端代码,但我认为这并不重要。

服务器进程仍在为已挂起的客户端准备数据。它对其他一切都视而不见。与此同时,另一个客户端连接。内核完成握手。这个新客户端在一段时间内不会得到服务器进程的任何关注,但在内核级别,第二个连接现在在两端都建立了。

最终,服务器的数据准备(对于第一个客户端)完成。它尝试写入()。服务器的内核不知道第一个客户端不再愿意接收数据,因为TCP不传达该信息!因此,写入成功,数据被发送出去(您的 wireshark 列表中的数据包 10711)。

客户端获取此数据包,其内核使用 RST 进行回复,因为它知道服务器不知道的内容:客户端套接字已经关闭以进行读取和写入,可能已关闭,可能已经忘记。

在 wireshark 跟踪中,服务器似乎只想向客户端发送 15 字节的数据,因此它可能成功完成了 write()。但是RST很快就到了,在服务器有机会执行其shutdown()和close()之前,这将发送一个FIN。收到 RST 后,服务器将不会在该套接字上再发送任何数据包。shutdown() 和 close() 现在被执行,但没有任何在线效果。

现在服务器终于准备好接受()下一个客户端了。它开始了另一个缓慢的准备步骤,并且由于第二个客户端已经等待了一段时间,因此进一步落后于计划。问题将不断恶化,直到客户端连接速率减慢到服务器可以处理的水平。

修复程序必须是在准备步骤中客户端挂断时使服务器进程通知,并立即关闭套接字并转到下一个客户端。如何执行此操作取决于数据准备代码的实际外观。如果它只是一个大的 CPU 密集型循环,您必须找到一些地方来插入对套接字的定期检查。或者创建一个子进程来执行数据准备和写入,而父进程只监视套接字 - 如果客户端在子进程退出之前挂起,则终止子进程。其他解决方案也是可能的(例如F_SETOWN当套接字上发生某些事情时向进程发送信号)。

啊哈,成功!事实证明,在调用accept()之前,服务器正在接收客户端的SYN,并且服务器的内核正在自动完成与另一个SYN的连接。所以肯定有一个侦听队列,有两个连接在队列上等待是原因的一半。

原因的另一半与问题中省略的信息有关(由于上面的错误假设,我认为这是无关紧要的)。有一个主连接端口(称为A),以及第二个麻烦的连接端口,这个问题就是关于的(称之为B)。正确的连接顺序是 A 建立连接 (A1),然后 B 尝试建立连接(将成为 B1)...在 200ms 的时间范围内(我已经将很久以前写的 100ms 的超时增加了一倍,所以我认为我很慷慨!如果它在 200 毫秒内没有获得 B 连接,则它会丢弃 A1。因此,B1 与服务器的内核建立连接,等待被接受。只有在 A2 建立连接并且客户端还发送 B2 连接时,它才会在下一个连接周期被接受。服务器接受 A2 连接,然后获取 B 队列上的第一个连接,即 B1(尚未接受 - 队列看起来像 B1、B2)。这就是客户端断开 B1 连接时服务器未发送 B1 的 FIN 的原因。所以服务器的两个连接是A2和B1,显然是不同步的。它尝试写入 B1,这是一个死连接,因此它会丢弃 A2 和 B1。那么下一对是 A3 和 B2,它们也是无效对。在服务器进程被终止并且 TCP 连接全部重置之前,它们永远不会从不同步状态中恢复过来。

因此,解决方案是将等待 B 套接字的超时从 200ms 更改为 5 秒。如此简单的修复让我挠头好几天(并在将其放在 stackoverflow 上的 24 小时内修复了它)!我还通过将套接字 B 添加到主 select() 调用中,然后接受()并立即关闭()它(这只会在 B 连接建立时间超过 5 秒时才会发生)来使其从杂散的 B 连接中恢复。感谢@AlanCurry建议将其添加到 select() 并添加有关 listen() 积压参数作为提示的拼图。

最新更新