Reliability and Flow Control

原文地址:http://gafferongames.com/networking-for-game-programmers/reliability-and-flow-control/

Introduction

大家好,我是Glenn Fiedler,欢迎阅读我的网上电子书《游戏程序的网络设计》第四章。

在上一章中,我们基于UDP建立了自己的虚链接的观念。

现在我们在我们的链接中添加可靠性和流控制。

这是目前为止低级游戏网络中最复杂的方向,请仔细阅读下面的文章。

The Problem with TCP

    如果你对TCP比较熟悉就知道,TCP已经有了自己的链接,可靠性,流控制的概念。为什么我们要基于UDP写一个简化版的TCP呢?

问题在于多人动作游戏依靠源源不断的数据包流,每秒钟大概10-30个包,这些包中存放的数据对时间很敏感,只有最近的数据才有用。这些数据包括玩家的输入信息,位置方向,字符输入速度,游戏世界中物理对象的状态。

TCP的问题在于,它把数据的发送抽象为一个可靠地有序地流。因此,如果一个包掉了,TCP就会停止并等待包的重传。这就打断了源源不断的数据包,因为最近的数据包必须在队列中等待直到重发数据包到来,这样才能保证数据包的顺序。

但我们需要地却是另一种可靠性。不是得到一个稳定的数据流,我们只是想以一个稳定的速率发包,并注意到另一端计算机是否收到了包。这让对时间敏感的数据不用等待重发的数据包直接到达。让我们自己决定在应用层级别如何来处理丢失的数据包。

这种可靠性传输系统没法通过TCP完成,所以我们别无选择,只有自己从UDP搭建。

不幸地是,可靠性并不是我们考虑的唯一情况。这是因为TCP还提供了流控制,所以它可以动态控制数据发送的速率来适应链接的性能。举例来说,TCP在28.8k的modem上发送的数据小于T1line,即使它事先不知道当前的链接是哪种类型,它也能这么做。

Sequence Numbers

       现在回到可靠性!

我们可靠性的目标很简单:我们只想知道哪个数据包到达了链接的另一端。

首先,我们需要一种方式来标识数据包。

假如我们添加了“数据包ID”的概念将会怎样?让我们把它设成一个整型值。我们可以从0开始,然后每个数据包发出后,这个值就+1。第一个我们发出的数据包为数据包0,那么第100个我们发出的数据包就是数据包99。

这实际上是相当常见的技术。它甚至被用在TCP中!数据包的id被称为序列数字。虽然我们不打算想TCP那样实现确实的可靠性,但数据包id理应使用相同的术语,所以现在开始我们把它叫作序列数字。

因为UDP不能保证数据包的次序,第100个包发出去了,但是否收到并不重要。它告诉我们,需要在数据包的某处加上序列数字,这样另一端的计算机才能知道这个数据包到底是哪个。

在上一章中,我们已经有了一个简单的数据包头,所以我们也把序列数字加到头部,就像这样:

[uint protocol id]
   [uint sequence]
   (packet data...)

现在当另一个计算机收到数据包,根据发送那端的计算机它就知道了数据包的序列数字。

Acks

既然我们可以通过序列数字标识数据包,下一步就是让另一端的计算机知道我们收到了哪个包。

从逻辑上讲这很简单,我们只要注意到每一个我们收到的包的序列数字,并把序列数字发送给发包的计算机。

因为我们连续不断地在两台计算机上发送数据包,我们可以在数据包头增加应答,就如我增加序列数字一样:

    [uint protocol id]
    [uint sequence]
    [uint ack]
    (packet data...)

我们的一般方法如下:

每次我们发送数据包,我们增加本机的序列数字。

当我们收到一个数据包,我们检查这个数据包的序列号比照大多数最近收到数据包,称为远程序列数字。如果这个包的数据比较新,我们就更新远程序列数字让它和这个包的序列数字相等。

当我们构成包头数据时,本机的序列号成为包头序列号,远程序列号成为应答号。

这个简单的应答系统让我们知道发出的每个包是否收到。

如果数据包聚集在一起会怎么样,当我们发送数据包前收到两个数据包?每个包只有应答一个包的空间,所以我们要做什么呢?

现在考虑到链接的一端发包速度较快。如果客户端每秒发30个包,但服务器每秒只能收10个包,我们在服务端发的每个包中至少需要3个应答。

让我们考虑得更复杂情况!如果包含应答消息的数据包掉了。数据包的发送方会认为这个数据包丢掉了,实际它已经收到了。

这表明让我们的可靠系统需要更可靠。

Reliable Acks

       这里是我们与TCP的不同之处。

    TCP维护了一个滑动窗口,这里应答包发送了按照包的次序它期望收到的下一个包的序列数字。如果TCP没有收到给定数据包的应答,它就会停止并且重发当前序列数字包。这就是我们希望避开的行为。

