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);
        ...
    }
}        

  我们看到使用这样的客户端依然会不断循环检测数据,性能较差。所以一般商业游戏为了性能开发我们可以选择异步客户端,多路复用服务端

 

posted @ 2023-07-18 15:30  CatSevenMillion  阅读(475)  评论(0编辑  收藏  举报