Unity 网络编程二: 异步与多路复用
1.什么是异步Socket程序
之前学习过的网络方法都是同步的方法,程序运行到Connect,Send,Receive时会被阻塞。同步的Socket方法虽然实现简单,但是在使用时会时不时卡住游戏,这是我们不想看到的。而异步程序的意思则是在游戏运行时,通过异步调用的方式,可以不卡顿游戏的同时连接网络。
在Unity中实现异步的方法需要依赖多线程技术,Untiy中执行主线程的部分,子线程负责处理回调函数。(Untiy中只有主线程能够访问Unity对象,子线程只能负责处理网络部分)
2.异步客户端
2.1 异步Connect
public IAsyncResult BeginConnect( string host, int port, AsyncCallback requestCallback, object state )
host:远程主机号,port:端口号,requestCallback:回调函数(形式必须为 void ConnectCallback(IAsyncResult ar) ),state:连接操作相关信息
IAsyncResult,是.NET提供的一种异步操作,通过名为BeginXXX,EndXXX的两个方法实现原同步方法的异步调用。BeginXXX方法用途返回一个实现AsyncResult接口的对象,EndXXX用于结束异步操作并返回结果。
实例:
public void Connection() { // Socket Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // connect socket.BeginConnect("127.0.0.1",8888,,socket); } public void ConnectCallback(IAsyncResult ar) { try { Socket socket = (Socket)ar.AsyncState; socket.EndConnect(ar); Debug.Log("Socket Connect Succ"); } catch (Exception e) { Debug.Log("Error: "+e.ToString()); } }
由BeginConnect最后一个参数传入socket,可由ar.AsyncState获得。
2.2 异步Receive
public IAsyncResult BeginReceive( byte[] buffer; int offset; int size; SocketFlags socketFlags; AsyncCallback callback; object state; )
其他都与之前的方法一致,SocketFlags是 SocketFlags值的按位组合,这里设置为0。
using System; using System.Net.Sockets; using TMPro; using UnityEngine; public class AsyncEcho : MonoBehaviour { // Socket private Socket _socket; // UGUI public TMP_InputField InputField; public TMP_Text Text; // 接收缓存 private byte[] readBuff = new byte[1024]; private string recvStr = ""; // Connect public void Connection() { // 地址族 , 套接字类型, 协议类型 _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _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 (Exception e) { Debug.Log("Socket Connect Fall!"+ar.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 (Exception e) { Debug.Log("Socket receive Fall"+e.ToString()); } } // 发送 public void Send() { // Send string sendStr = InputField.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); _socket.Send(sendBytes); // 不需要Receive了 } public void Update() { Text.text = recvStr; } }
为了防止子线程操作Unity的UI组件,我们在Update中给Text赋值。所以程序只给变量recvStr赋值。
2.3 异步Send
由上,Send依然是一个阻塞的方法,这可能导致客户端在发送数据的一瞬间被卡住。TCP是可靠连接,当接收方没有收到数据时,发送方会重新发送数据,直至确认接收方收到数据为止。Socket内部实现有一个发送缓存区,如果发送时发送缓冲区满了,就会阻塞Send。
异步发送代码如下:
// 发送 public void Send() { // Send string sendStr = InputField.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); _socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, _socket); // 不需要Receive了 } public void SendCallback(IAsyncResult ar) { try { Socket socket = (Socket)ar.AsyncState; int count = socket.EndSend(ar); Debug.Log("Socket Send Succ !"+count); } catch (Exception e) { Debug.Log("Send Fail!"+e.ToString()); } }
3.异步服务端
前一章的同步服务端同一时间只能处理一个客户端请求,因为它会一直阻塞,等待某一个客户端的数据,无暇接应其他客户端。使用异步的方法,可以让服务端同时处理多个客户端的数据,并及时响应。
异步客户端的结构依然分为三方面,一样是Bind,Listen,Accept。如果有客户端连接进来,异步Accept会让客户端开始接收数据,然后继续调用BeginAccept方法来接收下一个客户端的消息。
using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; namespace GameService { class ClientState { public Socket socket; public byte[] readBuff = new byte[1024]; } class MainClass { // 监听Socket private static Socket listenfd; // 客户端Socket及状态信息 private static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>(); public static void Main(string[] args) { Console.WriteLine("Hello World"); // Bind listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAdr = IPAddress.Parse("127.0.0.1"); IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888); listenfd.Bind(ipEp); // Listen listenfd.Listen(0); Console.WriteLine("服务器-启动成功"); // Accept listenfd.BeginAccept(AcceptCallback, listenfd); // Wait Console.ReadLine(); } public static void AcceptCallback(IAsyncResult ar) { try { Console.WriteLine("服务器-Accept"); Socket listenfd = (Socket)ar.AsyncState; Socket clientfd = listenfd.EndAccept(ar); // clients 列表 ClientState state = new ClientState(); state.socket = clientfd; clients.Add(clientfd,state); // 接收 clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state); // 继续Accept listenfd.BeginAccept(AcceptCallback, listenfd); } catch (Exception e) { Console.WriteLine(“Socket Accept Fail+”e.ToString()); } } public static void ReceiveCallback(IAsyncResult ar) { try { ClientState state = (ClientState)ar.AsyncState; Socket clientfd = state.socket; int count = clientfd.EndReceive(ar); // 客户端关闭 if (count == 0) { clientfd.Close(); clients.Remove(clientfd); Console.WriteLine("Socket Close"); return; } string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count); byte[] sendBytes = System.Text.Encoding.Default.GetBytes("echo" + recvStr); clientfd.Send(sendBytes); // 就不异步了 clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state); } catch (Exception e) { Console.WriteLine("Socket Receive Fail"+e.ToString()); } } } }
一个服务端可以用来保存多个客户端信息,所以这里建立一个类ClientState,用来存放单个类的信息(Socket 以及接收缓存)。同时用一个字典Clients保存Socket与之ClientState信息。
AcceptCallback主要处理三件事:
1)给新的连接分配ClientState,并添加到clients列表。
2)异步接收客户端数据。
3)再次调用BeginAccept实现异步接收效果。
ReceiveCallback也处理三件事:
1)服务端收到消息后,回应客户端。
2)如果收到客户端关闭连接的信号“count==0”,则断开连接
3)继续调用BeginReceive接收下一条数据。
4.做一个聊天室
制作聊天室也很简单,只需要简单更改上述代码即可。
对于服务端:服务端会遍历在线的客户端,然后推送消息。
public static void ReceiveCallback(IAsyncResult ar) { try { ...string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count); byte[] sendBytes = System.Text.Encoding.Default.GetBytes("echo" + recvStr);
foreach(ClientState s in clients.Values){
s.socket.Send(sendBytes);
}
clientfd.Send(sendBytes); // 就不异步了 clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state); }... }
对于客户端,只需要保存显示之前的消息即可。
public void ReceiveCallback(IAsyncResult ar) { try { Socket socket = (Socket)ar.AsyncState; int count = socket.EndReceive(ar);
string s = System.Text.Encoding.Default.GetString(readBuff,0,count); recvStr = s+"\n"+recvStr; socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket); } catch (Exception e) { Debug.Log("Socket receive Fall"+e.ToString()); } }
5.状态检测Poll
我们已经使用异步的方式优化了聊天室,那还有什么其他方式吗?
相比于异步程序,同步程序更加简单,而且不会引发线程问题。所以为了解决处理阻塞想到了一个绝妙的方法:
if(socket 有可读数据){ socket.Receive(); } if(socket 缓冲区可写){ socket.Send(); } if(socket 错误){ 处理错误; }
微软为我们实现了上述的过程,那就是Socket类中的Poll。
public bool Poll( int microSeconds; SelectMode mode; )
mircroSecond:等待回应时间。mode:嗓子办法Poll模式,对应上面三种情况。
Poll客户端:(实现了不阻塞Receive)
using System; using System.Net.Sockets; using TMPro; using UnityEngine; public class PollEcho : MonoBehaviour { // Socket private Socket _socket; // UGUI public TMP_InputField InputField; public TMP_Text Text; public void Connection() { // 地址族 , 套接字类型, 协议类型 _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.Connect("127.0.0.1",8888); } public void Send() { // Send string sendStr = InputField.text; byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr); _socket.Send(sendBytes); // Close _socket.Close(); } 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; } } }
Poll服务端:
using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; namespace GameService { class PollService { // 监听Socket private static Socket listenfd; // 客户端Socket及状态信息 private static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>(); public static void Main(string[] args) { Console.WriteLine("Hello World"); // Bind listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAdr = IPAddress.Parse("127.0.0.1"); IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888); listenfd.Bind(ipEp); // Listen listenfd.Listen(0); Console.WriteLine("服务器-启动成功"); // main 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; } } } System.Threading.Thread.Sleep(1); } } // 读取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); } // 读取Clientfd public static bool ReadClientfd(Socket clientfd) { ClientState state = clients[clientfd]; // 接收 int count = 0; try { count = clientfd.Receive(state.readBuff); } catch (Exception e) { clientfd.Close(); clients.Remove(clientfd); Console.WriteLine("Receive Socket Error"+e); 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; } } }
服务端可以不断地监听Socket和各个客户端Socket的状态,如果收到消息则处理。
Poll服务端需要注意几点:
1)主循环最后调用Sleep函数,避免死循环。
2)Poll超时时间设置为0,程序不会有任何等待,CPU占用率会很高。如果设置时间过长,则服务端可能无法及时处理与多个客户端的响应过程。
6.多路复用Select
Poll程序虽然解决了异步实现复杂的问题,但是它也有一个非常严重的问题,那就是若没有收到客户端数据,服务端也一直在循环,就会浪费CPU。同理,客户端也会不断检测数据,也会造成性能浪费。
什么是多路复用?
同时检测多个Socket的状态。在设置要监听的Socket列表后,如果有一个或多个Socket可读,那就返回这些可读的Socket,如果没有可读,那就阻塞。
public static void Select( IList checkRead; IList checkWrite; IList checkError; int microSeconds; )
checkRead:检测是否有可读Socket列表。
服务端:(经过Select处理,如果Socket可读,则Accept,如果可读,则广播)
public static void Main(string[] args) { Console.WriteLine("Hello World"); // Bind listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAdr = IPAddress.Parse("127.0.0.1"); IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888); listenfd.Bind(ipEp); // Listen listenfd.Listen(0); Console.WriteLine("服务器-启动成功"); // checkRead List<Socket> checkRead = new List<Socket>(); // main while (true) { checkRead.Clear(); checkRead.Add(listenfd); foreach (ClientState s in clients.Values) { checkRead.Add(s.socket); } // select Socket.Select(checkRead,null,null,1000); // 检查可读对象 foreach (Socket s in checkRead) { if (s == listenfd) { ReadListenfd(s); } else { ReadClientfd(s); } } } }
改动都在Main中。
客户端:
与使用Poll的客户端代码类似,因为只需要检测Socket的状态,将连接服务端的socket输入到checkRead列表即可。为了不卡住客户端,这里超时时间设置为0。
public void Update(){ if(socket ==null) return; // checkRead.Clear(); cheacRead.Add(socket); //select Socket.Select(checkRead,null,null,0); //check foreach(Socket s in checkRead){ byte[] readBuff = new byte[1024]; int count = s.Receive(readBuff); ... } }
我们看到使用这样的客户端依然会不断循环检测数据,性能较差。所以一般商业游戏为了性能开发我们可以选择异步客户端,多路复用服务端。