所以在我们的可靠系统中,我们决不重发给定序列数字的数据包。我们发送序列号为n的包一次,再发n+1,n+2,依次下去。如果序列号为n的包丢失,我们决不停下来重发。如果数据很重要,我们也只是把丢失的数据重新组成一个包再给它一个新序列数字并发出去。

因为我们做的事情与TCP不一样,我们应答的数据包可能不全,所以它不在满足仅仅列出我们最近收到包的序列号。

在一个包中我们要加入多个应答。

我们需要多少个应答?

正如我们先前提到的情况,在链接的一端发包速度快过另一端。让我们假设最坏的情况,一端每秒发送的包不超过10个,而另一边每秒发包超过30个。这种情况下,应答的平均数字就是每个包3个应答,但如果包聚集在一起,我们需要更多。让我们说最坏情况下,大概是6-10个吧。

    应答包丢失了没有到达会怎么样?

    为了解决这个问题,我们打算使用一个网络的经典策略冗余数来解决丢包。

    让我们在一包加入33个应答,这不是最大是33,而是始终是33。所以对于任何给定的应答,我们额外上传32次。因为一个应答包可能根本不会被收到。

但是在一个包里怎么填入33个应答?一个应答4 bytes,33个就要132bytes!

技巧是代表32个应答前,用一个bitfield,像这样

    [uint protocol id]
    [uint sequence]
    [uint ack]
    [uint ack bitfield]
    (packet data...)

我们定义“应答bitfield”每一位对应着当前应答之前的32个序列号。所以假设应答是“100”。如果“应答bitfield”的第一位如果设置了,那么这个包就包含了对序列号99的应答。如果第二位设置了,那么98也应答了。一直到全部设置了,表示对68应答了。

我们调整过的算法如下:

每次发包后我们增加下本机的序列数字。

当我们收到一个数据包,我们比照远程序列数字检查下包中的序列数字。如果这个包的序列数字比较新,我们就更新远程序列数字。

当我们组成包头时,本机的序列数字填到包中,远程序列数字改为应答。通过查找当前队列的前33个包,填写应答bitfield,包含序列数字在这个范围内[远程序列数字-32,远程序列数字]。我们在应答bits中设置bit n(在[1,32])为1,表示远程序列序列-n收到。

另外的,一个包收到后,应答bitfield被扫描,如果bit n设置为1,我们认为序列数字为n包被应答,如果之前没有被应答。

通过这个改进算法,除非超过1秒100%的丢包,否则就可以应答。当然,它也很容易处理不同的发包速率和包聚集在一起的情况。

Detecting Lost Packets

    现在我们知道了链接的另一端接收到了什么包,但我们如何检测包是否丢失?

技巧是反过来考虑,如果在指定时间你没有收到这个包的应答,就认为这个包丢掉了。

考虑到每秒钟我们发包的数量不会超过30个,我们多余得发送应答大概30次,如果你一秒钟内没收到一个包的应答,很可能这个包已经丢了。

所以我们这里使用了一个小技巧,虽然我们可以100%知道哪些包收到了,但我们只能确信哪些包可能没收到。言外之意是,任何数据,你使用这种可靠性技术重新发送需要有自己的消息id,这样如果你收到它多次,你可以丢掉它。这个能够在应用层实现。

Handling Sequence Number Wrap-Around

没讨论序列数字和应答会超出序列数字的范围!

序列数字和应答是32位无符号整型,所以它们所能代表的数字范围是[0, 4294967295]。这是一个很大的数字!如果你每秒发送30个包,这个数足够发送4年半。然后序列号才会重新回到0。

但也许你想节省一些带宽,因此你缩短你的序列号和应答为16位整数。每包减少4bytes长度,但现在只要一个半小时,它就会重新归0。

所以我们如何处理这种循环情况?

技巧是认识到当前的序列号很大,但接下来的序列号很小,你就认为开始循环了。所以尽管新的序列号小于当前序列号,它实际上表示更新的数据包。

比如,如果用1byte来我们编码序列号(顺便说一句,不推存这么做)在255后它们会这样循环:

... 252, 253, 254, 255, 0, 1, 2, 3, ...

为了处理这种情况,我们需要一个新的函数来意识到序列号在255后回到了0,所以认为0,1,2,3的数据比255更新。否则我们的可靠系统将停止工作,直到我们再次收到255的数据包。

这就是这个函数:

inline bool sequence_more_recent( unsigned int s1, unsigned int s2, unsigned int max_sequence )
    {
        return ( s1 > s2 ) && ( s1 - s2 <= max_sequence/2 ) ||
               ( s2 > s1 ) && ( s2 - s1 > max_sequence/2  );
    }

这个函数通过检查这两个数字和它们的不同。如果它们的不同不大于1/2最大的序列号,它们就是相近的—我们就只是像平时一样检查一个是否比另一个大。如果它们相距太远,它们的不同大于1/2最大的序列号,我们就认为序列号小的那个是最新的数据。

这最后一点是如何处理序列号的循环,所以0,1,2认为比255的数据新。

多么简单和优雅啊!

确定把这些加到你要处理的任何序列号中。

FlowControl

