幀同步會高頻次的上報和下發邏輯幀,所以網絡方案非常重要,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包一次性發送出去,節約包頭大小。