Unity3D网络游戏实战——分身有术:异步和多路复用

前言

前面一篇文章全部用的是阻塞API,也就是同步Socket程序,客户端一卡一顿、服务端只能一次处理一个客户端的消息,不具有实用性。
所有有了异步和多路复用两种技术解决了阻塞问题。

2.1什么是异步代码

不需要进程一直等待下去,而是继续往下执行,直到满足条件才调用回调函数,这样可以提高执行的效率。
异步的实现依赖于多线程技术。在Unity中,执行Start、Update方法的线程是主线程,会另外开一条线程执行异步代码,然后满足条件后另一条线程调用回调函数,主线程继续往下执行代码,不受影响。

2.2异步客户端

2.2.1异步Connect

每一个同步API对应两个异步API,分别是在原名称前加上Begin和End。
如果使用Connect且服务端没有答复,那么用户在尝试连接这段时间内不能做任何操作,体验极差。
这里就介绍一个BeginConnect的函数原型,后面出现的异步API都大同小异

public IAsyncResult BeginConnect(
	string host,//远程主机IP
	int port,//远程主机端口号
	AsyncCallback requestCallback,//回调函数形式:void ConnectCallback(IAsyncResult ar)
	object state//一般用自定义的客户端类来传进去,一般包含链接操作的相关信息,会被传递给callback
)

IAsyncResult是.NET提供的一步操作,通过begin…和end…完成原同步方法的异步调用。begin…包含回调委托和用户定义的状态对象,返回一个实现IAsyncResult接口的对象。end…用于结束一步操作返回结果,返回值和同步方法相同。

2.2.2异步客户端代码

里面的代码有点乱,因为我是把一整章的代码打完才写文章的。

//点击连接按钮
    public void Connection()
    {
        //新建sockect
        //地址族IPV4,套接字类型stream,协议类型
        socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        //connect
        //远程IP地址,远程端口,阻塞方法,卡住直到服务端回应、
        //自己建立服务器的话,ip地址和端口号就是这两个
        //socket.Connect("127.0.0.1", 8888);

        //异步
        socket.BeginConnect("127.0.0.1", 8888, ConnectCallback, socket);
    }
    //Connect回调
    public void ConnectCallback(IAsyncResult ar)
    {
        try
        {
            //此socket可由ar.AsyncState获取到
            Socket socket = (Socket)ar.AsyncState;
            socket.EndConnect(ar);
            Debug.Log("Socket Connect Succ");

            //接收缓冲区、0表示从readBuff第0位开始接收数据(和TCP粘包问题有关)、每次最多接收1024字节数据(即使服务器发送1025,也只接收1024)
            //接收函数调用时机:在连接成功后就开始接受数据,接收到数据后,回调函数ReceiveCallback被调用
            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);
            string s = System.Text.Encoding.Default.GetString(readBuff, 0, count);
            recvStr = s + "\n" + recvStr;
            //接收完一串数据后,等待下一串数据的到来
            socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
        }
        catch (SocketException ex)
        {
            Debug.Log("Socket Receive fail" + ex.ToString());
        }
    }
    //点击发送按钮
    public void Send()
    {
        //send
        if (canSend)
        {
            string sendStr = InputField.text;
            //string sendStr = System.DateTime.Now.ToString();
            //将str转化为字节流
            byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
            //socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket);

            socket.Send(sendBytes);
        }
        //异步不需要receive
        //Recv
        //byte[] readBuff = new byte[1024];
        接收数据的长度
        //int count = socket.Receive(readBuff);
        //string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
        //text.text = recvStr;
        Close
        //socket.Close();
    }

    //Send回调
    public void SendCallback(IAsyncResult ar)
    {
        try
        {
            //这个socket是传进回调的用户定义对象,可强转为socket
            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());
        }
    }

大致讲几个重点部分:

  • Connect会等待服务端应答导致卡顿、Receive会等到收到服务端数据卡顿、send会等到接收方确认收到消息而卡顿,我们把这些都换成了异步方法。
  • BeginReceive的调用位置分别在ConnectCallback,在连接成功后就开始接收数据,收到数据后,回调函数ReceiveCallback被调用。另一个就是在BeginReceive内部,接收完一串数据后,等待下一串数据的到来。
  • 在Unity中只有主线程可以操控UI组件,所以在其他线程执行的异步回调不能直接给text赋值,只能给一个全局变量赋值然后再给text赋值
  • Send有发送缓冲区,如果满了(也就是服务端不接收且一直发送的情况),就会卡住。而且send只是把数据写入到缓冲区,返回只代表成功将数据发送到缓存区,对方可能没有收到数据

2.3异步服务端

同步服务端同一时间只能处理一个客户端的请求,用异步方法就可以同时处理多个。

2.3.1管理客户端

