在.NET中从NetworkStream读取的正确方式是什么?

26

我一直在努力处理这个问题,但找不到代码无法正确从我编写的TCP服务器中读取数据的原因。我正在使用TcpClient类及其GetStream()方法,但似乎有些问题。要么操作无限期地阻塞(最后一个读取操作没有按预期超时),要么数据被裁剪了(由于某种原因,读取操作返回0并退出循环,可能是服务器响应速度不够快)。以下是三种实现此函数的尝试:

// this will break from the loop without getting the entire 4804 bytes from the server 
string SendCmd(string cmd, string ip, int port)
{
    var client = new TcpClient(ip, port);
    var data = Encoding.GetEncoding(1252).GetBytes(cmd);
    var stm = client.GetStream();
    stm.Write(data, 0, data.Length);
    byte[] resp = new byte[2048];
    var memStream = new MemoryStream();
    int bytes = stm.Read(resp, 0, resp.Length);
    while (bytes > 0)
    {
        memStream.Write(resp, 0, bytes);
        bytes = 0;
        if (stm.DataAvailable)
            bytes = stm.Read(resp, 0, resp.Length);
    }
    return Encoding.GetEncoding(1252).GetString(memStream.ToArray());
}

// this will block forever. It reads everything but freezes when data is exhausted
string SendCmd(string cmd, string ip, int port)
{
    var client = new TcpClient(ip, port);
    var data = Encoding.GetEncoding(1252).GetBytes(cmd);
    var stm = client.GetStream();
    stm.Write(data, 0, data.Length);
    byte[] resp = new byte[2048];
    var memStream = new MemoryStream();
    int bytes = stm.Read(resp, 0, resp.Length);
    while (bytes > 0)
    {
        memStream.Write(resp, 0, bytes);
        bytes = stm.Read(resp, 0, resp.Length);
    }
    return Encoding.GetEncoding(1252).GetString(memStream.ToArray());
}

// inserting a sleep inside the loop will make everything work perfectly
string SendCmd(string cmd, string ip, int port)
{
    var client = new TcpClient(ip, port);
    var data = Encoding.GetEncoding(1252).GetBytes(cmd);
    var stm = client.GetStream();
    stm.Write(data, 0, data.Length);
    byte[] resp = new byte[2048];
    var memStream = new MemoryStream();
    int bytes = stm.Read(resp, 0, resp.Length);
    while (bytes > 0)
    {
        memStream.Write(resp, 0, bytes);
        Thread.Sleep(20);
        bytes = 0;
        if (stm.DataAvailable)
            bytes = stm.Read(resp, 0, resp.Length);
    }
    return Encoding.GetEncoding(1252).GetString(memStream.ToArray());
}

最后一个“有效”,但是在循环内部放置硬编码的睡眠看起来确实很丑,考虑到套接字已经支持读取超时!我需要在TcpClient或者NetworkStream上设置一些属性吗?问题出在服务器上吗?服务器不会关闭连接,这取决于客户端。以上代码还在UI线程上下文(test program)中运行,也许与此有关...

有人知道如何正确使用NetworkStream.Read读取数据直到没有更多数据可用吗?我想我希望得到类似于旧的Win32 winsock超时属性... ReadTimeout等。它尝试读取直到达到超时时间,然后返回0... 但是有时当数据可用时(或正在途中时)它似乎会返回0,并且在没有数据可用时它会无限期地阻塞最后一次读取...

是的,我束手无策!


请注意。您的尝试中的代码(以及答案)未关闭客户端或流,如果重复调用可能会导致资源泄漏并造成严重后果。您应该使用 using (var client = new System.Net.Sockets.TcpClient(ip, port)) using (var stm = client.GetStream()) 然后将方法的其余部分括在大括号内。这将确保在方法退出时,无论原因如何,连接都将关闭,资源将被回收。 - Stéphane Gourichon
你看过TcpClient类的代码吗?我建议你去看一下它...如果你仍然想在端点上回答,你不应该关闭流或tcpclient(如果我没记错的话,它会关闭流)。但是我正在使用的实际代码不同,我会找出来并在这里更新答案。 - Loudenvier
谢谢您的建议。我找到了TCPClient.cs。确实,关闭或释放TCPClient会释放流。我曾经看到过由于程序不够小心(因为其他原因)而进行数千次连接时出现连接失败的情况。为什么要在这里偏离通常的IDisposable模式?实现IDisposable容易出错,而使用using()则简单且安全。 - Stéphane Gourichon
3个回答

23

网络编程往往难以编写、测试和调试。

