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

实现UDP

===

在前面介绍了如何实现TCP socket,下面要介绍下UDP的实现方式。其实两者都是长链接的方式,很多地方都有相识之处,比如两者都需要连接和断开事件支撑,都需要做发送和接收队列缓存,都需要定义数据包协议格式,都需要加密和校验。

我们先来说说TCP有而UDP没有的部分,再看看需要我们在UDP上实现的有哪些功能。和TCP相比,UDP基本就是TCP的阉割版本,很多TCP本身自带有的功能UDP都没有,虽然它们最大的区别是数据传输的方式,但在接口和逻辑层上很难感觉到它们的区别,不过至少我们得知道它们传输的不同方式。相同的地方包括,大都以异步发送和接收的方式进行,以及都需要理解多线程同步的操作机制,还有发送和接收都需要缓冲队列,并且发送数据时都需要合并数据包(其实TCP可以不用,因为它本身就有确认和重发机制)。TCP有而UDP没有的地方包括,UDP不会自己校验重发,由于是数据报的发送模式丢包概率大,数据包接收顺序不确定,而且UDP本身没有连接状态导致没有连接断开这个机制,也没有连接确认机制。

前面说了这么多关于TCP和UDP在程序上的区别就是要大家明白,直接使用UDP来做网络发送和接收会遇到诸多问题,可以说如果直接使用而不加修饰,使得不确定性太大,导致基本用不了UDP。因此这节我们来说说应该怎么封装才能让UDP顺利的使用在我们的项目上。

连接确认机制

TCP有连接的三次握手协议,相当于在连接过程中跟服务器端协商后敲定我们已经建立了连接这个一致预期,而UDP是无状态链接,它并没有三次握手的协议,所以可以说UDP的连接其实是一厢情愿的,客户端并不知道是否真正连接成功了,如果由于网络异常原因没有连接上,就会导致收发失败。其实怎么确认UDP连接上了服务器,这才是我们想要得到的反馈,我们需要准确的得知UDP连接是否已经成功连接上服务器,因此这个连接确认机制必不可少。

发送和接收如果建立在连接确认的基础上则会更加牢靠,因此我们必须先确认知道我们是否连接成功。能够判断连接是否成功是整个实现UDP的第一步,只有这样才能顺利得进行下面的数据包收发操作。

我们应该怎么确认连接成功

很简单我们可以模仿TCP的确认连接机制,我们来看看TCP连接的三次握手在数据包上是怎么做的。

1.首先客户端向服务器端发送一个数据包,里面包含了Seq=0的变量,表示当前发送数据包的序列号为0,也就是第一个数据包。

2.务器端收到客户端的数据包后,发现Seq=0,说明是第一个包是用来确认连接的,于是给客户端也发送了一个数据包,包含了Seq=0,和Ack=1,表示服务器端已经收到客户端的连接确认包了,并且回应包Ack序列标记为1。

3.当客户端收到服务器端给的回应数据包后,知道了服务器端已经知道我们想要并对方已经建立连接,于是向服务器端发送了一个数据包,里面包含了Seq=1,Ack=1,表示确认数据包已经收到,连接已经确认,开始发送数据。

以上就是TCP的三次握手来确认连接的流程在数据包中的体现。

在UDP下它自身并没有三次握手机制,为了建立更好的确认连接机制,我们可以模仿TCP三次握手的形式来确认连接。不过第3次握手稍微有点多余,我们可以省去最后一次握手的数据包,改为2次握手。步骤如下:

首先在UDP打开连接后,在确认连接前不进行任何的其他类型的数据发送和接收,我们将这种发送数据包以确认连接成功与否的数据包称为握手包。

在打开连接后,客户端先向服务器端发送一个握手数据包,代表客户端向服务器端请求连接确认信号的数据包,包内的数据仅仅是一个序列号Seq=0,或者不是序列号也行,它可以是一个特殊的字段。只要当服务器端收到这个握手数据包后能够识别该数据包为连接确认的握手包,也就是实现了第一次握手。

