《Unity3D高级编程之进阶主程》第六章,网络层(二) - 实现TCP

程序实现TCP长连接。

===

前面讲了很多关于协议的介绍和一些底层的知识,这节我们从原理出发在程序上实现协议的通信。我们先从整体出发,列一下实现TCP连接需要的考虑哪些方面:

	1, 建立连接

	2, 断线检测

	3, 网络协议

	4, 发送和接收队列缓冲

	5, 发送数据合并

	6, 线程死锁策略

上面6个方面是程序在建立和实现连接的必备要素,我们下文将依次讲解如何来对此作出程序上的实现。

TCP本身已经具备有数据包可靠性确认,以及丢包重发机制,数据包的大小也没有做限时,从TCP那里我们可以把免费得到的这些功能。所以在实现TCP的连接中不需要我们再做包括:包体的校验、包体的拆分,以及重发数据包,省掉了很大一部分麻烦。我们只需要做的就是建立连接,发送,以及接收,三个步骤,以及这三个步骤引起的一系列问题的对策。不过如果不对发送数据进行合并,就会有很多小的数据分批发送,导致发送效率降低。所以我们下文中会提到关于发送合并的问题。

介绍下TCP的API库

c#的.net库提供了 TCP 的 Socket连接API,我们来看看c#的.net库中的这些API的用法。

一般情况下我们不会去用阻塞方式连接和接收,因为我们不会让你的游戏卡住不动来等待连接,因为这有可能导致崩溃,所以连接,接收,断开都是异步的线程操作。同步阻塞的操作可能会在周边工具中会用到,比如编辑器的工具,回放工具,GM的工具等,但其他大部分时候都会更加平滑的异步操作作为网络连接和收发操作。

	BeginConnect,开始连接

	BeginReceive,开始接收信息

	BeginSend,开始发送数据

	BeginDisconnect,开始断开

	Disconnect(Boolean),立刻断开连接

上锁接口中前四个都是异步的,调用后会开启一个线程来工作,最后一个为同步阻塞方式的断开连接接口。最后一个阻塞式大都在游戏退出时调用,但问题是APP没有退出事件,因此一般Disconnect都会用在Unity3D Eitor下或者windows版本上调用,以保证在开发时强制退出后编辑器不会奔溃。

线程锁

实际项目中网络模块中所有的操作都会以线程级的形式对待,而Unity3D的渲染和逻辑都是在主线程上运作的,这里就涉及到了主线程和子线程对资源抢占冲突导致我们需要做线程锁的问题。

当主线程与子线程一起工作到某时间点都需要某个内存块或者资源时,就会同时去读取或者写入资源,这就会造成资源读写混乱的情况。因此在所有线程上,在调用有冲突的资源时,都需要做锁的操作,以防止线程们在读取或写入操作时对资源错误的争夺。

我们拿网络接收数据的线程来举例,接收线程在接收到网络数据后将数据推入到队列里,在push操作上就需要做锁的操作:


lock(obj)
{
	mQueue.Push(data);
}

另一边当主线程在读取网络数据时,当需要推出一个数据时,在推出元素操作上也需要加一个锁的操作。


Lock(obj)
{
	data = mQueue.Pop();
}

由于两边的线程都需要对队列进行操作,所以每次对线程共享的资源进行操作时,都需要先进行锁确认的操作,以避免线程争夺资源而造成混乱。我们拿在海上航行的运输船来做比喻,每个线程都是一条船,每条船都在各自做着自己的事,直到两条船都要进港口卸货,港口几条卸货的通道,它们不得不排队等待前面的船只卸完才能轮到自己,每次它们(可以认为是线程)都会去查看是否有空出来的航线,如果有则先按下标记说我先占了,其他船只在查看时则会看到已被占用的标记则继续等待。

缓冲队列

在网络收发时,数据会远远不断进行发送和接收,很多时候程序还没处理好当前的数据包,就已经有许许多多的数据包从服务器已经传送到达了客户端。发送数据也是一样,会瞬间积累很多的需要发送的数据包,这些数据包如果没有保存好则无法进行重发甚至丢失,所以我们需要用一个队列来进行存储和缓冲,它就被称为缓冲队列。

