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 代码输出信息