服务器端在收到第一次握手数据包后,需要向客户端反馈一个握手数据包,里面同样带有客户端能识别的连接确认信号。当客户端接收握手数据包时说明发出去给服务器端的连接确认数据包有了反馈,并且收到了服务器握手数据包的反馈,也就是说第二次握手成功。在接收到了这个第二次握手连接确认数据包时,双方都可以认为是连接已经成功建立。

整个UDP确认连接的握手过程,就相当于客户端和服务器端的一次交流,相互认识一下并且示意双方后面的交流即将开始。

实现UDP连接确认具体步骤(伪代码):

1.首先使用API建立UDP连接:


SvrEndPoint = new IPEndPoint(IPAddress.Parse(host), port);
UdpClient = new UdpClient(host, port);
UdpClient.Connect(SvrEndPoint);

上述代码为使用C#的UDP接口对指定的IP和端口打开链接。

2.启动接收数据线程:


UdpClient.BeginReceive(ReceiveCallback, this);
void ReceiveCallback(IAsyncResult ar)
{
    Byte[] data = (mIPEndPoint == null) ?
        UdpClient.Receive(ref mIPEndPoint) :
        UdpClient.EndReceive(ar, ref mIPEndPoint);

    if (null != data)
        OnData(data);

    if (mUdpClient != null)
    {
        // try to receive again.
        mUdpClient.BeginReceive(ReceiveCallback, this);
    }
}

UPD是无状态连接,打开连接就相当于只是一厢情愿的自我意识,因此可以立刻开始接收数据的接口并开启线程。

3.发送连接确认数据包,并屏蔽其他发送和接收功能。


SendConnectRequest();
StopSendNormalPackage();
StopReceiveNormalPackage();

这三个函数是自定义的,第一个表示发送握手包,可以认为是为握手包特意定制的数据装载函数,第二个和第三个只是把普通的接收数据包的开关给关了,在连接还没确认前不接收任何其他形式的数据包,实际上接收数据包仍在继续,只是不加入到数据处理队列且也不处理数据的具体句柄。

4.等待接收连接确认数据包:


Void OnData(byte[] data)
{
     If( !IsConnected )
    {
        If( IsConnectResponse(data) )
        {
            OnEvent( Event.ConnectSuccess );
            IsConnected = true;
        }
        Return;
    }
    ProcessNormalData(data);
}

这是接收到数据的处理过程,先判断是否当前是否已经处于连接状态,再判断该数据包是否为握手数据包,如果确认是握手数据包的话,那意味着服务器收到了握手数据包并且做出了回应,连接可以确认了。因此在这里发出了连接确认事件,并且标记连接为确认状态。

5.连接握手数据包收到后,说明确认连接已经成功建立,于是就可以开启正常发送和接收数据包的功能。


Void ProcessNormalData(data)
{
    If( !IsConnected ) return;

    DealNetworkData(data);
}

经过与服务器端数据包的来回,UDP完成了2次握手的确认机制,已经可以认定为连接已经成功建立,这里是正常数据包处理过程,包括识别,装载,推入队列,检测是否丢失,是否需要重发等。

检测连接是否依旧存在

UDP自己不能判断连接是否断开,因为它是无状态的连接,打开即完成连接关闭即完成断开,因此需要我们自己来做断线检测,检测的方式也与我们前面说的如何主动检测TCP连接状态的心跳包类似,因为这种方式是花费最小的代价并且能够及时准确的确认连接的方式。因此UDP检测连接的判定机制,也可以用数据包来回的形式,不过这次不像握手数据包那样只是单一一个数据包,而是持续的心跳包的形式来做持续的判断连接状态。

首先,我们要与服务器端有个协定,每隔X秒(比如5秒)发送一个心跳数据包给服务器端,这个客户端发送的心跳数据包里包含了一些客户端信息,包括ID,角色状态,设备信息等,包体不能太大,否则会就加重了宽带负担。

当服务器端收到心跳数据包时,也立刻回复一个心跳数据回应包,里面包含了,服务器端当前时间,服务器端当前状态等信息。客户端收到此数据包时,说明连接尚在,也能同时同步服务器端的时间和一些基础的信息。

如果客户端很久没有收到心跳数据回应包时,就表明,连接已经断开了,比如30秒没收到心跳包,可以判断连接已经断开。服务器端也是一样操作,当没有收到心跳包很久,就表明客户端的连接已经断开。这时客户端就可以开启相应的重连程序,或重连提示以及步骤。