因为服务端需要转发某个客户端发送来的消息给所有客户端,所以要有列表平保存所有连接到该服务端的客户端。
这个socket是服务端用于回应这个客户端的socket对象,并不是客户端用于连接的socket。
readBuff是填充BeginReceive参数的读缓冲区

class ClientState
    {
        public Socket socket;
        public byte[] readBuff = new byte[1024];
    }

再用方便获取客户端信息

static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();

2.3.2服务端代码

using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;

namespace ConsoleApp1
{
    class ClientState
    {
        public Socket socket;
        public byte[] readBuff = new byte[1024];
    }
    class MainClass
    {
        //异步服务器
        //监听Socket
        static Socket listenfd;
        //客户端Socket及状态信息
        static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();

        public static void Main(string[] args)
        {
            Console.WriteLine("Hello");
            //Socket
            Socket listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            //Bind
            IPAddress ipAdr = IPAddress.Parse("127.0.0.1");//IP地址
            IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888);//IP和端口
            //给listenfd套接字绑定IP和端口,"127.0.0.1"地址和8888号端口
            listenfd.Bind(ipEp);
            //Listen,等待客户端连接
            //0表示容纳等待接收的客户端连接数不受限制,1代表最多可容纳等待接受的连接数为1
            listenfd.Listen(0);
            Console.WriteLine("[服务器]启动成功");

            //异步服务器
            listenfd.BeginAccept(AcceptCallback, listenfd);
            //等待
            Console.ReadLine();
        }
//Accept回调
        //是beginaccept的回调函数,处理3件事
        //1.给新的连接分配ClientState,并把它加入到clients列表中
        //2.异步接收客户端数据
        //3.再次调用BeginAccept循环
        public static void AcceptCallback(IAsyncResult ar)
        {
            try
            {
                Console.WriteLine("[服务器]Accept");
                //监听和应答客户端的socket
                Socket listenfd = (Socket)ar.AsyncState;
                //处理该客户端的socket
                Socket clientfd = listenfd.EndAccept(ar);
                //clients列表
                ClientState state = new ClientState();
                //初始化此客户端类,key和value岂不是重复利用了?
                state.socket = clientfd;
                clients.Add(clientfd, state);
                //接收数据BeginReceive,以ClientState取代Socket
                clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
                //继续Accept
                listenfd.BeginAccept(AcceptCallback, listenfd);
            }
            catch(SocketException ex)
            {
                Console.WriteLine("Socket Accept fail" + ex.ToString());
            }
        }

        //Receive回调
        //1.服务端收到消息后,回应客户端
        //2.如果收到客户端关闭连接的信号"if(count==0)",断开连接
        //3.继续调用BeginReceive接收下一个数据
        public static void ReceiveCallback(IAsyncResult ar)
        {
            try
            {
                //发送消息的客户端
                ClientState state = (ClientState)ar.AsyncState;
                Socket clientfd = state.socket;
                //当receive返回值小于等于0时,表示socket连接可以断开
                int count = clientfd.EndReceive(ar);
                //客户端关闭
                if(count == 0)
                {
                    clientfd.Close();
                    clients.Remove(clientfd);
                    Console.WriteLine("Socket Close");
                    return;
                }
                //从收到的字节流转为string
                string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
                string sendStr = clientfd.RemoteEndPoint.ToString() + ":" + recvStr;
                byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);//string转为bytes
                //用于处理该客户端数据的socket
                foreach(ClientState s in clients.Values)
                {
                    s.socket.Send(sendBytes);
                }
                clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
            }
            catch(SocketException ex)

            {
                Console.WriteLine("Socket Receive fail" + ex.ToString());
            }
        }
    }

重点:

  • 将Accept和Receive变为异步。否则Accept会一直阻塞在该语句上,用了异步后,等客户端连接上来回调函数就会被执行。可用EndAccept获取新客户端的socket。
  • 异步服务器结构:Socket、Bind、Listen初始化后监听socket,用BeginAccept异步处理客户端连接,如有连接就调用AcceptCallback回调,让客户端开始接受数据。再急需调用BeginAccept等待下一个连接
  • 当Receive返回值小于等于0时,表示连接断开,可以关闭Socket。

2.5状态监测Poll

在阻塞方法前面加上一层判断,有数据可读时调用Receive,有数据写调用Send,这就是Poll。
Socket类的Poll方法:

public bool Poll(
	int microSeconds,
	SelectMode mode
)

第一个参数是等待回应的时间,以ms为单位,如果为-1就一直等待,如果为0表示非阻塞(不希望阻塞就写0)。
mode有三种可选模式,不细嗦了

2.5.1Poll客户端

卡住客户端最大的问题就是阻塞Receive,只要在Update里不停判断有没有数据可读,有数据可读才调用Receive就行了
一般客户端的poll设为不阻塞模式

