10没有基于策略的qos_WebRTC QoS | NACK 格式与发送策略
本文是 WebRTC QoS 第 1 篇
導讀
10、20、100、1000、10000
策略 1,10 次
策略 2,20 毫秒
策略 3,100 毫秒
策略 4,1000 個(丟失包數量)
策略 5,10000 個(包號跨度)
小結
源碼分析
關鍵成員變量
OnReceivedPacket 函數
Process 函數
GetNackBatch 函數
ClearUpTo 函數
小結
NACK FCI (Feedback Control Information)格式
抓包分析
導讀
NACK 全稱為 Negative Acknowledgment Packet,是一種對 RTP 數據傳輸層進行反饋的 RTCP 包,包類型為 205,反饋類型為 1。相對于 TCP 的 ACK 接收確認,NACK 則是未接收確認。
NACK 模塊是 WebRTC 對抗弱網的核心 QoS 技術之一,有兩種發送模式,一種是基于時間序列的發送模式,一種是基于包序列號的發送模式。
NACK 模塊總體的發送策略為:對于每一個因為不連續而被判為丟失的包,首次都是基于序列號立即發送 nack 包以請求重傳,之后則都是基于時間序列,周期性批量處理 nack_list,并根據距離上次發送的時間間隔是否已經超過一個 rtt 來決定是否發送 nack 包。
我們首先單刀直入 NACK 的核心發送策略,之后再理解源碼和 NACK 的格式就會游刃有余。
10、20、100、1000、10000
NACK 模塊具體的發送策略圍繞著 10、20、100、1000、10000 這五個數字展開。
const int kMaxNackRetries = 10;const int kDefaultRttMs = 100;
const int kProcessIntervalMs = 20;
const int kMaxNackPackets = 1000;
const int kMaxPacketAge = 10000;
策略 1,10 次
NACK 模塊對同一包號的最大請求次數,超過這個最大次數限制,會把該包號移出 nack_list,放棄對該包的重傳請求。
策略 2,20 毫秒
NACK 模塊每隔 20 毫秒批量處理 nack_list,獲取一批請求包號存儲到 nack_batch,生成 nack 包并發送。
不過,nack_list 的處理周期并不是固定的 20ms ,而是基于 20ms 動態變化,接下來的源碼分析部分會詳細介紹這個點。
策略 3,100 毫秒
NACK 模塊默認 rtt 時間,如果距離上次 nack 發送時間不到一個 rtt 時間,那么不會發送 nack 請求。
從發送 nack 請求到接收重傳包一般是一個 rtt 的時間,也就是說重傳包理論上應該在一個 rtt 時間內到來,超過這個時間還未到來,才會發送 nack 請求。
注意,100ms 只是 rtt 的默認值,在實際應用中,rtt 應該要根據網絡狀況動態計算,計算方式有很多種,比如對于接收端來說,可以通過發送 xr 包來計算 rtt。
策略 4,1000 個(丟失包數量)
nack_list 的最大長度,即本次發送的 nack 包至多可以對 1000 個丟失的包進行重傳請求。
- 如果丟失的包數量超過 1000,會循環清空 nack_list 中關鍵幀之前的包,直到其長度小于 1000。也就是說,放棄對關鍵幀首包之前的包的重傳請求,直接而快速的以關鍵幀首包之后的包號作為重傳請求的開始。
舉個例子。假設連續收到了包號為 0、981、1182 的三個包,且都為關鍵幀的首包。當收到包號為 981 的包時,可知丟失了 980 個包,當收到包號為 1182 的包時,丟失的包數量達到 980 + 200,已經超過 1000,這時,需要控制 nack_list 的長度,具體的做法是:
整個過程如下圖所示:
NACK 關鍵幀清空策略- 如果經過多輪清空操作,key_frame_list 中已經沒有關鍵幀(無法再去清空 nack_list 中關鍵幀之前的包),但是此時 nack_list 的長度仍然大于 1000,那么將清空整個 nack_list,放棄所有重傳請求,直接請求新的關鍵幀。
一旦發生這種情況,基本可以說明當前網絡環境很差,從而導致大量的丟包。如果繼續期待 nack 重傳,那么可能會因為長時間等待重傳包而導致畫面卡頓,或者因為獲取不到重傳包而導致解碼花屏。
因為關鍵幀可以單獨解碼出圖像,不必參考前后視頻幀,所以,為了使解碼端能夠立刻刷新出新圖像,此時采取請求關鍵幀的方式替代重傳數據包,是更加合理且高效的做法。
策略 5,10000 個(包號跨度)
nack_list 中包號的距離不能超過 10000 個包號。即 nack_list 中的包號始終保持 [cur_seq_num - 10000, cur_seq_num] 這樣的跨度,以保證 nack 請求列表中不會有太老舊的包號。
小結
策略 1 和策略 4 屬于 nack 包發送的保護策略,這非常關鍵,比如有以下兩種場景:
- 場景 1,服務器下行分發鏈路丟包率過高。
這會導致接收端對一些包的重傳請求次數過高,如果不對 nack 請求次數做限制,那么接收端將無限循環發送 nack 請求。
- 場景 2,服務器上行推流鏈路出現長時間抖動,恢復后導致接收端 rtp 包號斷層。
假如包號斷層達到 1 萬,那么在抖動恢復的瞬間,接收端會將 1 萬個包號全部加入到 nack_list 。這會增加服務器生成 nack 包的負擔,而且生成的 nack 包將達到 2.3KB 大小,推流端解析這個包同樣也要耗費更多時間。
所以,如果沒有 1、4 這兩條 nack 保護策略,那么,當拉流用戶很多的時候,上述兩種場景會給服務器和端帶來巨大的 cpu 性能損耗,并會引起 nack 網絡風暴。不過,即使有這兩條發送保護策略加持,有時還是會產生很多問題,比如下面這種場景。
- 場景 3,上游服務器上行推流鏈路丟包,引發下游服務器回源分發鏈路丟包。
存在這種情況:上游服務器發送 nack 請求后,rtx 重傳包還未到來,所以還未中繼分發到下游服務器,然而此時下游服務已經收到了下游用戶連續的并發的 nack 請求。針對這種場景,則需要對上行推流鏈路進行數據包排序,只有組成完整的幀才會中繼分發到下游服務器,這樣就避免了下游用戶并發的 nack 請求。
其實,nack 的發送保護策略還有一條:收到一組連續且完整的幀之后,會立即對 nack_list 執行部分清空操作,避免無必要的再次重傳請求,接下來的源碼分析部分會進一步介紹這個策略。
最后,根據策略 1 和策略 3 的描述,我們可以推斷出這樣的結論:假設當前網絡 rtt 為 100ms,那么 100ms * 10 次,恰好為 1s。也就是說,包在 1s 內還沒有重傳回來,那么就放棄它。
源碼分析
基于 WebRTC M71 版本。
class NackModule : public Module {public:
int OnReceivedPacket(uint16_t seq_num,bool is_keyframe);
void Process() override;
void ClearUpTo(uint16_t seq_num);
private:
struct NackInfo {
NackInfo(uint16_t seq_num,
uint16_t send_at_seq_num);
uint16_t seq_num;
uint16_t send_at_seq_num;
int64_t sent_at_time;
int retries;
};
NackSender* const nack_sender_;
KeyFrameRequestSender* const
keyframe_request_sender_;
std::map<uint16_t, NackInfo,
DescendingSeqNumComp<uint16_t>> nack_list_;
std::set<uint16_t,
DescendingSeqNumComp<uint16_t>> keyframe_list_;
uint16_t newest_seq_num_;
};
關鍵成員變量
- newest_seq_num_ 表示 nack 模塊目前收到的最新的包的序列號,這是一個很關鍵的變量,它的作用主要有兩點:
比如,一組連續包 1 2 3 4 到來后,此時 newest_seq_num_ 為 4,隨后序列號為 7 的包到來,那么 7 號包不是連續到來的包,中間的 5 號和 6 號包會被認為丟失并加入到 nack_list,接著發送對 5 號包和 6 號包的 nack 請求。
此時 newest_seq_num_ 為 7,假設這樣一種場景:5 號包因為 nack 請求重傳到來,6 號包因為滯留在網絡亂序到來,那么這兩個包號會被移出 nack_list。
- NackInfo 是一個關鍵的數據結構,存儲在 nack_list 中,seq_num 代表請求重傳的包號,假設有如下兩個 NackInfo 信息:
nack_info_2 = { 6, 6, 123456789, 0};
觀察 nack_info_1,我們發現 sent_at_time 值為 -1,那么這是一個基于序列號發送的 nack,而且要在當前接收的最新包號 newest_seq_num_ 大于等于 send_at_seq_num = 10 時才會發送。
觀察 nack_info_2,我們發現,sent_at_time 值為 123456789,那么這是一個基于時間序列發送的 nack,要將這個參數結合當前 rtt 來決定是否發送重傳請求。
keyframe_list_ 和 nack_list_ 分別存儲了收到的關鍵幀首包包號和丟失的包信息, keyframe_list_ ?為 nack_list_ 大小超過 1000 后的清空邏輯提供服務。
NackSender 和 KeyFrameRequestSender 是真正發送 nack rtcp 包和關鍵幀請求包(pli 或者 fir)的接口類,用于 NackModule 模塊和 JitterBuffer 模塊發送 nack 或者關鍵幀請求,接口分別是 SendNack 和 RequestKeyFrame,應用層應該實現這兩個接口。
OnReceivedPacket 函數
該函數實現了基于包序列號的 nack 發送策略,其判斷是否要發送 nack 請求的關鍵在于包號是否連續。
首先,對于重復的包,不做任何處理。
if (seq_num == newest_seq_num_)return 0;
接下來,判斷是否是經過 nack 請求后重傳到來的包或者滯留在網絡中亂序到來的包,如果是則返回對該包的 nack 請求的次數。
其實,這兩種場景下到來的包都屬于亂序包,都是舊的包,且包號一定小于當前接收到的最新的包號 newest_seq_num_ 。
if (AheadOf(newest_seq_num_, seq_num)) {// An out of order packet has been received.
auto nack_list_it = nack_list_.find(seq_num);
int nacks_sent_for_packet = 0;
if (nack_list_it != nack_list_.end()) {
nacks_sent_for_packet =
nack_list_it->second.retries;
nack_list_.erase(nack_list_it);
}
return nacks_sent_for_packet;
}
關于判斷 rtp 包序列號大小(即判斷 rtp 包新舊)的算法,在 WebRTC 基礎技術 | RTP 包序列號的回繞處理[1] ?一文中有詳細的介紹,這里只不過用 AheadOf 函數替代了 IsNewerSequenceNumber 函數,內部比較算法是一致的,這里不再贅述。
接下來,判斷包的連續性,如果當前包號不連續,則將中間斷掉的包號加入到 nack 請求列表,并更新 newest_seq_num_ 。
AddPacketsToNack(newest_seq_num_ + 1, seq_num);
newest_seq_num_ = seq_num;
關于 AddPacketsToNack 函數的細節,策略 4 已經詳細介紹,這里不再贅述。
接下來,判斷收到的包是否是關鍵幀的第一個包,如果是,記錄其序列號到關鍵幀列表 keyframe_list_。和 nack_list 一樣,keyframe_list 也要遵循策略 5,即保持序列號的距離不超過 kMaxPacketAge = 10000。
if (is_keyframe)keyframe_list_.insert(seq_num);
auto it = keyframe_list_.lower_bound(
seq_num - kMaxPacketAge);
if (it != keyframe_list_.begin())
keyframe_list_.erase(keyframe_list_.begin(), it);
追蹤關鍵幀首包包號的目的是為了和策略 4 聯動,一旦發現 nack_list 的大小已經超過 1000,那么就要根據關鍵幀序列號來調整其大小。
最后,批量獲取 nack_list 中的包序列號到數組 nack_batch 中,生成并發送 nack 包。
std::vector<uint16_t> nack_batch =GetNackBatch(kSeqNumOnly);
if (!nack_batch.empty())
nack_sender_->SendNack(nack_batch);
Process 函數
該函數實現了基于時間周期(20ms)的 nack 發送模式,參考策略 2。具體的處理周期計算方法如下:
next_process_time_ms_ =next_process_time_ms_ +
kProcessIntervalMs +
(now_ms - next_process_time_ms_) /
kProcessIntervalMs * kProcessIntervalMs;
因為 kProcessIntervalMs = 20ms,所以上面代碼可以寫成下面這樣:
next_process_time_ms_ =next_process_time_ms_ + 20 +
(now_ms - next_process_time_ms_) / 20 * 20;
可知,在固定的 20ms 周期之上又附加了 ((now_ms - next_process_time_ms_) / 20)個 20 毫秒的時間,所以這是一個動態的周期。這么做的原因是為了應對 cpu 繁忙時線程調度滯后的場景,追趕上正常的處理進度。
NACK 動態處理周期如上圖所示,在 0ms 時間點進行第一次處理,并計算出了下一次處理的時間點為 20ms。假設由于 cpu 繁忙,導致線程調度滯后,在 40ms 的時間點才開始第二次處理(顯然,原定的 20ms 的處理時間點被跳過),此時需要計算第三次處理的時間點:
這就是動態處理周期的意義所在。其實,WebRTC 的注釋已經很好的解釋了這么做的原因:
Also add multiple intervals in case of a skip in time as to not make uneccessary calls to Process in order to catch up.
GetNackBatch 函數
該函數傳入 nack 過濾選項參數,根據時間或者序列號批量獲取 nack_list 中的包序列號,并返回存儲了這些包號的數組 nack_batch。
while (it != nack_list_.end()) {if (consider_seq_num &&
it->second.sent_at_time == -1 &&
AheadOrAt(newest_seq_num_,
it->second.send_at_seq_num))
{
nack_batch.emplace_back(it->second.seq_num);
++it->second.retries;
it->second.sent_at_time = now_ms;
if (it->second.retries >= kMaxNackRetries) {
it = nack_list_.erase(it);
} else {
++it;
}
continue;
}
if (consider_timestamp &&
it->second.sent_at_time +
rtt_ms_ <= now_ms)
{
nack_batch.emplace_back(it->second.seq_num);
++it->second.retries;
it->second.sent_at_time = now_ms;
if (it->second.retries >= kMaxNackRetries) {
it = nack_list_.erase(it);
} else {
++it;
}
continue;
}
++it;
}
閱讀上面的源碼可知,對于基于序列號和基于時間序列這兩種不同的 nack 發送模式,它們的發送條件如下:
這兩種發送模式具體的處理方式一致:
ClearUpTo 函數
該函數傳入包序列號 seq_num 參數,將 nack_list 和 key_frame_list 中 seq_num 之前的包全部清空,也就是說對于 seq_num 之前的包不再請求重傳,同樣屬于 NACK 模塊發送保護策略。
nack_list_.erase(nack_list_.begin(),nack_list_.lower_bound(seq_num));
keyframe_list_.erase(keyframe_list_.begin(),
keyframe_list_.lower_bound(seq_num));
該函數在 WebRTC 中的應用場景是:當接收到一組連續且完整的幀之后,找到最后一幀的最后一個包的 seq_num,執行 ClearUpTo 函數。相關的代碼可參考 RtpVideoStreamReceiver::OnCompleteFrame 函數。
小結
在源碼分析這一部分,有的函數我并未具體介紹,比如 RemovePacketsUntilKeyFrame 和 AddPacketsToNack。您可以結合發送策略部分閱讀這部分源碼,相信不是什么難事。
另外,關于 UpdateReorderingStatistics 和 WaitNumberOfPackets 這兩個函數,涉及到了 NACK 的延遲發送策略。該策略通過不斷更新當前網絡環境下的亂序分布直方圖,計算 nack 包延遲發送需要等待的包的個數。
比如某次 nack_info 請求的包號為 5,經過延遲發送策略計算,需要等待 2 個包才能發送該 nack 請求,那么對 5 號包的重傳請求會在 7 號包到達之后才能發送。
為什么要這么做呢?這是因為,5 號包可能并未真正丟失,只是滯留在網絡鏈路中,可能經過短暫的等待就會到達,這種情況下則不必發送 nack 請求,一定程度上可以降低網絡流量消耗。不過,WebRTC 目前并未啟用該策略。
NACK FCI (Feedback Control Information)格式
對于 nack 的發送,并不是每丟一個 rtp 包就發送一個 nack rtcp 請求包,而是將一批丟失的 rtp 包的序列號記錄到 nack 包的 FCI 信息中(將 nack_list 中的 nack_info 打包到 nack_batch,通過 nack_batch 生成 nack 包),一次 nack 可以請求多個丟失的 rtp 包。那么 nack 包是如何記錄多個丟失的包的呢?下面,我們分析一下 NACK 的 FCI 格式。
每一個 nack 包至少攜帶 1 條 FCI 信息,FCI 格式如下:
NACK FCI 格式- PID
Packet ID,2 字節,是丟失的第一個 RTP 數據包的序列號。
- BLP
bitmask of following lost packets,16 bits,記錄了 PID 之后的 16 個 RTP 數據包的丟失情況。
比如,接收端沒有收到序列號為 (PID + i) % 65536 的 RTP 包,即這個包丟失了,那么 BLP 第 i 個 bit 會被置為 1。
注意,根據 RFC 4585[2] 的描述:BLP 某個 bit 被置為 1,發送端可以認為接收端丟失了相應的包,但是 BLP 某個位為 0,發送端卻并不能得出接收端已經收到相應包的結論,發送端只能認為這個包在這個時刻沒有被接收端報告為丟失。
我們把 nack 中一條 FCI 信息看作是一個 item,那么每一個 nack_item 都是 4 字節長度,至多可以表示 17 個丟失的包。假設 mtu = 1212 字節,減去 12 字節頭部長度后,1200 字節最大可以表示 300 個 nack_item,即 5100 個連續丟失的包。
不過,一次 nack 請求如此多連續的包的情況一般不會發生。首先,根據策略 4,單個 nack 至多可以記錄 1000 個丟失的包號。其次,出現這種情況足以說明網絡發生了較長時間的抖動,且大于 ice 15 秒的超時時間,還沒等到請求 nack,ice 早已斷開了連接。
抓包分析
看完 NACK 的 FCI 格式,下面我們來實際抓包看一下 nack 包的廬山真面目。注意,抓包前需要把 dtls-srtp 加密關掉,否則 wireshark 無法解析 FCI。
wireshark 分析 NACK觀察上圖,可知:
至此,全文結束,感謝閱讀。
參考資料
[1]RTP 包序列號的回繞處理: https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=10&appmsgid=100000263&isMul=1&isSend=0&token=452395595&lang=zh_CN
[2]Generic NACK: https://tools.ietf.org/html/rfc4585#section-6.2.1
與50位技術專家面對面20年技術見證,附贈技術全景圖
總結
以上是生活随笔為你收集整理的10没有基于策略的qos_WebRTC QoS | NACK 格式与发送策略的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: bigdecimal js 判断等于0_
- 下一篇: linux网络文件系统包括,Linux文