步骤可以分为如下4步:

    1.每隔X秒向服务器端发送心跳数据包。

    2.服务器端收到心跳数据包后回复心跳响应数据包。

    3.如果客户端和服务器端都很久没有收到心跳数据包,比如30秒,则判定连接断开。

    4.当判定为连接断开,则主动断开连接并发起断开连接事件,通知客户端提示用户,或者重新创建连接,服务器端则是处理与之相关的数据处理操作。

这里不只是客户端主动发送数据包给服务器,也可以反过来服务器主动发送数据包给客户端的形式检测连接是否断开。

数据包校验与重发机制

我们前面说UDP相当于是TCP的阉割版,其中UDP最关键的阉割部分就是校验和重发机制。没有校验和重发机制,意味着发送端无法知道数据包的发送是否到达,丢失了也无法重新发送的机制来补充丢失的数据包,因此我们需要自己编写增加对数据的校验和重发机制来确保数据的可靠性。

TCP已经有校验和重发机制,我们可以模仿它的校验和重发机制,把它搬到UDP上,并在此基础上加以改进,这样我们即有了UDP的速度,又有了TCP的可靠性。

我们先来看看TCP是如何做数据包的校验和重发的:

先解释几个英文名词Seq和Ack。Seq即Sequence Number,为源端(source)的发送序列号;Ack即Acknowledgment Number,为目的端(destination)的接收确认序列号。

1.首先A端向B端发送数据包,TCP的包头中里面包含了字段Seq(sequence number)序列号值为1(即已经发送的数据包的累计大小),比如这次A向B发送的数据包大小为264,则size大小为264。

2.B端收到传过来的数据包后,知道了当前连接的这个数据包的序号为1,也就是连接后第一个数据包。于是向A端发送了一个确认包,确认包中包含了的Ack=264(接收到的数据包的累计大小),告诉A端我B端已经收到了数据包,现在累计接收大小为264的数据包。

3.A端收到B端发来的确认数据包时,会检测累计发送大小和累计接收大小是否一致,如果发现累计接收大小与累积发送大小不一致则认为传输错误启动重传机制,退回到最后一次正确的的数据包位置进行重传,以保证可靠性。

4.如果大小比较一致,则认为B端准确收到了数据包,A端可以继续发送其他数据包,当再次发送时里面包含的Seq序列号更改为了265,意思为累计发送数据大小,假如这次发送的数据包大小为100,则size字段填写100,B端接收到后再向A端发送确认包,确认包中包含了Ack=365,意思是已经累计收到数据包大小为365。当A端收到确认包时会先对比一致性,如果累计大小错误,请启动重传机制,确保可靠性。以此类推

5.如果是由B端向A端发送数据也是同样的步骤。B端向A端发送数据也是同样的方法和步骤。数据包中包含了B端的Seq(已经发送的数据包大小),比如累计发送了1,seq=1,这次数据包大小为585,A端接收到B端的数据包后,向B端发送确认包,包中包含了B端发过来的Ack =586,B端收到确认包后,就知道了累计接收的数据包大小已经到了585,也就是说当前的数据包已经发送成功。如果累积发送和累积接收数据不一致则启动重发机制,从最近的正确确认点开始发送数据。

序列号确认机制是TCP可靠性传输的保障,除了累积接收大小和累积发送大小的比较外,若在规定的时间内收到确认数据包就表明该报文发送成功,可以发送下一个报文,如果超过时间则启动重传(TCP Retransmitssion)。

我们可以借鉴TCP的方法,UDP也可以用此方法来做检测和重传。不过TCP接收和发送累积大小的检测方式使得重传量比较大,一旦会导致失败重传整个数据,重传内容太多。因此它不能准确快速的定位重传的数据包,由于中间数据包的丢包,导致已经到达的数据也需要重传,这种类型的可靠性保证的依赖于消耗大量的带宽消耗。

因此我们在实现UDP的检测和重传时,我们可以进行如下的改进。

1.A端向B端发送数据包,数据包中包含Seq=1(表示数据包的发送序列),发送后将此数据包推入到已经发送但还没有确认的队列里。

