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

实现TCP长连接。

我们先从整体出发,列一下Unity3D实现TCP连接需要的实现哪些方面:

    1, 建立连接

    2, 断线检测

    3, 网络协议

    4, 发送和接收队列缓冲

    5, 发送数据合并

    6, 线程死锁策略

===

TCP本身已经有数据包可靠性确认,以及丢包重发机制,数据包的大小也没有限时,我们可以把这些功能称之为免费得到的功能。

所以在实现TCP的连接中不需要我们再做包体的校验,切割包体,以及重发数据包,省掉了很大一部分麻烦。

我们只需要做的就是建立连接,发送,以及接收。不过如果不对发送数据进行合并,就会有很多小的数据分批发送,导致发送效率低下。所以我们下文中会提到关于发送合并的问题。

简单介绍下TCP的API库

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

理论上你不会去用阻塞方式连接和接收,因为你不会让你的游戏卡住不动来等待连接,这有可能导致崩溃,所以连接,接收,断开,都是异步的线程操作。同步阻塞的操作可能会周边的工具中会用到,比如编辑器的工具,回放工具,GM的工具等,其他大部分时候都希望异步操作网络连接。

    BeginConnect,开始连接

    BeginReceive,开始接收信息

    BeginSend,开始发送数据

    BeginDisconnect,开始断开

    Disconnect(Boolean),立刻断开连接

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

线程锁

网络部分基本上所有的操作都是线程级别的,Unity3D的渲染和逻辑是在主线程上运作的,这里就涉及到了主线程和子线程的资源线程锁的问题。

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

例如接收线程,接收到网络数据后将数据push到队列时,在push操作上做锁操作:

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

或者主线程在读取网络数据时,在推出一个数据时,在Pop的操作上加一个锁的操作。

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

伪代码中,首先是对子线程的接收部分描述,当接收到数据包时push到接收队列中,而当主线程需要处理数据时,先切换队列,防止对队列占用过多时间,切换完毕后,再对队列中的全部数据进行处理。

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

发送数据

我们前面说发送数据时也需要队列来做缓冲,因为发送的数据包会很多,也有可能很短时间内会积累很多数据包。

另外,发送时大多数的数据包都是很小很小的数据包,如果每个数据包都发送一次等待接受后再发送就会导致发送效率过低,发送太慢导致延迟过大。

而如果一下子把全部数据都发送的话,发送的数据可能会太大,导致发送效率很差,因为数据包越大越容易发送失败或丢包,TCP就会全盘否定这次发送的内容,并将整个包都重新发送一次,效率极其糟糕。

所以我们需要自己建立发送缓冲来保证发送的有序和高效,发送队列以及对发送数据的合并就是很好的策略。

步骤如下:

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

    2,发送时,合并队列里的所有数据包,准备一次性发送多个数据包以提高效率。

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

我们既要合并数据包,又不能让数据包太大,这样才能保证发送的效率比较高。

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

协议数据定义标准

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

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

我们在实现TCP里需要对协议进行商讨,那么在商讨协议的过程中,关键点是什么呢?

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

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

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

4, 加密。为了保证网络数据包不被篡改和查看,导致外挂破坏整个游戏平衡,我们需要对发送的网络数据包中的主体部分进行加密。加密算法很多,这里不一一介绍,最简单方式就是对数据做异或处理,发送时做异或处理一次,收到时做一次异或处理,这样就能简单加密解密数据。前面说的这种方式比较单板,因为密钥Key是同一个,而且密钥KEY会暴露在客户端会被查看到,于是非对称加密是加密的首选,这样前后端两边的密钥Key不同且各自保存,即使当后端密钥泄漏时,也可以随时替换。

我们这里主要聊一下网络数据包的校验能力,各种包体的协议格式下面的章节中会具体讲解,加密也同样会在最后的几节部分做出详细的介绍。

在接收数据的时候,有时候会是一个不完整的包,或者一个包后面跟着另一段不完整的包,怎么办,怎么识别哪里是头,哪里是尾?嗯,这就有了数据格式的意义,通常的长连接数据格式,以头和数据块,两部分组成,比如头用4个字节组成,这4个字节是数据块得大小size,这样后面数据块得大小就能知道了,因为每次拿到网络数据的时候我先取4个字节,然后就知道了后面数据块大小,再读取size大小的数据块,这样就拿到了数据信息。 再做的复杂点,可以分成,头、固定标识信息、数据块,三个部分。头,存储包体大小信息;标识信息,存储例如句柄编号,序列号,特殊命令编号,校验码等的标识信息;数据块,存储具体的数据信息。

TCP本身有做一些校验的工作,但为了防止数据被人为攥改、网络错误、以及逻辑本身的错误检测,我们需要做额外的校验工作。

通常方法有几种

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

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

这个校验算法类似于:

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

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

该算法过程如下:

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

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

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

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

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

TCP本身就是强连接,所以自身就有断线的检测机制,但是它本身的检测机制还不够用,对断线的判断不够准确,所以我们需要加强断线检测机制,让断线判断变的更加准确和即使。

为了能有效检测TCP连接是否正常,我们需要服务器和客户端达成一个协议来检测连接。我们可以取名叫心跳包协议,在心跳包协议中,每几秒服务器向客户端发送一个心跳包,包内包含了服务器时间,服务器状态等信息,然后再由客户端发送给服务器一个心跳回应包,包内包含客户端的一些信息例如客户端状态,用户信息等,这样客户端和服务器就都知道连接是有响应的,当前是连接正常。

倘若没有收到心跳包和心跳回应包,就表示连接存在问题了,有可能已经断开。为了规避一些时候网络的波动,我们可以设置一个有效判断断开连接的时间间隔,比如,10秒内没有收到心跳包和心跳回应包,就表示连接已经断开,这时服务器和客户端主动断开连接,再根据游戏的逻辑寻求再次连接。

这种在TCP之上,加强断线检测的做法,能更有效快速得检测到断线问题。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号