虽然我们解决了可靠性,但我们还是有个问题关于流控制。TCP提供了流控制,避免数据包的拥塞,当你使用TCP时。但UDP没有什么流控制!

如果我们发送数据包而不做什么流控制,我们冒了极大的风险,会导致严重的延迟(2秒以上)连接我们和其它计算机的路由会超负荷动作,并且缓冲区塞满了包。这是因为路由拼命交付我们发出的数据包,因此在丢包前会尝试把它们放入缓冲区。

虽然最好我们能告诉路由器,我们对时间敏感的数据包,应该丢掉而不是缓冲如果路由器过载,但除非我们重写这个世界上的所有路由软件,否则没办法做到这点。

所以我们实际上要关注我们能够做的,来避免链接的超负荷。

这么做是为了实现我们自己的基本流控制。我强调基本!就像可靠性,我们没有希望提出某些一般性和健壮和TCP相同的实现,正如我们第一次尝试一样,所以让它尽可能的简单。

Measuring Round Trip Time

       从整体角度流控制是为了避免负荷连接和增加往返时间(RTT),最重要的衡量我们是否负荷连接的标准就是RTT自己。

我们需要一种方式来测量我们链接的RTT。

这里是最基本的技巧:

对于我们发送的每个包,我们添加发送包时间和条目包含到序列号队列。

每次我们收到了一个应答,我们查找条目并检测和收到应答时间和本机发送时间的不同,这就是这个包的RTT时间。

因为数据包的到来伴随网络抖动,我们需要平滑这个值来保证这些有意义,所以每次我们包含一个新RTT,我们移动当前RTT和数据包RTT的距离百分比。据我的经验在实际工作中10%是个不错的情况。这就是所谓的指数平滑移动平均线,它通过低通滤波消除在RTT的噪声。

为了确保发送队列不会增长下去,我们丢弃超过了最大预计RTT的数据包。正如前面讨论的可靠性,一个意外地一秒钟内没应答就丢失了,所以一秒是RTT最大的值的最好结果。

       现在我们已经有RTT了,我们可以用它来测量驾驭我们的流控制。如果RTT太大,我们就减少发送数量,如果在可接受的范围内,我们就增加发包数量。

Simple Binary Flow Control

在谈论之前,我们不要太过贪心,我们只是实现了一个基本的流控制。这种流控制有两种环境,好的或坏的。我称它为简单二进制控制。

让我们假设你发送包是一个确定的大小,比如256bytes。你可能每秒发送30次,但如果网络条件差,你可以减少到每秒发送10次。

所以256 byte 的包每秒30次大约是64kbits/sec,10次1分钟大约是20kbit/sec。在世界上没有一个网络带宽是处理不了20kbit/sec,所以我们将推进这一进展。不像TCP完全适合于任何设备任何带宽。我们只是假设在我们链接中设备能支持的最小带宽。

所以基本的观念就是这样。当网络环境好的时候,我们每秒发送30个包,当网络不好时,我们减少到每秒发送10个包。

当然,你可以按你喜欢的定义好的和坏的网络,但只考虑RTT我已经得到了好结果。比如如果RTT超过一些值(比如250ms)你就知道你可能负荷链接。当然这是假如正常情况下没负荷连接的条件下,没人的网络会超过250ms。这种假如前提是我们有合理的带宽。

你怎么切换好和坏呢?我喜欢的算法来操作就像是这样:

如果你当前是好环境,如果条件变坏,立刻切换到坏的环境。

如果你在坏的环境,在指定长度的时间后条件变好,然后切换到好环境。

避免在好环境与坏环境中快速切换,如果你从好环境切换到坏环境10秒,至少20秒后才回到好环境。设置一个最大值,比如60秒。

为了避免好环境有时有不好的动作,每10秒链接在好环境中,至少要5秒才能切换回坏的环境。设置一个最小值,比如1秒。

使用这个算法,在坏的网络环境中减少发包速率为10包每秒,避免负荷链接。你还可以保守尝试好模式,并坚持以一个较高的速率发送数据包30包每秒,在网络条件良好下。

当然,你可以实施更多复杂的算法。数据包丢包的%率也可以做为网络测试的一个标准,甚至占用的网络抖动(应答包中的时间变化),不只是RTT。

你可以对流控制更贪心点,试图发现你可以以更高的带宽发送数据时,但你要小心。无止境的贪婪可能带来更大的风险,你可能会负荷链接。

Conclusion

我们的新的可靠性系统让我们源源不断地发送数据包并提示我们收到了哪些包。通过这个,我们可以推道丢失了哪些包,并重发数据如果丢失的数据很重要的话。

基于这些我们有了一个简单的流控制系统,基于环境,10秒每包,30秒每包交替使用,所以我们不会使网络负荷。

本文中提及有很多的实现细节太具体,你可以在事例源码中查看所有的实现。我保证它很简单,结构良好。

这就是流控制,可能是低级网络中最复杂的方面。



你可能感兴趣的:(Reliability and Flow Control)