一般我们会让负责接收的子线程把接收好的网络数据包放入接收缓冲队列,再由主线程通过Update轮训去检查接收队列里是否有数据,有的话则一个个取出来处理,没有的话继续轮训等待。

下面伪代码表达了,主线程每帧检查一下是否有收到信息,检测到就立刻处理,没有的话下一帧继续轮训检测。如果没有信息接收来时,子线程上会阻塞等待直到有消息接收到才会调用接收消息接口,将数据包解析后推入接收队列。


/////////////////////////////////////////////////////////
//接收线程等待接收数据并推入队列
try
{
	socket.BeginReceive( Receive_Callback );
}
catch (Exception e)
{
    Log(LogerType.ERROR, e.StackTrace);
    DisConnect();
}

void Receive_Callback(IAsyncResult  _result)
{
	PushNetworkData(_result); //将数据推入队列
	Receive();	//继续接收数据消息
}

///////////////////////////////////////////////////////////////
//主线程处理数据队列里的数据
void Update()
{
	While( (data = PopNetworkData() )!= null )
	{
		DealNetworkData(data);
	}
}

上面的伪代码中,首先有子线程的等待接收数据,接收到数据以后就立即将数据推入队列。另一面,主线程一直在轮询是否有已经接收到的网络数据,如果有就立即逐个处理全部数据。

其中接收数据时会有些细节在里面,因为数据包并不会按照我们希望的大小发送,所以它或多或少的都会拆分一些数据或者粘粘了其他的数据,导致我们需要识别是否是一个完整的数据包,然后再从中解析出正确的数据包。这部分的数据包格式定义我们把它挪到了后面的章节中,在网络协议格式章节中进行详细的讲解。

双队列结构

前面提到的缓冲队列是在多线程编程中常用的手段之一,不过它的效率还不够高,因为多个线程的锁的效率影响会被锁点卡住导致其他线程无法继续工作。双队列数据结构就能很好解决这个问题,它能增强多线程中队列的读写效率。

双队列是一种高效的内存数据结构,在多线程编程中能保证生产者线程的写入和消费者的读出尽量做到最低的影响,避免了共享队列的锁开销。

在大多数多线程工作时都需要对缓冲队列读写,其中接收数据的网络线程会将数据写入队列,而处理数据的主线程则会读取队列头部并删除(即我们所说的弹出),两者都会读写队列导致资源争夺,因此通常会增加锁机制来规范它们的行为。但是锁机制导致线程会常常处于等待状态,因为占用的线程需要处理些复杂的逻辑导致其他线程需要暂停很久才能继续工作,因此加入了线程锁的机制后,仍然没能很好的解决两个线程顺畅操作一个队列的问题。实际项目中处理响应数据逻辑的主线程需要花很久的时间去处理网络数据使之反应到画面上,这时接收数据的线程因为接收队列被主线程锁住而不能继续自己的工作去接收数据,所以子线程只能等待资源使用完毕后才能使用资源,当这个接收到数据所需要处理的逻辑很多很复杂时,那么子线程就要等很少时间,大大降低了线程了效率。

用双队列的形式就能让线程处理队列时解放出来,让线程的效率大大增加,使得各线程能够各自处理调用各自的队列处理而不用因为资源锁而等待。双队列与普通的缓冲队列在接收数据包部分的逻辑操作都是一样的,即接收数据线程接收到数据时直接推入接收数据的队列,不一样的地方在当处理数据的线程轮询时,先将接收数据的队列拷贝到处理数据的队列中并清空接收数据的队列,然后主线再对拷贝后的数据队列进行处理,这时子线程无需等待主线程的逻辑处理时间就能够顺利的继续接收数据。这样就解放了两个线程各自工作的冲突时间,即两个队列分别理解为了接收数据队列和处理数据队列,当主线程需要处理数据时,先把接收到的数据队列中的数据置换为处理数据队列上,最后各自继续处理自己的工作因为队列已经分开。

我把最关键部分提取出来用伪代码描述,如下:


/////////////////////////////////////////////
//子线程中,接收数据线程
void Receive_CallBack(Data _result)
{
	Pushdata(_result);
}

/////////////////////////////////////////
//处理数据的主线程
void SwitchQueue()
{
	lock(obj)
	{
		Swap(receiveQueue, produceQueue);
	}
}

