2.5 状态检测Poll

使用异步程序,我们已经能够开发一套聊天程序。除了异步,有没有其他技术可以改善聊天室呢?

2.5.1 什么是Poll

比起异步程序,同步程序更简单明了,而且不会引发线程问题。智慧的人们经过多年辛勤钻研,终于在某一天灵光一闪,想到一个处理阻塞问题的绝佳方法,那就是:

        if(socket有可读数据){
            socket.Receive()
        }

        if(socket缓冲区可写){
            socket.Send()
        }

        if(socket发生程序){
            错误处理
        }

只要在阻塞方法前加上一层判断,有数据可读才调用Receive,有数据可写才调用Send,那不就既能够实现功能,又不会卡住程序了么?可能有人会在心里感叹,这样的好方法我怎么就没有想到呢?

微软当然很早就想到了这个解决方法,于是给Socket类提供了Poll方法,它的原型如下:

        public bool Poll (
            int microSeconds,
            SelectMode mode
        )

表2-5对Poll的参数进行了说明。

表2-5 Poll的参数说明

Poll方法将会检查Socket的状态。如果指定mode参数为SelectMode.SelectRead,则可确定Socket是否为可读;指定参数为SelectMode.SelectWrite,可确定Socket是否为可写;指定参数为SelectMode.SelectError,可以检测错误条件。Poll将在指定的时段(以微秒为单位)内阻止执行,如果希望无限期地等待响应,可将microSeconds设置为一个负整数;如果希望不阻塞,可将microSeconds设置为0。

2.5.2 Poll客户端

卡住客户端的最大“罪犯”就是阻塞Receive方法,如果能在Update里面不停地判断有没有数据可读,如果有数据可读才调用Receive,那不就解决问题了么?代码如下:

        //省略各种using
        public class Echo : MonoBehaviour {

            //定义套接字
            Socket socket;
            //UGUI
            public InputField InputFeld;
            public Text text;

            //点击连接按钮
            public void Connection()
            {
                //Socket
                socket = new Socket(AddressFamily.InterNetwork,
                    SocketType.Stream, ProtocolType.Tcp);
                //Connect
                socket.Connect("127.0.0.1", 8888);
            }

            //点击发送按钮
            public void Send(){……//}

            public void Update(){
                if(socket == null) {
                    return;
                }

                if(socket.Poll(0, SelectMode.SelectRead)){
                    byte[] readBuff = new byte[1024];
                    int count = socket.Receive(readBuff);
                    string recvStr =
                        System.Text.Encoding.Default.GetString(readBuff, 0, count);
                    text.text = recvStr;
                }
            }
        }

上述代码调用了socket.Poll,设置为不阻塞模式(microSeconds为0)。比起异步程序,这段代码可谓简洁。程序只处理阻塞Receive,阻塞Send就由读者自己实现吧(也是因为涉及后面的缓冲区章节的内容,所以就留到后面再讲解)。

2.5.3 Poll服务端

服务端可以不断检测监听Socket和各个客户端Socket的状态,如果收到消息,则分别处理,流程如下所示。

        初始化listenfd
        初始化clients列表
        while(true){
            if(listenfd可读)  Accept;
            for(遍历clients列表){
                if(这个客户端可读)  消息处理;
            }
        }

服务端使用主循环结构while(true){……},不断重复做两件事情:

1)判断监听Socket是否可读,如果监听Socket可读,意味着有客户端连接上来,调用Accept回应客户端,以及把客户端Socket加入客户端信息列表。

2)如果某一个客户端Socket可读,处理它的消息(在聊天室中,服务端把消息广播给各个客户端)。

服务端代码如下:

        class MainClass
        {
            //监听Socket
            static Socket listenfd;
            //客户端Socket及状态信息
            static Dictionary<Socket, ClientState> clients =
                new Dictionary<Socket, ClientState>();
            public static void Main (string[] args)
            {
                //Socket
                listenfd = new Socket(AddressFamily.InterNetwork,
                    SocketType.Stream, ProtocolType.Tcp);
                //Bind
                IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
                IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888);
                listenfd.Bind(ipEp);
                //Listen
                listenfd.Listen(0);
                Console.WriteLine("[服务器]启动成功");
                //主循环
                while(true){
                    //检查listenfd
                    if(listenfd.Poll(0, SelectMode.SelectRead)){
                        ReadListenfd(listenfd);
                    }
                    //检查clientfd
                    foreach (ClientState s in clients.Values){
                        Socket clientfd = s.socket;
                        if(clientfd.Poll(0, SelectMode.SelectRead)){
                            if(! ReadClientfd(clientfd)){
                                break;
                            }
                        }
                    }
                    //防止CPU占用过高
                    System.Threading.Thread.Sleep(1);
                }
            }
        }

这段代码有三个注意点。

其一是在主循环最后调用了System.Threading.Thread.Sleep(1),让程序挂起1毫秒,这样做的目的是避免死循环,让CPU有个短暂的喘息时间。

其二是ReadClientfd会返回true或false,返回false表示该客户端断开(收到长度为0的数据)。由于客户端断开后,ReadClientfd会删除clients列表中对应的客户端信息,导致clients列表改变,而ReadClientfd又是在foreach(ClientState s in clients.Values)的循环中被调用的,clients列表变化会导致遍历失败,因此程序在检测到客户端关闭后将退出foreach循环。

其三是将Poll的超时时间设置为0,程序不会有任何等待。如果设置较长的超时时间,服务端将无法及时处理多个客户端同时连接的情况。当然,这样设置也会导致程序的CPU占用率很高。

下面来看看ReadListenfd和ReadClientfd两个方法的实现。

ReadListenfd代码如下。它和异步服务端中AcceptCallback很相似,用于应答(Accept)客户端,添加客户端信息(ClientState)。

        //读取Listenfd
        public static void ReadListenfd(Socket listenfd){
            Console.WriteLine("Accept");
            Socket clientfd = listenfd.Accept();
            ClientState state = new ClientState();
            state.socket = clientfd;
            clients.Add(clientfd, state);
        }

ReadClientfd代码如下。它和异步服务端中的ReceiveCallback很相似,用于接收客户端消息,并广播给所有的客户端。

        //读取Clientfd
        public static bool ReadClientfd(Socket clientfd){
            ClientState state = clients[clientfd];
            //接收
            int count = 0;
            try{
                count = clientfd.Receive(state.readBuff);
            }catch(SocketException ex){
                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("Receive SocketException " + ex.ToString());
                return false;
            }
            //客户端关闭
            if(count == 0){
                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("Socket Close");
                return false;
            }
            //广播
            string recvStr =
                System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
            Console.WriteLine("Receive" + recvStr);
            string sendStr = clientfd.RemoteEndPoint.ToString() + ":" + recvStr;
            byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
            foreach (ClientState cs in clients.Values){
                cs.socket.Send(sendBytes);
            }
            return true;
        }

尽管逻辑清晰,但Poll服务端的弊端也很明显,若没有收到客户端数据,服务端也一直在循环,浪费了CPU。Poll客户端也是同理,没有数据的时候还总在Update中检测数据,同样是一种浪费。从性能角度考虑,还有不小的改进空间。