public void Update()
    {
        if(socket == null)
        {
            return;
        }
        //poll客户端
        if (socket.Poll(0, SelectMode.SelectRead))
        {
            byte[] readBuff = new byte[1024];
            int count = socket.Receive(readBuff);
            //不阻塞模式,microSeconds=0
            string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
            text.text = recvStr;
        }

        //处理阻塞send应该也差不多
        if (socket.Poll(0, SelectMode.SelectWrite))
        {
            canSend = true;
        }
        else
        {
            canSend = false;
        }
    }

比异步的套娃简洁,该干嘛时干嘛即可。

2.5.3Poll服务端

注意点:

  • 不断检测 监听Socket(llistenfd)和各个客户端Socket的状态,如果监听Socket可读意味有客户端连接,就Aceept回应,并将其加入列表。客户端socket可读,就转发它的消息给所有客户端
  • while循环最后有一个sleep让程序挂起1ms,避免死循环。
  • 如果收到长度为0的数据就直接break循环,因为为0就会将这个客户端踢出列表,再遍历它会出错。
  • Poll超时时间设为0,可以及时处理多个客户端连接,但是导致CPU占用率高
  • 最后两个Read函数和对应的回调做的事差不多,都是条件达到了就调用
		public static void Main(string[] args)
        {
            Console.WriteLine("Hello");
            //Socket
            Socket listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            //Bind
            IPAddress ipAdr = IPAddress.Parse("127.0.0.1");//IP地址
            IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888);//IP和端口
            //给listenfd套接字绑定IP和端口,"127.0.0.1"地址和8888号端口
            listenfd.Bind(ipEp);
            //Listen,等待客户端连接
            //0表示容纳等待接收的客户端连接数不受限制,1代表最多可容纳等待接受的连接数为1
            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))
                    {
                        //false表示客户端断开(收到长度为0的数据)
                        //断开会删掉列表中对应的信息,导致遍历失败,所以直接break
                        if (!ReadClientfd(clientfd))
                        {
                            break;
                        }
                    }
                }
                //防止CPU占用过高
                //让程序挂起1ms,避免死循环让CPU喘息
                System.Threading.Thread.Sleep(1);
            }
        }
		public static void ReadListenfd(Socket listenfd)
        {
            Console.WriteLine("Accept");
            Socket clientfd = listenfd.Accept();
            ClientState state = new ClientState();
            state.socket = clientfd;
            clients.Add(clientfd, state);
        }

        //和异步服务端的Receivecallback类似,用于接收客户端消息,并广播给所有客户端
        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);
            //发送回去的是ip+收到的
            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;
        }

2.6多路复用Select

2.6.1什么是多路复用

虽然Poll逻辑清晰,但是弊端也很明显,比如服务端没有收到客户端数据也一直循环,浪费Cpu。
所以有了多路复用,也就是同时处理多路信号,比如同时检测多个Socket的状态。
办法就是:在需要坚挺的Socket列表中,如果有一个(or多个)Socket可读(or可写or错误),就返回这些Scoket加入对应的列表。

Public static void Select(
	IList checkRead,
	IList checkWrite,
	IList checkError,
	int microSeconds
)

将需要检测的socket加入对应列表后,作为这函数的参数被调用就会改变原列表,只会留下符合要求的。当没有符合要求的socket就会阻塞。

2.6.2Select服务端

仍然使用while(true){…}结构,不断调用Select检测Socket状态。如果不为空就调用对应的函数,类似回调,处理对应的socket。

			//select服务器
            //checkRead
            List<Socket> checkRead = new List<Socket>();
            //主循环
            while (true)
            {
                //填充checkRead列表
                checkRead.Clear();
                checkRead.Add(listenfd);
                foreach (ClientState s in clients.Values)
                {
                    checkRead.Add(s.socket);
                }
                //select,只将待检查可读的列表传进去
                Socket.Select(checkRead, null, null, 1000);
                //调用完上面的方法后这个列表就被改了,这个列表中只有可读的socket
                foreach (Socket s in checkRead)
                {
                    //因为listnfd本身就被加进去了
                    if (s == listenfd)
                    {
                        ReadListenfd(s);
                    }
                    //除了listen其余都是可读的客户端,直接处理即可
                    else
                    {
                        ReadClientfd(s);
                    }
                }
            }

2.6.3Select客户端

代码在update中,和poll客户端极其相似,而且只需要检查一个socket。
**问题在于,如果没有可读的,是会卡住update线程吗??**可能它本身就是开启一个异步线程来检测,卡住的是异步线程

		checkRead.Clear();
        checkRead.Add(socket);
        //select
        Socket.Select(checkRead, null, null, 0);
        //check
        foreach (Socket s in checkRead)
        {
            byte[] readBuff = new byte[1024];
            int count = socket.Receive(readBuff);
            string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
            text.text = recvStr;
        }

在Update监测数据性能太差,商业上一般使用异步或者用多线程模拟异步

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值