如何正确地异步编写Socket/NetworkStream?

3
我想使用NetworkStream(或者也许是Socket)来读写TCP连接。我想使用非阻塞操作,这样就不必处理多个线程,也不必处理如果某些线程在阻塞网络操作上停止时如何停止程序的问题。 NetworkStream.Read文档暗示它是非阻塞的(“如果没有可用于读取的数据,则Read方法返回0”),所以我猜我不需要异步回调来读取...对吧?
那么写入呢?嗯,微软在这个主题上有一个通常的低质量教程,他们建议为每个操作编写冗长的AsyncCallback方法。这个回调中的代码有什么意义呢?
client.BeginSend(byteData, 0, byteData.Length, SocketFlags.None,
                 new AsyncCallback(SendCallback), client);
    ...
private static void SendCallback(IAsyncResult ar)
{
    try {
        Socket client = (Socket)ar.AsyncState;

        int bytesSent = client.EndSend(ar);

        Console.WriteLine("Sent {0} bytes to server.", bytesSent);
        // Signal that all bytes have been sent.

        sendDone.Set();
    }
    catch (Exception e)
    {
        Console.WriteLine(e.ToString());
    }
}
AsyncCallback文档中写道,它是“在异步操作完成时调用的回调方法”。如果操作已经完成,为什么我们还要调用Socket.EndSend(IAsyncResult)呢?同时,NetworkStream.BeginWrite的文档说:“您必须创建一个实现了AsyncCallback委托的回调方法,并将其名称传递给BeginWrite方法。至少,您的状态参数必须包含NetworkStream。”但为什么我“必须”这样做呢?我不能只是把IAsyncResult存储在某个地方吗?
_sendOp = client.BeginSend(message, 0, message.Length, SocketFlags.None, null, null);

...并定期检查它是否完成?

if (_sendOp.IsCompleted)
    // start sending the next message in the queue

我知道这意味着轮询,但它将在一个预计大部分时间都处于活动状态的服务器上运行,并且我计划在空闲时进行一些Thread.Sleeps。
另一个问题是,我发送的消息被分成头部数组和正文数组。我能够连续发出两个BeginWrites吗?
IAsyncResult ar1 = _stream.BeginWrite(header, 0, header.Length, null, null);
IAsyncResult ar2 = _stream.BeginWrite(msgBody, 0, msgBody.Length, null, null);

此外,欢迎任何关于使用Socket或NetworkStream的意见。
2个回答

3

我没有使用NetworkStream的经验,但是关于异步Socket操作,我可以说这些:

  • 当操作完成时需要调用End*,因为它会清理异步操作使用的操作系统资源,并允许您获取操作的结果(即使该结果只是成功或失败)。MSDN提供了对此常见异步模式的很好概述
  • 您不需要将任何特定对象作为state参数传递。当编写该文档时,传递它是保持对象在完成委托中范围内的最简单方法。现在,使用lambda表达式,这种做法已经不再常见。
  • 您可以将写入排队到套接字中,在大多数情况下,这将起作用。但是,有可能其中一个写入仅部分完成(我从未见过这种情况发生,但理论上可能发生)。我曾经这样做了多年,但现在不再推荐这样做。

我建议使用完成委托而不是轮询。如果您想要一个更易于使用的包装器,可以尝试Nito.Async.Sockets,它包装了Begin/End操作,并将它们全部串行化通过单个线程(例如,UI线程)。

P.S. 如果您想要停止阻塞操作,您可以从另一个线程关闭套接字,这将导致操作以错误完成。但是,我不建议使用阻塞套接字操作。


我认为这个答案是正确的,但我决定采取不同的方法:在将socket.Blocking = false设置后,调用Send()而不是BeginSend()。唯一的问题是未记录的事实,如果Send()现在无法发送,它不会像你想的那样返回零,而是抛出SocketException,ErrorCode == 0x2733,“无法立即完成非阻塞套接字操作”。 - Qwertie

1
NetworkStream.Read 的文档暗示它是非阻塞的(“如果没有可供读取的数据,Read 方法将返回 0”),那么我猜我不需要异步回调来进行读取...对吗?我不明白怎么会暗示是非阻塞操作,除非你永远不打算有任何可用的读取数据。如果有可供读取的数据,则操作将会阻塞直到读取完数据。
那写作方面呢?嗯,微软在这个主题上有一个通常的低质量教程,并且他们建议为每个操作编写一个繁琐的AsyncCallback方法。这个回调中的代码有什么意义呢?
重点是避免为自己编写更加繁琐的线程管理代码。就多线程而言,AsyncCallback是最简单的,尽管这个例子有些愚蠢。在这个例子中,回调例程会发出一个信号ManualResetEvent (sendDone.Set()),这是一种避免轮询的方法;你可以有一个单独的线程等待那个ManualResetEvent,它会阻塞直到其他东西调用它的Set()方法。但是,使用AsyncCallback的主要目的不是为了避免编写自己的线程管理吗?那么为什么要有一个等待ManualResetEvent的线程呢?更好的例子将展示在AsyncCallback方法中实际执行某些操作,但请注意,如果您与任何UI元素(如窗体控件)交互,则需要使用控件的Invoke方法,而不是直接操纵它。
如果操作已经完成,为什么我们还要调用Socket.EndSend(IAsyncResult)?
从MSDN文档中关于Stream.BeginWrite方法的说明:
将当前方法返回的IAsyncResult传递给EndWrite以确保写入完成并适当地释放资源。对于每次调用BeginWrite,都必须调用EndWrite一次。您可以通过使用调用BeginWrite的相同代码或在传递给BeginWrite的回调中执行此操作来实现。如果在异步写入期间发生错误,则不会抛出异常,直到使用此方法返回的IAsyncResult调用EndWrite。
您永远不知道I/O操作会发生什么,而且您无法使用异步操作进行正常的错误处理,因为执行是在线程池中的单独线程上进行的。因此,调用EndWrite的目的是让您有机会通过在AsyncCallback方法内部的try/catch块中放置EndWrite来处理任何错误。
我能不能把IAsyncResult存储在某个地方,然后定期检查它是否完成?
如果你想处理所有线程管理和安全问题,以确保不同的线程永远不会尝试同时处理相同的IAsyncResult,那么你可以这样做。但是,对我来说,这似乎比编写AsyncCallback要麻烦得多。再次强调,使用异步方法的目的是尽可能少地处理线程问题。
另一个问题。我发送的消息被分成头部数组和正文数组。我可以连续发出两个BeginWrites吗?
虽然我从未尝试过这样做,但我不知道它是否有效,这实际上取决于NetworkStream类的具体实现。可能第二个请求会等待第一个请求完成后再尝试执行,但我有一种感觉,要么会抛出异常,要么在另一端会得到损坏的数据。如果您想同时发送2个单独的数据流,我认为最好的方法是使用2个单独的NetworkStreams。

我现在意识到,如果连续调用BeginWrite是合法的,那我真的应该这样做。因为如果在开始另一个发送之前等待一个发送完成,标题和正文可能会分别发送到两个数据包中,这会导致不必要的网络流量增加(通常情况下,我期望我的消息非常小)。 - Qwertie
Read()方法返回读取的字节数,并且如果没有数据可用,立即返回,这表明它不会停止并等待网络中的数据。然而,令人沮丧的是,文档并没有明确保证非阻塞行为。 - Qwertie

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