月度归档:2015年02月

C# Socket 如何判断连接已断开

相信很多 Unity3D 的程序员都会遇到网络编程的情况,虽然目前可能大部分的手游都会优先选择使用非长连接技术来实现,也就是我们常说的直接使用 Http 来完成跟服务器通信的部分,但是还是难免会有遇到需要使用实时网络连接的时候。那么这时候,我们通常还是需要回到网络编程的根源,那就是直接使用 Socket 进行编程,.NET Framework 中的 Socket 已经封装得非常的完备了,真的很好使。

但是在使用的过程中难免还是会碰到一丢丢问题的,对吧。今天我要记录的就是在处理连接已断开的这种情况。

在手游的使用场景中,以下情况可能会出现客户端与服务器出现无法连接的情况:

  1. 客户端无法连接到网络,例如:客户端设备当前接入的 Wi-Fi 热点无法连接到互联网,客户端设备进入无线网络信号很差的地方(例如某些大厦的电梯,某些地铁线路等等),这个时候其实客户端和服务器都没有主动关闭过这个 TCP 连接,但是网络已经无法正常连接了;
  2. 服务器主动关闭了这条 TCP 连接,服务器为什么要主动关闭掉我的这条 TCP 连接呢?设想一下,客户端跟服务器建立 TCP 连接之后,客户端在 10 分钟内都没有向服务器发送过任何数据,那么服务器还有必要保留这条连接吗?如果当前在线的玩家较多,服务器可用的连接数已经达到一个容限值,那么是否需要及时将某些已经不活跃(例如 10 分钟以上没有发送任何消息)的连接主动关闭,回收这些连接资源,以备其他新登录游戏的玩家设备连接使用呢?所以,当服务器主动关闭了 TCP 连接的时候,10 分钟我上完厕所回来了,我重新拿起我的手机,想继续玩我的游戏,那么就需要处理客户端如何重新连接到服务器的问题了;
  3. 服务器网络出现问题,例如服务器所在机房链路出现问题,通过某些网络就是无法访问服务器,例如服务器网线不小心被挖断了或者网线被网管拔了,服务器电源烧了,主板坏了,等等诸如此类的物理设备出现故障的情况下,客户端是无法连接到服务器的,服务器也不可能通过 TCP 协议给客户端发送什么 FIN,ACK,SYN 等等状态码啦,这个时候客户端也需要有正常的逻辑来处理这个情况。

其实说了这么多就是想表达一下,我们的客户端可能在任何情况下与服务器失去连接,而这个时候客户端需要合理地来处理这种连接已断开的情况。

我们可以查看一下.NET Framework 的文档,看看 Socket 中的一些相关的属性和方法:

Socket.Connected Property

Gets a value that indicates whether a Socket is connected to a remote host as of the last Send or Receive operation.

The Connected property gets the connection state of the Socket as of the last I/O operation. When it returns false, the Socket was either never connected, or is no longer connected.

The value of the Connected property reflects the state of the connection as of the most recent operation. If you need to determine the current state of the connection, make a nonblocking, zero-byte Send call. If the call returns successfully or throws a WAEWOULDBLOCK error code (10035), then the socket is still connected; otherwise, the socket is no longer connected.

If you call Connect on a User Datagram Protocol (UDP) socket, the Connected property always returns true; however, this action does not change the inherent connectionless nature of UDP.

这个是 Connected 属性的相关描述,大体的意思就是这个 Connected 属性会根据当前这个 Socket 最后一次的 I/O 操作来进行设定,也就是说这个属性为 True 还是为 False 会在 Socket 最后一次 I/O 操作完毕之后进行设定,应该也是根据错误返回码 SocketError 来进行设定的。如果我们使用的是阻塞的 Socket 的话,那么这个状态会在最后一次 I/O 操作出错后将这个属性设置为 False,如果使用的是非阻塞的 Socket 就需要我们自己通过发送一个零字节的数据包,然后根据错误返回码来判断 Socket 连接是否断开。


 

