基於UDP的幀同步網絡方案(基礎)

        幀同步會高頻次的上報和下發邏輯幀,所以網絡方案非常重要,TCP協議的優勢在於有序、可靠、有連接,但是由於較爲保守的超時重傳方案、過大的包頭,會給幀同步帶來比較高的延遲和較大的數據流量,而UDP是較爲精簡的網絡協議,不保證時序、不保證可靠性、無連接,但是這些都可以自行實現、定製,並且包頭較小,可以說是幀同步較爲理想的網絡協議。但是直接用UDP的話,肯定是不能滿足幀同步的需求的,幀同步要求 :相同的輸入一定會有相同的輸出,所以網絡包是不可丟棄的,網絡包的時序也要保證一致,所以UDP必須實現最基礎的這兩個功能才行。

        首先是時序,這個比較好解決,給每個網絡包都定義一個自增序號,收消息的時候檢查序號是否連續,不連續的話,暫時不處理,等序號連續了才處理。爲了實現處理的消息有序,我們將消息包頭定義爲下面這樣的數據結構:

*  ReliableData 數據包格式 ps:每個data大小不得超過255 只用8位來表示
* | CRC - 16bit | TYPE - 4BIT | IsLittleEndian - 1BIT | Empty - 3BIT | ACK -  16BIT | SEQ - 16BIT | | DataSize - 8bit | Data |

正常的CRC爲32位,這裏採用16位算法,節約包頭大小,然後是4位的包類型,分爲可靠包、Ping包、純ACK包三種類型,1位的大小端標誌,3個預留位,ACK爲本地已接收到的有序的最新消息的序號(告訴對方在這個序號以前的消息不用再發給我了),SEQ爲當前這條消息的序號,DataSize爲消息大小,至於爲什麼只留8位,後面再說;最後的Data位消息體內容。這樣設計下來,整個包頭爲64bit,相對與TCP的近200bit來說,還是節省了不少。

        接收到消息的時候處理算法如下:

if (type == (byte)NXUdp.UDPPackageType.ReliableData)
            {
                int messageCount = 0;
                while (true)
                {
                    ushort seq = stream.ReadUShort();
                    int dataSize = stream.ReadByte();
                    if (dataSize == -1)
                        break;      // 讀完了
                    if (seq > _recvAck.Value)
                    {
                        var current = _recvMsgQuene.First;
                        while (current != null)
                        {
                            var curNode = current.Value;
                            if (seq == curNode.Seq                      // 重複的消息
                                || seq < curNode.Seq)                   // 插入的消息
                                break;
                            current = current.Next;
                        }
                        if (current != null && current.Value.Seq == seq)
                        {
                            // 這個包已經收到過了 丟棄
                            stream.Seek(dataSize, SeekOrigin.Current);
                        }
                        else
                        {
                            var node = _buffers.Alloc(stream.GetBuffer(),  (int)stream.Position, dataSize);
                            stream.Seek(dataSize, SeekOrigin.Current);
                            var msg = new NXUdpMessage(seq, node);
                            if (current == null) _recvMsgQuene.AddLast(msg);
                            else _recvMsgQuene.AddBefore(current, msg);
                            hasNewReliableData = true;
                        }
                    }
                    else
                    {
                        // 這個包過時了 丟棄
                        stream.Seek(dataSize, SeekOrigin.Current);
                    }
                    recvLog.AppendFormat(" Msg[{0}] Seq:{1} Length:{2}",  messageCount, seq, dataSize);
                    ++messageCount;
                }
            }

_recvMsgQuene是消息隊列,這裏是做了一次插入算法,將比原來鏈表中Seq大的消息插入到鏈表中,至於相同的Seq和較小的Seq則進行忽略。

這樣鏈表保證Seq是一個由小到大的順序。得到了這個鏈表之後就要對這個鏈表進行遍歷,然後取出消息:

        private void UpdateRecvMessage()
        {
            if (!IsConnect) return;
            var current = _recvMsgQuene.First;
            while (current != null)
            {
                var curNode = current.Value;
                if (_recvAck.IsNext(curNode.Seq))
                {
                    _recvAck.Set(curNode.Seq);
                    OnReceiveMsgEvent?.Invoke(curNode, _buffers);
                    _buffers.Free(curNode.Pos);
                    current = current.Next;
                    _recvMsgQuene.RemoveFirst();
                }
                else  // 因爲是個有序的  只要不等於 就可以break了
                {
                    break;
                }
            }
        }