void Update()
{
	SwitchQueue();
	while( (data = PopQueue()) != null )
	{
		Deal_with_network_data(data);
	}
}

上述伪代码中,首先是对子线程的接收部分描述,当接收到数据包时与普通的接收一样只需将数据推送到接收队列中即可,当主线程需要处理数据时,先切换队列防止对队列占用过多时间,切换完毕后,再对队列中的全部数据进行处理。

这样一来两个线程在锁上的时间变短了,原本要在处理期间全程上锁导致其他线程无法使用,现在只在切换那一瞬间锁上资源即可,其他时间各线程都能顺畅得各自做自己的工作,这样大大提高了多线程的工作效率。

发送数据

我们前面说的都是接收时的队列,发送数据时也需要队列来做缓冲。当发送的数据包会很多时,也有可能很短时间内会积累过多数据包导致发送池溢出。如果发送时大多数的数据包都是很小很小的数据包,如果每个数据包都发送一次等待接受后再发送就会导致发送效率过低,发送太慢导致延迟过大。而如果一下子把全部数据都发送的话,发送的数据可能会太大,导致发送效率很差,因为数据包越大越容易发送失败或丢包,TCP就会全盘否定这次发送的内容,并将整个包都重新发送一次,效率极其糟糕。

因此我们需要自己建立发送缓冲来保证发送的有序和高效,发送队列以及对发送数据的合并就是很好的策略。其具体步骤如下:

	1,每次当你调用发送接口时先把数据包推入发送队列,发送程序就开始轮训是否有需要发送的信息在队列里,有的话就发送,没有的话就继续轮训等待。

	2,发送时合并队列里的一部分数据包,这样可以一次性发送多个数据包以提高效率。

	3,对这种合并操作做个限制,如果因为合并而导致数据包太大,也会导致效率差。发送过程中,只要丢失一个数据就要全盘重新发送,数据包很大的话,发送本来就很缓慢的情况下,又重新整体重新发送,就会使得发送效率大大降低,合并的数据的大小限制在窗口大小的范围内(2的16次字节内)。

我们既要合并数据包,又不能让数据包太大,这样才能保证发送的效率比较高。比如我们做个合并后的数据包大小限制不得超过10K。这样每个数据包大小都处于10K以下的大小,除非单个包大于10K就让他单独发送,且每次发送包含了多个数据包,这样发送效率就有了一定的保证。

协议数据定义标准

在网络数据传输中协议是比较重要的一个关键点,它是客户端与服务器交流的语言。

协议简单来说就是客户端和服务器端商讨后达成一个对数据格式的协定,是客户端与服务器进行交流的语言,假如两边都用Json格式的协议来传输数据,两边都能用根据协议的格式来知道对方传达了什么信息,以及我的信息如何传达给对方。这样两边在发送和收到数据时,都能够按照一定的规则识别数据了。

我们在实现TCP的程序里需要对协议进行商讨,下面讲一下在制定协议过程中的几个关键点:

1.选择客户端和服务器都能接受的格式。

并不是所有的格式都适合,我们最好选择前后端都能接受的协议格式是最重要的,因为合作最重要,个人力量和一个协议格式的力量与团体来说都是渺小的。在团队都理解和一致的情况下,再对协议进行精进,选择更好更高效的协议。

2.数据包体大小最小化。

为了尽可能的减少包体的大小,我们应该选择一些能节省包大小空间的格式,比如google protocol buffer 或者其他变种,具体还是要看团队和项目的情况。也可以对已经确定的协议格式,对其协议包的主体部分使用压缩算法,我不建议只加入压缩算法而不改变协议本身,因为这样会导致对压缩算法过度依赖进而省略了协议本身的浪费空间,比如你用了压缩算法后发现xml或json格式的协议也还过得去就不再更改协议本身了,这样就会导致后期数据量大时数据包变得很大很沉重,传输效率降低。但很多老旧的项目和一些为了加快速度而不去更换更好的改协议的项目情况也时常发生,它们只启用压缩算法而不改变协议本身很多时候也是无奈之举。

3.要有一定的校验能力。