Socket.Receive Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Receives data from a bound Socket into a receive buffer, using the specified SocketFlags.

在我们的项目中我们使用了这个方法来读取 Socket 中的数据 ,这个方法中的最后一个参数是一个 out 参数,也就是如果我们在读取数据的时候出现了一些异常,错误码会通过这个参数返回。


Socket.Send Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Sends the specified number of bytes of data to a connected Socket, starting at the specified offset, and using the specified SocketFlags

我们使用这个 Send 方法来进行数据的发送,同样也是通过最后一个 out 参数 SocketError 来进行错误码的返回。

这个 SocketError 类型中呢有很多很多预定义的错误码,这个需要我们仔细查阅看看有哪些是我们需要使用到的,这个因项目类型和需求不同,这里不一一阐述了。

那么在我们实际的开发过程中呢,我们肯定可以通过判断 SocketError 错误码来进行一些异常情况的处理,不过有些时候我们其实并不想在出现异常了之后才处理,例如我有一个发送消息的队列,有一条消息发送出错了,我还得把这条消息再放回队列,可是如果直接入队到消息队列的队尾可能会有消息先后顺序的问题等等。所以有些时候我们就需要在异常出现之前就想好解决方法,也就是在我们读取或者发送数据之前就先检查一下连接是否正常消息是否能够成功发送出去,那么这又要肿么搞呢?

先看看 Poll 方法的描述吧:

Socket.Poll Method

Determines the status of the Socket.

Remarks

The Poll method will check the state of the Socket. Specify SelectMode.SelectRead for the selectMode parameter to determine if the Socket is readable. Specify SelectMode.SelectWrite to determine if the Socket is writable. Use SelectMode.SelectError to detect an error condition. Poll will block execution until the specified time period, measured in microseconds, elapses. Set the microSeconds parameter to a negative integer if you would like to wait indefinitely for a response. If you want to check the status of multiple sockets, you might prefer to use the Select method.

 看上去这个方法貌似就是我们想要的呢,对吧?如果你这么想那就太 Naive 了,在实际使用的过程中,我就犯了一个不仔细看文档,导致栽了一个大跟头的错误。我写了一段代码用来判断当前 Socket 是否已经跟服务器断开连接。

Socket socket;
// 省去初始化和连接服务器代码若干
if (!socket.Poll(1000, SelectMode.SelectRead)) {
// 做错误提示等相关的操作
}

事实证明这样是瞎搞,因为.NET Framework 的文档关于 SelectMode.SelectRead 这样描述的。

true if Listen has been called and a connection is pending;
-or-
true if data is available for reading;
-or-
true if the connection has been closed, reset, or terminated;
otherwise, returns false.

你说这不是坑爹是啥?这个意思就是说,如果我们是一个服务器端的 Socket,调用了 Listen 方法,并且有一个外部连接在等待我们处理的时候,这个 Poll 方法会返回 true,如果当前 Socket 中有数据可以读取时,这个 Poll 方法也会返回 true,或者当这个 Socket 连接被关闭、重置、强制终止了也会返回 true,否则返回 false。

实际测试的时候,发现这个值只在 Socket 缓冲区中有数据可以读取和连接被服务器关闭了之后才会返回 true,好吧,我承认自己是猪吧。那么回到我们的初衷上来,我们还是希望能在读取数据之前就能知道这个连接是否已经被关闭了(是否与服务器断开连接了)。那么可以通过下面这段代码来进行判断。

// 省去初始化和连接服务器代码若干
if (socket.Available == 0 && socket.Poll(1000, SelectMode.SelectRead)) {
// 因为根据文档的描述,如果连接正常的话,这两个条件不可能同时成立
// 只有在连接已断开的时候,才可能会出现这两个条件同时成立的情况
}

写到这里的时候,突然之间很想看看 Available 这个属性又是怎么一回事,好吧,我们再来看看.NET Framework 的文档吧。

Socket.Available Property

Gets the amount of data that has been received from the network and is available to be read.

Remarks