您通常需要考虑许多事情,例如:

  • 交换的数据使用哪种“字节序”(Intel x86 / x64基于小端字节序)-使用大端字节序的系统仍然可以读取小端字节序的数据(反之亦然),但它们必须重新排列数据。在记录您的“协议”时,请明确指出您正在使用的字节序。

  • 套接字上是否设置了任何可能影响“流”行为的“设置”(例如SO_LINGER)-如果代码非常敏感,则可能需要打开或关闭某些设置

  • 现实世界中的拥塞如何影响您的读/写逻辑并导致流中的延迟

如果在客户端和服务器之间(双向)交换的“消息”大小可能会有所变化,则通常需要使用一种策略以可靠的方式交换该“消息”(也称为协议)。

以下是几种处理交换的不同方法:

  • 在数据前面以头的形式编码消息大小-这可以只是第一次发送的2/4/8个字节中的“数字”,也可以是更复杂的“头文件”

  • 使用特殊的“消息结束”标记(sentinel),如果有真实数据可能与“结束标记”混淆,则对真实数据进行编码/转义

  • 使用超时....即若一段时间内没有接收到任何字节,则表示该消息没有更多数据-但是,这可能在短时间内容易出错,并且很容易在拥塞的流上遇到。

  • 在单独的“连接”上使用“命令”和“数据”通道....这是FTP协议使用的方法(优点是将数据与命令明确分开...以第二个连接为代价)

每种方法都有其正确性的优缺点。

下面的代码使用了“超时”方法,因为这似乎是您想要的。

请参见http://msdn.microsoft.com/en-us/library/bk6w7hs8.aspx。 您可以访问TCPClient上的NetworkStream,因此您可以更改ReadTimeout

string SendCmd(string cmd, string ip, int port)
{
  var client = new TcpClient(ip, port);
  var data = Encoding.GetEncoding(1252).GetBytes(cmd);
  var stm = client.GetStream();
  // Set a 250 millisecond timeout for reading (instead of Infinite the default)
  stm.ReadTimeout = 250;
  stm.Write(data, 0, data.Length);
  byte[] resp = new byte[2048];
  var memStream = new MemoryStream();
  int bytesread = stm.Read(resp, 0, resp.Length);
  while (bytesread > 0)
  {
      memStream.Write(resp, 0, bytesread);
      bytesread = stm.Read(resp, 0, resp.Length);
  }
  return Encoding.GetEncoding(1252).GetString(memStream.ToArray());
}

作为对这个编写网络代码的其他变体的注释...在进行读取时,如果你想避免"阻塞",你可以检查DataAvailable标志,然后仅读取缓冲区中的内容,并检查.Length属性,如stm.Read(resp, 0, stm.Length);


大小未知,可能在MB范围内...正如我在问题中所说的,第三个选项确实可以正确读取所有内容,但我无法接受需要在那里使用Sleep,并且我也无法解释它。我知道它的作用:通过睡眠,我给数据在套接字中到达的时间,但如果数据在此之前到达,我不想睡眠20ms。我知道如何使用非阻塞读取和自己的重叠IO来做到这一点...但我认为在.NET中不应该需要这样做! - Loudenvier
你使用什么方法让读取器知道它是否已经读取了所有数据,即正确的数量/大小?典型的方法是在响应的第一部分中使用编码的大小标头。 - Colin Smith
我确实有一个Content-length(协议几乎与HTTP相同),但为了简单起见,我会使用“传入数据超时”,并且我认为Read会阻塞一段时间,然后超时而不是永远等待它到达!通过“内部循环”睡眠,在每次迭代中都要付出20ms的代价,而使用“传入数据超时”,只有在需要时和最后一次读取时才需要付出代价。我可以使用ContentLenght进行优化,但仅依赖长度也很危险,因为服务器可能会表现不良并且不发送所有内容! - Loudenvier
ReadTimeout 只有在实际开始读取操作后才会超时。如果没有任何数据到达,ReadTimeout 将没有效果。我正在研究 Socket.ReceiveTimeout... - Loudenvier
我需要监控公司遗留 PBX(通过已知的 IP 地址和端口号)与其遗留客户端软件(Oaisys NetPhone v4.6)之间的 TCP 流量(除非我们升级,否则不再受支持),以提取来电显示电话号码,以解决客户端软件在 Windows 10 下不再记录来电显示号码的问题(与 7 或 XP 相比)。我可以使用 TcpClient 来实现这一点吗(例如,不会阻止 NetPhone 和 PBX 之间的通信)?还是我必须使用 IPGlobalPrperties 或其他东西?我对网络软件完全不熟悉。任何帮助都将不胜感激。 - Tom

12

通过设置底层套接字的ReceiveTimeout属性解决了问题。您可以像这样访问它:yourTcpClient.Client.ReceiveTimeout。您可以阅读文档获取更多信息。