当数据包体不完整时或者本身包体后面连接着另外的数据包时(即粘包情况),我们要能识别。很多时候我们在传输数据的时候,收到的并不是一个完整的包体,或者因为网络关系,收到了错误的,甚至被攥改过的数据,我们要有能力去校验他们。因此在数据包完整性上,我们要能有校验能力和识别完整包体范围的能力。

我们这里主要聊一下网络数据包的校验能力,各种包体的协议格式会在后面章节中详细讲解。

在接收数据的时候,有时候会是一个不完整的包或者一个包后面跟着另一段不完整的包,我们怎么识别哪里是头部数据,并且数据块是哪些?为了解决这些问题就有了数据格式的意义,通常两端通信的协议数据格式,分为包头和数据块两部分组成,这和我们前面介绍的TCP和UDP的包头数据一样,我们自己定义的格式也需要包头用来作为我们业务层的协议格式。

通常头部数据由4-8个字节组成,里面通常包含了数据包大小,加密方式,广播方式等数据位。其中比较重要的是数据包大小,一般数据大小为4个字节,这4个字节代表的是数据块得大小size,有了这个数据后面数据块得大小就能知道了。因为每次拿到网络数据的时候我们先取头部规定好的几个字节,这样就知道了后面数据块的大小,接着再读取size大小的数据块,这样就拿到了数据信息,如果接收到的数据块大小部满足size大小,则需要继续等待。

比如再做的复杂点,把数据块的标示也融入头重,每个标示为一个2字节的正整数,为了确定调用的是哪个逻辑具柄的,我们可以把数据包分成,头、固定标识信息、数据块,三个部分。头部存储包体大小、加密位、广播方式等信息,标识信息则存储例如句柄编号、序列号、特殊命令编号、校验码等的标识信息;数据块则存储具体的数据信息。

TCP本身有做一些校验的工作,为了防止数据被人为攥改、以及逻辑本身的错误检测,我们有时也需要做额外的校验工作。通常的校验方法有几种:

MD5校验。

这种校验方式比较直接,将数据块整个用MD5散列函数生成一个校验字符串,将校验字符串保存在数据包中。当服务器收到数据包时,也对整个数据块做同样的MD5操作,将数据块用MD5散列函数生成一个校验字符串,与数据包中的校验字符串进行比较,如果一致,则认为校验通过,否则就认为被人为修改过。

算法可以用下面的代码表示:

bool CheckData(byte[] data, string data_md5)
{
	string str_md5 = MD5(data);

	if(str_md5 == data_md5)
	{
		return true;
	}

	return false;
}

上述代码中data和data_md5来自数据包中是由发送方计算的值,收到数据后对数据块进行md5操作并与传过来的data_md5字符串进行比较,如果相同则认为校验一致。

奇偶校验。

奇偶校验与MD5有点类似,只是所用的函数方法不同。对每个数据进行异或赋值成一个变量,将这个变量保存在数据包中。当服务器收到数据包时,也对整个数据块做同样的操作,将数据快中的数据进行异或操作并转换成一个变量,然后将这个变量值与数据包中的校验值进行比较,如果数据一致则认为校验正确,否则则认为数据被人攥改过。

这种方式校验相对于MD5比较简单快速,但重复性也比较高,这个校验算法如下:

unsigned uCRC=0;//校验初始值
for(int i=0;i<DataLenth;i++)
{
	uCRC^=Data[i];
}

上述代码中,每个Data中的数据都会与前面操作过的数据进行异或,最终得出一个值就是校验值,客户端与服务器都做同样的操作,如果得出的值时相等的则认为是正确的数据。

CRC循环冗余校验。

循环冗余校验是利用除法及余数的原理来进行错误检测的.将接收到的数据组进行除法运算,如果能除尽则说明数据校验正确,如果未除尽,则表明数据被认为攥改过。

该算法步骤如下:

	1,	前后端约定一个除数。

	2,	将数据块用除数取余。

	3,	将余数保存在数据包中。

	4,	服务器收到数据后,将余数和数据块相加,并进行取余操作。

	5,	余数为0则认为校验正确,否则则认为数据被攥改过。

