[Unity] U3D网络开发实战(1)

第二章:分身有术:异步和多路复用

什么样的代码算是异步代码

如果想实现一个5秒后响铃的方法,可以:

  1. 暂停当前线程5秒,然后响铃:

    System.Threading.Thread.Sleep(5000);

  2. 使用多线程技术,异步回调执行响铃函数:

    Timer timer = new Timer(TimeOut,null,5000,0);

异步客户端

异步Connect

每一个同步API对应着两个异步API,分别是在原名称前面加上Begin和End。客户端发起连接时,如果网络不好或者服务端没有回应,可会断会被卡住一段时间(指同步Connect)。若使用异步程序,则可以防止程序卡住,其核心的API BeginConnect的函数原型如下:

1
public IAsyncResult BeginConnect(string host,int port,AsyncCallback requestCallback,object state)
参数 说明
host 远程主机的名称(IP),如”127.0.0.1”
port 远程主机的端口号,如”8888”
requestCallback 一个AsyncCallback委托,即回调函数,回调函数的参数必须是这样的形式:void ConnectCallback(IAsyncResult ar)
state 一个用户定义对象,可包含连接操纵的相关信息。此对象会被传递给回调函数

EndConnect的函数原型如下。在BeginConnect的回调函数中调用EndConnect,可完成连接。

public void EndConnect(IAsyncResult asyncResult)

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using UnityEngine.UI;
using System;


public class Chap2_Echo : MonoBehaviour {

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

// 点击连接按钮
public void Connection()
{
// Socket
socket = new Socket(AddressFamily.InterNetwork,SocketType.Stream,ProtocolType.Tcp);
// Connect
socket.BeginConnect("127.0.0.1",8888,ConnectCallback,socket);
}
// 点击发送按钮
public void Send()
{
// Send
string sendStr = inputField.text;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);

// Reveice
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();
}

public void ConnectCallback(IAsyncResult ar)
{
try
{
Socket socket = (Socket) ar.AsyncState;
socket.EndConnect(ar);
Debug.Log("Socket Connect Succ");
}
catch (SocketException ex)
{
Debug.Log("Socket Connect fail" + ex.ToString());
}
}

}

异步Receive

与BeginConnect相似,BeginReceive用于实现异步数据的接收。

public IAsyncResult BeginReceive(byte[] buffer,int offset,int size,SocketFlags socketFlags,AsyncCallback callback,object state)

参数 说明
buffer Byte类型的数组,它存储接收到的数据
offset buffer中存储的数据的位置,该位置从0开始计数
size 最多接收的字节数
socketFlags SocketFlags值的按位组合,这里设置为0
callback 回调函数,一个AsyncCallback委托
state 一个用户定义对象,其中包含接收操作的相关信息。当操作完成时,此对象会被传递给EndReceive委托

对应的EndReceive的原型如下,它的返回值代表了接收到的字节数。

public int EndReceive(IAsyncResult asyncResult)

异步Send

TCP是可靠连接,当接收方没有收到数据时,发送方会重新发送数据,直至确认接收方收到数据为止。

在操作系统内部,每个Socket都会有一个发送缓冲区,用于保存那些接收方还没有确认的数据。

发送缓冲区的长度是有限的,如果缓冲区满,那么Send就会被阻塞,知道缓冲区的数据被确认腾出空间。

Send过程只是把书记放到发送缓冲区中,然后由操作系统负责重传、确认等步骤。Send方法返回只代表成功将数据放到发送缓冲区中,对方可能还没有收到数据。

异步Send方法BeginSend的原型如下:

public IAsyncResult BeginSend(byte[] buffer,int offset,int size,SocketFlags socketFlags,AsyncCallback callback,object state)

参数 说明
buffer byte类型的数组,包含要发送的数据
offset 从buffer中的offset位置开始发送
size 要发送的字节数
SocketFlags SocketFlags值的按位组合,这里设置为0
callback 回调函数,一个AsyncCallback索引
state 一个用户定义对象,其中包含发送操作的相关信息。当操作完成时,此对象会被传递给EndSend委托

异步服务端

第一章的同步服务端程序同一时间只能处理一个客户端的请求,因为它会一直阻塞,等待某一个客户端的数据,无暇顾及其他客户端。

管理客户端

可以定义一个名为ClientState的类,用于保存一个客户端信息。ClientState类包含TCP连接所需Socket,以及应用于BeginReceive参数的读缓冲区readBuff。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;

namespace EchoSever
{

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 World!");

// Socket
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("[服务器]启动成功");
// Accept
listenfd.BeginAccept(AcceptCallback, listenfd);
// 等待
Console.ReadLine();

}

// Accept回调
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);

// 接收数据BeginReceive
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 回调
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(SocketException ex)
{
Console.WriteLine("Socket Receive fail" + ex.ToString());
}
}
}

}

代码解释

  1. AcceptCallback是BeginAccept的回调函数:
    1. 给新的连接分配ClientState,并把它添加到client列表中
    2. 异步接收客户端数据
    3. 再次调用BeginAccept实现循环
  2. ReceiveCallback是BeginReceive的回调函数:
    1. 服务端收到消息后,回应客户端
    2. 如果收到客户端关闭连接的信号if(Count == 0),断开连接(当Receive返回值小于等于零时,表示Socket连接断开,可以关闭Socket)
    3. 继续调用BeginReceive接收下一个数据

做个聊天室

状态检测Poll

本节的目的是引入Poll来讲述单线程下的响应优化问题。

什么是Poll

比起异步程序,同步程序更加简单明了,而且不会引发线程问题。只要在阻塞方法前加上一层判断,有数据可读才调用Receive,有数据可写才调用Send,这样就既能实现功能,又不会卡住程序了。

Socket类提供的Poll方法原型如下:

1
public bool Poll(int microSeconds,SelectMode mode)
  • MicroSeconds为等待回应的时间,以微秒为单位,如果该参数为-1,则一直等待,如果为0表示非阻塞。
  • mode对应三种可选的模式,与Socket的可读写性以及连接成功与否有关。

Poll客户端

多路复用Select

什么是多路复用

多路复用,就是同时处理多路信号,比如同时检测多个Socket的状态。

解决Poll服务端CPU占用率过高的方法:同时检测多个Socket的状态,在设置要监听的Socket列表后,如果有一个Socket可读(或可写,或发生错误信息),那就返回这些可读的Socket,如果没有可读的,那就阻塞。

Select方法是实现多路复用的关键,它的原型如下:

1
public static void Select(IList checkRead,IList checkWrite,IList checkError,int microSeconds)
参数 说明
checkRead 检测是否有可读的Socket列表
checkWrite 检测是否有可写的Socket列表
checkError 检测是否有出错的Socket列表
microSeconds 等待回应的时间,以微秒为单位,如果该参数为-1,则一直等待,如果为0表示非阻塞

原理:

Select可以确定一个或多个Socket对象的状态,使用它时,需先将一个或多个套接字放入IList中。通过调用Select可检查Socket是否具有可读性、可写性或错误条件。在调用Select之后,Select将修改IList列表,仅保留那些满足条件的套接字。当没有任何满足条件的Socket时,程序将会阻塞,不占用CPU资源。