2.2 异步客户端
同步模式中,客户端使用API Connect连接服务器,并使用API Send和Receive接收数据。在异步模式下,客户端可以使用BeginConnect和EndConnect等API完成同样的功能。
2.2.1 异步Connect
每一个同步API(如Connect)对应着两个异步API,分别是在原名称前面加上Begin和End(如BeginConnect和EndConnect)。客户端发起连接时,如果网络不好或服务端没有回应,客户端会被卡住一段时间。读者可以做一个这样的实验:使用NetLimiter等软件限制网速,然后打开第1章制作的Echo程序。点击连接后,客户端会卡住十几秒,并弹出“由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。”的异常信息。而在这卡住的十几秒,用户不能做任何操作,游戏体验很差。
若使用异步程序,则可以防止程序卡住,其核心的API BeginConnect的函数原型如下:
public IAsyncResult BeginConnect( string host, int port, AsyncCallback requestCallback, object state )
表2-1中针对BeginConnect的参数进行了说明。
表2-1 BeginConnect的参数
知识点
IAsyncResult是.NET提供的一种异步操作,通过名为Begin×××和End×××的两个方法来实现原同步方法的异步调用。Begin×××方法包含同步方法中所需的参数,此外还包含另外两个参数:一个AsyncCallback委托和一个用户定义的状态对象。委托用来调用回调方法,状态对象用来向回调方法传递状态信息。且Begin×××方法返回一个实现IAsyncResult接口的对象,End×××方法用于结束异步操作并返回结果。End×××方法含有一个IAsyncResult参数,用于获取异步操作是否完成的信息,它的返回值与同步方法相同。
EndConnect的函数原型如下。在BeginConnect的回调函数中调用EndConnect,可完成连接。
public void EndConnect( IAsyncResult asyncResult )
2.2.2 Show Me The Code
“码不出何以论天下”,开始编程吧!使用异步Connect修改Echo客户端程序如下所示。
using System; //点击连接按钮 public void Connection() { //Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); //Connect socket.BeginConnect("127.0.0.1", 8888, ConnectCallback, socket); } //Connect回调 public void ConnectCallback(IAsyncResult ar){ try{ Socket socket = (Socket) ar.AsyncState; socket.EndConnect(ar); Debug.Log("Socket Connect Succ"); } catch (SocketException ex){ Debug.Log("Socket Connect fail" + ex.ToString()); } }
说明:
1)由BeginConnect最后一个参数传入的socket,可由ar.AsyncState获取到。
图2-2 限制网速,客户端无法连接服务端,弹出异常
2)try-catch是C#里处理异常的结构。它允许将任何可能发生异常情形的程序代码放置在try{}中进行监控。异常发生后,catch{}里面的代码将会被执行。catch语句中的参数ex附带了异常信息,可以将它打印出来。如果连接失败,EndConnect会抛出异常,所以将相关的语句放到try-catch结构中。
打开Echo服务端,运行程序。点击连接按钮后,客户端不再被卡住。图2-2展示的是在限制网速的情况下,客户端无法连接服务端,弹出异常的情形。但无论如何,客户端不再卡住。
2.2.3 异步Receive
Receive是个阻塞方法,会让客户端一直卡着,直至收到服务端的数据为止。如果服务端不回应(试试注释掉Echo服务端的Send方法!),客户端就算等到海枯石烂,也只能继续等着。异步Receive方法BeginReceive和EndReceive正是解决这个问题的关键。
与BeginConnect相似,BeginReceive用于实现异步数据的接收,它的原型如下所示。
public IAsyncResult BeginReceive ( byte[] buffer, int offset, int size, SocketFlags socketFlags, AsyncCallback callback, object state )
表2-2对BeginReceive的参数进行了说明。
表2-2 BeginReceive的参数说明
虽然参数比较多,但我们先重点关注buffer、callback和state三个即可。对应的End-Receive的原型如下,它的返回值代表了接收到的字节数。
public int EndReceive( IAsyncResult asyncResult )
冗谈无用,源码拿来!修改Echo客户端程序如下所示,其中底纹标注的部分为需要特别注意的地方。
using System.Collections; using System.Collections.Generic; using UnityEngine; using System.Net.Sockets; using UnityEngine.UI; using System; public class Echo : MonoBehaviour { //定义套接字 Socket socket; //UGUI public InputField InputFeld; public Text text; //接收缓冲区 byte[] readBuff = new byte[1024]; string recvStr = ""; //点击连接按钮 public void Connection() { //Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); //Connect socket.BeginConnect("127.0.0.1", 8888, ConnectCallback, socket); } //Connect回调 public void ConnectCallback(IAsyncResult ar){ try{ Socket socket = (Socket) ar.AsyncState; socket.EndConnect(ar); Debug.Log("Socket Connect Succ"); socket.BeginReceive( readBuff, 0, 1024, 0, ReceiveCallback, socket); } catch (SocketException ex){ Debug.Log("Socket Connect fail" + ex.ToString()); } } //Receive回调 public void ReceiveCallback(IAsyncResult ar){ try { Socket socket = (Socket) ar.AsyncState; int count = socket.EndReceive(ar); recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count); socket.BeginReceive( readBuff, 0, 1024, 0, ReceiveCallback, socket); } catch (SocketException ex){ Debug.Log("Socket Receive fail" + ex.ToString()); } } //点击发送按钮 public void Send() { //Send string sendStr = InputFeld.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); socket.Send(sendBytes); //不需要Receive了 } public void Update(){ text.text = recvStr; } }
上述代码运行的结果如图2-3所示。
图2-3 程序运行结果
下面对值得注意的地方进行进一步解释。
(1)BeginReceive的参数
上述程序中,BeginReceive的参数为(readBuff, 0, 1024, 0, ReceiveCallback, socket)。第一个参数readBuff表示接收缓冲区;第二个参数0表示从readBuff第0位开始接收数据,这个参数和TCP粘包问题有关,后续章节再详细介绍;第三个参数1024代表每次最多接收1024个字节的数据,假如服务端回应一串长长的数据,那一次也只会收到1024个字节。
(2)BeginReceive的调用位置
程序在两个地方调用了BeginReceive:一个是ConnectCallback,在连接成功后,就开始接收数据,接收到数据后,回调函数ReceiveCallback被调用。另一个是BeginReceive内部,接收完一串数据后,等待下一串数据的到来,如图2-4所示。
图2-4 程序结构图
(3)Update和recvStr
在Unity中,只有主线程可以操作UI组件。由于异步回调是在其他线程执行的,如果在BeginReceive给text.text赋值,Unity会弹出“get_isActiveAndEnabled can only be called from the main thread”的异常信息,所以程序只给变量recvStr赋值,在主线程执行的Update中再给text.text赋值(如图2-5所示)。
图2-5 在主线程中给UI组件赋值
2.2.4 异步Send
尽管不容易察觉,Send也是个阻塞方法,可能导致客户端在发送数据的一瞬间卡住。TCP是可靠连接,当接收方没有收到数据时,发送方会重新发送数据,直至确认接收方收到数据为止。
在操作系统内部,每个Socket都会有一个发送缓冲区,用于保存那些接收方还没有确认的数据。图2-6指示了一个Socket涉及的属性,它分为“用户层面”和“操作系统层面”两大部分。Socket使用的协议、IP、端口属于用户层面的属性,可以直接修改;操作系统层面拥有“发送”和“接收”两个缓冲区,当调用Send方法时,程序将要发送的字节流写入到发送缓冲区中,再由操作系统完成数据的发送和确认。由于这些步骤是操作系统自动处理的,不对用户开放,因此称为“操作系统层面”上的属性。
图2-6 发送缓冲区示意图
发送缓冲区的长度是有限的(默认值约为8KB),如果缓冲区满,那么Send就会阻塞,直到缓冲区的数据被确认腾出空间。
可以做一个这样的实验:删去服务端Receive相关的内容,使客户端的Socket缓冲区不能释放,然后发送很多数据(如下代码所示),这时就能够把客户端卡住。
//点击发送按钮 public void Send() { //Send string sendStr = InputFeld.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); for(int i=0; i<10000; i++){ socket.Send(sendBytes); } }
值得注意的是,Send过程只是把数据写入到发送缓冲区,然后由操作系统负责重传、确认等步骤。Send方法返回只代表成功将数据放到发送缓存区中,对方可能还没有收到数据。
异步Send不会卡住程序,当数据成功写入输入缓冲区(或发生错误)时会调用回调函数。异步Send方法BeginSend的原型如下。
public IAsyncResult BeginSend( byte[] buffer, int offset, int size, SocketFlags socketFlags, AsyncCallback callback, object state )
表2-3对BeginSend的参数进行了说明。
表2-3 BeginSend参数说明
EndSend函数原型如下。它的返回值代表发送的字节数,如果发送失败会抛出异常。
public int EndSend ( IAsyncResult asyncResult )
又到“Show Me The Code”的时间了,修改客户端程序,使用异步发送。
//点击发送按钮 public void Send() { //Send string sendStr = InputFeld.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket); } //Send回调 public void SendCallback(IAsyncResult ar){ try { Socket socket = (Socket) ar.AsyncState; int count = socket.EndSend(ar); Debug.Log("Socket Send succ" + count); } catch (SocketException ex){ Debug.Log("Socket Send fail" + ex.ToString()); } }
注意:在上述代码中BeginSend的第二个参数设置为0;第三个参数sendBytes.Length,代表发送sendBytes一整串数据。读者可以将它们分别设置为1、endBytes.Length-1,代表从第2个字符开始发送。
一般情况下,EndSend的返回值count与要发送数据的长度相同,代表数据全部发出。但也不绝对,如果EndSend的返回值指示未全部发完,需要再次调用BeginSend方法,以便发送未发送的数据(本章只介绍异步程序,后面章节再详细介绍缓冲区)。
使用异步Send时,无论发送多少数据,客户端都不会卡住。测试程序如下所示。
//点击发送按钮 public void Send() { //Send string sendStr = InputFeld.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); for(int i=0; i<10000; i++){ socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket); } }
图2-7是上述代码的输出结果。
图2-7 代码输出信息