If you are using a non-blocking Socket, Available is a good way to determine whether data is queued for reading, before calling Receive. The available data is the total amount of data queued in the network buffer for reading. If no data is queued in the network buffer, Available returns 0.

If the remote host shuts down or closes the connection, Available can throw a SocketException. If you receive a SocketException, use the SocketException.ErrorCode property to obtain the specific error code. After you have obtained this code, refer to the Windows Sockets version 2 API error code documentation in the MSDN library for a detailed description of the error.

虽然文档中提到如果我们使用的是非阻塞的 Socket 的话,那么 Available 属性将会是一个用于检测 Socket 缓冲区中是否有数据可以 Receive 的好选择,这样我们如果使用阻塞的 Socket 难道就不能用了吗?当然也不是,肯定还是可以用的,只是说非阻塞的 Socket 如果一直循环调用 Receive 很有可能会做无用功,因为如果当前 Socket 缓冲区中没有数据可以读取的话,Receive 方法会很快速的结束调用并且抛出一个 SocketException,而如果每次 Receive 之前检查一下变量的成本相对于调用一次 Receive 方法可能开销会更小一些吧。而对于阻塞的 Socket 来说,每次 Receive 调用都是阻塞直到有数据返回,或者超时后抛出一个超时异常。所以我们阻塞的 Socket 也是可以用 Available 这个属性来进行提前判断的。但是实际测试中,Unity3D 中使用的 Mono 中的 Socket 实现并非和.NET Framework 的文档描述一致。关于如果 Remote Host 关闭连接之后,再调用这个 Available 属性会抛出异常的行为就没有。

 关于 SelectMode 中另外两个 SelectWrite 和 SelectError 类型,显然我们也不能放过啊。一一测试之后发现,在服务器主动断开连接之后,通过 Socket.Poll(1000, SelectMode.SelectWrite) 返回一直都是 true。即便我们通过 Send 方法发出去的字节数为 0,返回的 SocketError 中的错误码为 Shutdown,依然不为所动啊,所以对于这个 SelectMode.SelectWrite 我已经凌乱了,希望哪天可以找到合理的解释。

但是 Socket.Poll(1000, SelectMode.SelectError) 在服务器断开连接之后会在第一时间内从 false 变成 true,貌似这个还是蛮靠谱的,但是看了一下文档之后我又有点凌乱了。

true if processing a Connect that does not block, and the connection has failed;
-or-
true if OutOfBandInline is not set and out-of-band data is available;
otherwise, returns false.

这个意思是说,如果正在建立一个非阻塞的连接,然后这个连接失败了会返回 true,或者没有设置 OutOfBandInline 但是又收到了 out-of-band 数据的时候会返回 true,否则就返回 false。

综合上面说的那一大堆东西,最终得出的结论就是 Connected 字段可以用,但是有滞后性,如果想通过这个字段来检测连接是否已经断开的话,就要明白这个字段是在我们通过 Receive 或者 Send 方法成功进行一次 I/O 操作之后,系统根据最后一次 I/O 操作的错误返回码来设定 Connected 字段的值的。

而如果想在进行 I/O 操作之前就检测到连接出现断开的情况的话,是可以通过 Poll 来达成目的的,当然这个会比较纠结。首先 Poll 配合 SelectMode.SelectRead 和 SelectMode.SelectWrite 都不能非常直接的反馈连接是否被断开 。但是貌似这个 SelectMode.SelectError 还是蛮靠谱的,所以呢,从目前测试得到的结果中,可以使用这个来提前判断连接是否已经断开。

不过在实际的应用中,我个人倒是更倾向于在使用 SelectMode.SelectError 做判断的同时,一定还要对在 Receive 和 Send 方法中返回的 SocketError 进行检查,这个返回码是直接从 Native 层返回的 Socket 错误码,非常可靠也很能说明问题。例如”HostDown”、”ShutDown“、”NetworkDown“等等状态码还是很能直观地反应当前 Socket 的状态的,所以还是需要好好地看看.NET Framework 中关于 SocketError 的描述文档。