這裏就非常簡單了,_recvAck是表示當前已經處理的最新一條消息的Seq,每次取出一個消息就檢測一下是否是_recvAck的下一條消息,如果是的話,就進行處理,如果不是的話,後面的消息也不用處理了,說明中間一定少收了某些消息,爲保證時序,我們必須等待中間的消息收到了以後才能處理剩下的消息。

以上就是保證接收時序的核心算法,這塊相對簡單,總結起來就是,發送的時候帶序號,接收的時候丟到鏈表裏面,順序處理。

 

然後另外一個就是超時重傳,TCP是自帶超時重傳功能的,但是爲了保證網絡的暢通,TCP的設計者不希望過於佔用網絡資源,所以重傳算法設計得比較保守。先介紹幾個概念:

RTO:重傳超時時間,當發送的數據包時間超過RTO以後,會觸發重傳機制,這個值設置得過小,會導致頻繁觸發重傳,而設置得過大,又會導致重傳不及時,要解決這個問題,就要先介紹另外一個概念RTT。

RTT:一次消息在網絡上的往返時間,這個時間是動態的,每次發出消息的時候根據網絡狀況的不同、選擇的鏈路不同,都不一樣。

而RTO就是需要根據RTT去動態計算出來,我們首先要拿到 一次性收發到的消息的平均RTT,然後根據公式計算出RTO,計算公式網上一大堆,原理就是根據最新的RTT和平均RTT進行加權,最終得出RTO。但是如果一個包第一次重傳依舊超時,怎麼辦?可能是RTO設置得不合理,不夠大,也可能是網絡確實不通,這時候爲了避免佔用過多的網絡資源,引入了Karn算法,這種算法說起來更簡單,就是針對這種重傳的包,第二次重傳的RTO=a*RTO,TCP協議中,這裏的a=2。

分析完TCP協議的超時重傳算法,就開始設計我們自己的超時重傳算法了,在這裏我們發現TCP協議的重傳算法在大框架上考慮得比較周到,但是細節上還是有不少可優化的點,例如動態RTO的計算公式、Karn算法中的a值等,都可以做一些優化,例如我們可以把a設置爲1.5,或者更小 ,讓協議包能快速重傳。

不過我現在寫的這一套NXUdp沒有做動態計算RTO的算法,RTO現在寫死了,只是做了重傳機制,後續優化的時候考慮將動態RTO加入進來。

        private bool UpdateSendMessage()
        {
            if (!IsConnect) return false;
            if (_sendMsgQuene.Count == 0) return false;
            bool sendMessage = false;
            _sendMessageFlushList.Clear();
            var cur = _stopWatch.ElapsedMilliseconds;
            var totalSize = 0;
            var current = _sendMsgQuene.First;
            while (current != null)
            {
                var msg = current.Value;
                if (cur - msg.LRto > _udp.GetRto())
                {
                    totalSize += msg.Length;
                    if (totalSize > NXUdp.MAX_PAK_SIZE)
                    {
                        _udp.Send(this, NXUdp.UDPPackageType.ReliableData,  _sendMessageFlushList, _recvAck.ToUInt16(), _buffers);
                        _sendMessageFlushList.Clear();
                        totalSize = msg.Length;
                        sendMessage = true;
                    }
                    msg.LRto = cur;
                    _sendMessageFlushList.Add(msg);
                    current = current.Next;
                }
            }
            if (_sendMessageFlushList.Count != 0)
            {
                _udp.Send(this, NXUdp.UDPPackageType.ReliableData,  _sendMessageFlushList, _recvAck.ToUInt16(), _buffers);
                _sendMessageFlushList.Clear();
                sendMessage = true;
            }
            return sendMessage;
        }

這裏做了一點優化,我將需要重傳的包都儘可能的打包成一個UDP包一次性發送出去,節約包頭大小。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章