在数据数组中,对每4个字节的数据合并后除余,得到一个1个字节以内的余数,每4个字节得到1个字节的余数,最终得到一组余数数组。校验时反向操作,先取4个字节的数据组成一个正整数加上对应的余数,再除余操作,如果不为零则校验失败,如果全部为零则校验成功。

4.加密。

为了保证网络数据包不被篡改和查看,导致外挂破坏整个游戏平衡,我们需要对发送的网络数据包中的主体部分进行加密。加密算法很多,包括RSA,公钥私钥,以及非对称加密等,其中最简单也是最快的加密方式就是对数据做异或处理,由于数据两次异或处理就能使得数据回到原形,所以算法中常使用异或的操作来做加密。通常做法是发送时对数据做异或处理一次,收到时再做一次异或处理,这样就能简单快速加密解密数据。

前面说的这种方式密钥Key是同一个,通常大多数加密都使用秘钥的概念,秘钥的Key常常会暴露在外界导致一些不怀好意的人会在客户端上破解并查看秘钥从而知道网络数据协议的格式进而可以进行一些捣乱,所以前后端同使用一个秘钥Key会比较危险,于是非对称加密是加密会比较安全,这样前后端两边的密钥Key不同且各自保存,即使当前端密钥泄漏了也可以随时替换。但仍然无法避免前端的秘钥暴露在外面被人破解,于是如何隐藏这个秘钥键值成了重要关键,很多人写入代码中编译进程序里,如果是C#代码由于它是先翻译为IL语言的,因此很容易用IL翻译的方式反向得出代码内容,也有人把秘钥用c或c++编译放入额外的so文件中,这种确实加大了破解的难度但也不是没有办法破解。所以我们仍然需要不断加大破解的门槛,比如把秘钥分为几段分别用几种方式隐藏在项目文件中,用多种加密方式对秘钥加密,让关键秘钥在获取前再做加密等等,我们应该详尽办法用各种手段达到通常人能想到的解密思路,加大了破解的门槛最好能让黑客望而生畏。

最后还是要关注下加密导致的性能损耗问题,如果加密的性能损耗过大那就得不偿失了,所以我们仍然希望加密的过程是快速的,在不损耗大量CPU前提下,不影响项目性能的情况下对协议数据做最大化的加密工作。

断线检测

TCP本身就是强连接,所以自身就有断线的检测机制,但是它本身的检测机制还不够好,时常会因为网络问题导致断线的判断不够及时,所以我们在编写TCP长连接的程序时需要加强断线检测机制,让断线判断变的更加准确及时。

为了能有效检测TCP连接是否正常,我们需要服务器和客户端共同达成一个协议来检测连接,我们把这个共同达成的协议取名叫心跳包协议。在心跳包协议中,每几秒服务器向客户端发送一个心跳包,包内包含了服务器时间、服务器状态等少量信息,然后由接收到这个心跳协议的客户端做反馈,发送给服务器一个心跳回应包,包内也包含客户端的少量信息例如客户端状态、用户信息等。两边的终端上的逻辑可以就此达成共识,认为当收到心跳信息时认为连接时存在的,当服务器30秒没有收到任何反馈心跳包的信息则认为客户端已经断线,这时主动断开客户端的连接。客户端这边也是同样的协定,当客户端30秒内没有接收到任何数据包时则认为网络已经断开,客户端最好主动退出游戏重新登陆重新连接服务器。

通常当网络异常时,客户端和服务器都很难断定连接是否依然存在,因此需要用这种机制来加以判定,例如在ios中APP可以随时切出屏幕并不关闭游戏,或者直接关闭应用不给服务器任何解释,服务器自然收不到断开连接的请求。面对各种异常的情况,我们制定的心跳包和心跳回应来判定是否仍处于连接的状态,倘若没有收到心跳包和心跳回应包,就表示连接存在问题了,有可能已经断开。为了规避一些时候网络的波动,我们可以设置一个有效判断断开连接的时间间隔,比如,10秒内没有收到心跳包和心跳回应包,就表示连接已经断开,这时服务器和客户端主动断开连接,客户端可以根据游戏的逻辑先退出游戏再重新登陆寻求再次与服务器连接。

心跳协议在TCP之上,加强了断线检测的准确性,能更有效快速的检测到断线问题。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第六章,网络层(二) - 实现TCP

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)