现在代码只会在需要等待数据到达套接字时才会“休眠”,或者如果在读操作开始时超过20ms没有收到任何数据,它将引发异常。如果需要,我可以调整此超时时间。现在我不必在每次迭代中都付出20ms的代价,我只在最后一次读取操作中支付它。由于我从服务器读取的第一个字节是消息的内容长度,因此我可以将其用于进一步微调并且如果已经接收到了所有预期的数据,则不尝试继续读取。

我发现使用ReceiveTimeout比实现异步读取要容易得多... 这是工作代码:

string SendCmd(string cmd, string ip, int port)
{
  var client = new TcpClient(ip, port);
  var data = Encoding.GetEncoding(1252).GetBytes(cmd);
  var stm = client.GetStream();
  stm.Write(data, 0, data.Length);
  byte[] resp = new byte[2048];
  var memStream = new MemoryStream();
  var bytes = 0;
  client.Client.ReceiveTimeout = 20;
  do
  {
      try
      {
          bytes = stm.Read(resp, 0, resp.Length);
          memStream.Write(resp, 0, bytes);
      }
      catch (IOException ex)
      {
          // if the ReceiveTimeout is reached an IOException will be raised...
          // with an InnerException of type SocketException and ErrorCode 10060
          var socketExept = ex.InnerException as SocketException;
          if (socketExept == null || socketExept.ErrorCode != 10060)
              // if it's not the "expected" exception, let's not hide the error
              throw ex;
          // if it is the receive timeout, then reading ended
          bytes = 0;
      }
  } while (bytes > 0);
  return Encoding.GetEncoding(1252).GetString(memStream.ToArray());
}

3
使用这个黑客技巧时要小心。我们有一个依赖于套接字超时来检测消息结束的Java应用程序,有时会在流中丢失一个字节。我承认我不知道这是否也适用于.NET,但我怀疑是底层TCP/IP堆栈造成了问题。超时后不应该重新使用套接字。 - Søren Boisen
@SørenBoisen 我在超时后没有重用套接字...我不会丢失数据,但可能会出现虚假的“正”超时(如果网络速度慢,数据可能需要比字符间超时更长的时间到达),但在这种情况下,另一端将简单地处理断开连接,并希望再次尝试。这实际上不是一个黑客技巧,协议(我无法更改)发送未知大小的消息,因此我必须依靠超时。我实际上已经使代码更加健壮,但没有更新文章。现在是个好时机。 - Loudenvier
1
10035表示它正在努力连接,也就是说此时流为空,但如果我们稍等片刻,消息就会继续传输。我使用请求头内容长度与已读取字节数之和来决定是否应该继续重试读取10035。 - BeatriceThalo

0
根据您的要求,Thread.Sleep是完全可以使用的,因为您不确定何时数据将可用,所以您可能需要等待数据变得可用。我稍微改变了您函数的逻辑,这可能会对您有所帮助。
string SendCmd(string cmd, string ip, int port)
{
    var client = new TcpClient(ip, port);
    var data = Encoding.GetEncoding(1252).GetBytes(cmd);
    var stm = client.GetStream();
    stm.Write(data, 0, data.Length);
    byte[] resp = new byte[2048];
    var memStream = new MemoryStream();

    int bytes = 0;

    do
    {
        bytes = 0;
        while (!stm.DataAvailable)
            Thread.Sleep(20); // some delay
        bytes = stm.Read(resp, 0, resp.Length);
        memStream.Write(resp, 0, bytes);
    } 
    while (bytes > 0);

    return Encoding.GetEncoding(1252).GetString(memStream.ToArray());
}

希望这能帮到你!


1
我在发布问题后已经按照这个方法编写了代码!但是我仍然不认为它可接受。如果我使用老式的WIN32 overlapped IO,我可以“阻塞”20毫秒或更长时间,直到数据开始到达,因此我的代码可以更安全,类似于这样的伪代码:stm.ReadIfDataBecomesAvailableInUpTo(timeout=2000) 我知道20毫秒是一个小代价,但它将在每次迭代中支付!要准备一个MB大小的响应,这将是一个巨大的开销!我不想求助于编写自己的超时逻辑... :-( - Loudenvier
7
在使用线程睡眠时需要小心。如果时间太短,会在操作系统上强制进行不必要的上下文切换,从而使 CPU 资源被消耗殆尽。更好的机制可能是中断驱动或事件驱动。你可以使用 stm.BeginRead(),这样当数据准备好时就会触发一个事件,这样你的进程就可以处于阻塞状态,操作系统只会在资源准备好时将其唤醒。每次唤醒时,线程睡眠都会将控制权交还给操作系统。 - user1132959

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接