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中检测数据,同样是一种浪费。从性能角度考虑,还有不小的改进空间。