如果B端接收到Seq=1的数据包,就回应客户端一个确认包,包中Ack=1,表示Seq=1的包已经确认收到。

如果B端没有接收到数据,客户端X秒后发现仍然没有收到Seq为1的确认包,判定为Seq=1的数据包传输失败,从已经发送但未确认的数据包队列中取出Seq=1的数据包,重新发送。

2.例如A端向B端发送了10个数据包,分别是Seq=1,2,3,4,5,6,7,8,9,10,服务器收到的序列是,1,3,4,5,7,8,9,10,其中有2,6,没有收到数据包。

A端在等待确认包超时后,对2,6进行重传。在B端接收到数据包后,处理数据包时,如果数据包顺序有跳跃的现象就表明数据包丢失,就等待A端重传,这时就在断开的序列处停止处理数据包,等待重传数据包的到来。

3.B端也可以做加快重传确认时间的处理。A端向B端发送5个数据包,分别是Seq=1,2,3,4,5,B端收到的包的序列是1,3,4,5,当收到3时,发现2被跳过1次,当收到4时发现2被跳过2次,立刻向A端发送确认包要求启动2的重传,这样就加快了丢包重传的确认速度。

丢包问题分析

UDP丢包多是很正常现象,这是UDP牺牲质量而提高速度的代价。UDP丢包的原因很多,我们这里做个分析。

1.接收端处理时间过长导致丢包:

当调用异步接收数据方法接收到数据后,处理数据会花去一些时间,处理完后再次调用接收方法,在这二次调用间隔里发过来的包可能会丢失。

要解决接收方丢包的问题其实很简单,首先保证程序执行后马上开始监听(如果数据包不确定什么时候发过来的话),其次要在收到一个数据包后最短的时间内重新回到监听状态,其间要尽量避免复杂的操作。

对于这种情况可以修改接收端,将包接收后存入一个缓冲区,然后迅速返回继续开启接收线程。或者使用前面提到的双队列机制,来缩短锁队列的时间从而解放了处理包的时间和接收数据包的线程之间的冲突,让两个线程能迅速回到自己的‘岗位’上做自己的事。

2.发送的包巨大导致丢包概率加大:

虽然Send方法会帮我们做大包切割成小包发送的事情,但包太大也不行。例如超过50K的一个udp包,不切割直接通过send方法发送也会导致这个包丢失。

发送的数据包较大是个危险的行为,如果超过接收者缓存将大概率导致丢包,一般当包超过MTU大小的数倍就会增大丢包的概率。

什么是MTU,即Maximum Transmission Unit的缩写,意思是网络上传送的最大数据包。大部分网络设备的MTU都是1500,如果本机的MTU比网关的MTU大,大的数据包就会被拆开来传送,这样会产生很多数据包碎片增加丢包率,从而增加重发概率导致降低网络速度。

报文过大的问题可以通过控制报文大小来解决,使得每个报文的长度小于MTU。以太网的MTU通常是1500 bytes,其他一些诸如拨号连接的网络MTU值为1280 bytes,如果使用speaking这样很难得到MTU的网络,那么最好将报文长度控制在1280 bytes以下,这些都是经验之谈。

3.发送的包频率太快:

虽然每个包的大小都小于MTU大小但是频率太快,例如40多个MTU大小的包连续发送中间不休眠,也有可能导致丢包。

这种情况可以通过建立Socket接收缓冲队列解决,以及通过建立发送缓冲队列来解决,并且在发送频率过快的时候考虑线程Sleep休眠一下作为时间间隔。

这里有些人会不理解发送速度过快为什么会产生丢包,原因是UDP的发送数据是不会造成线程阻塞的,也就是说UDP的发送不会像TCP中的发送数据那样直到数据完全发送才会返回回调用函数,UDP并不保证当执行下一条语句时前面的数据是否被发送,它的发送接口是异步的。

如果要发送的数据过多或者过大,那么在缓冲区满的那个瞬间要发送的报文就很有可能被丢失,一般一秒钟几个数据包不算什么,但是一秒钟成百上千的数据包就不好办了。

以上是UDP的实现细节,同时阐述了UDP的实现难点和注意点。

参考文献:

    1, 基于TCp的数据包传输过程
· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

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

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