深入浅出WebRTC—NACK
WebRTC 中的 NACK(Negative Acknowledgment)机制是实时通信中处理网络丢包的关键组件。在网络条件下,数据包丢失是常见的现象,尤其是在无线网络或不稳定连接中。NACK 机制旨在通过请求丢失的数据包的重传来减少这种影响,从而保持通信的连续性和质量。
1. 总体架构
NACK 总体架构如下图所示。发送端发送 RTP 报文时会缓存一份,收到 NACK 请求的时候从缓存获取对应报文发送出去,RtpPacketHistory 收到 TransportFeedback 将对端收到的报文从缓存中移除。接收端收到的所有报文都从 NackRequester 过一遍(只需要序列号),NackRequest 在定时器驱动下发送 NACK 请求(极端情况会发送关键帧请求)。
2. 发送端
2.1. 调用流程
发送端的调用流程以 RTPPacketHistory 为核心,由三条子流程组成:
1)Send RTP packet
发送出去的报文会被缓存到 RtpPacketHistory,用来响应 NACK 请求。
2)Received ACK
收到 TransportFeedback,确认对方已经收到报文,将对应报文从 RtpPacketHistory 中移除。
3)Received NACK
收到 NACK 请求,从 RtpPacketHistory 查找缓存报文并发送出去。
2.2. RtpPacketHistory
可以将 RtpPacketHistory 看成是一个缓存队列。RtpSenderEgress 负责报文发送,发送完后将报文缓存到 RtpPacketHistory。ModuleRtpRtcpImpl2 处理所有 RTCP 报文,NACK 请求交给 RTPSender 处理,RTPSender 从 RtpPacketHistory 获取请求重传的报文然后发送出去。
2.2.1. 重传条件
RtpSenderEgress 只会将满足条件的报文缓存到 RtpPacketHistory。首先 FEC 报文不重传,其次,正常的视频帧需要重传,但对于 simulcast 或 SVC,需要根据重传策略决定来判断,判断逻辑比较复杂,暂不分析。
void RtpSenderEgress::CompleteSendPacket(const Packet& compound_packet, bool last_in_batch) { ... if (is_media && packet->allow_retransmission()) { packet_history_->PutRtpPacket(std::make_unique<RtpPacketToSend>(*packet), now); } else if (packet->retransmitted_sequence_number()) { packet_history_->MarkPacketAsSent(*packet->retransmitted_sequence_number()); } ... }
2.2.2. 队列长度
缓存队列长度非常重要,太长的话,会引入较大延迟,太短的话,会导致重传 miss。因此,队列长度的设置需要在延迟和 miss 之间取得一个较好的平衡。
WebRTC 从时间和数量两个维度来对队列长度进行限制,其中,kMaxCapacity 是一个硬性限制,不管缓存的报文是否新鲜,都不能超过这个限制。
// packet_duration = max(1 second, 3x RTT). static constexpr TimeDelta kMinPacketDuration = TimeDelta::Seconds(1); static constexpr int kMinPacketDurationRtt = 3; // With kStoreAndCull, always remove packets after 3x max(1000ms, 3x rtt). static constexpr int kPacketCullingDelayFactor = 3; // number_to_store_ = min(kMaxCapacity, kMinSendSidePacketHistorySize) static constexpr size_t kMaxCapacity = 9600; static const int kMinSendSidePacketHistorySize = 600;
void RtpPacketHistory::CullOldPackets() { // 当前时间 Timestamp now = clock_->CurrentTime(); // 取 3 倍 RTT 和 1秒两者较大值,即不小于 1 秒 TimeDelta packet_duration = rtt_.IsFinite() ? std::max(kMinPacketDurationRtt * rtt_, kMinPacketDuration) : kMinPacketDuration; while (!packet_history_.empty()) { // 队列中报文数量超过最大容量限制 if (packet_history_.size() >= kMaxCapacity) { RemovePacket(0); // 移除最旧的报文 continue; } // 取队列首报文进行判断 const StoredPacket& stored_packet = packet_history_.front(); // 正在重传中,退出 if (stored_packet.pending_transmission_) { return; } // 还很新鲜(未超时),退出 if (stored_packet.send_time() + packet_duration > now) { return; } // 首报文已经不新鲜,如果报文数量多或者首报文太老,才需要移除 if (packet_history_.size() >= number_to_store_ || stored_packet.send_time() + (packet_duration * kPacketCullingDelayFactor) <= now) { RemovePacket(0); } else { // No more packets can be removed right now. return; } } }
2.2.3. PaddingMode
RtpPacketHistory 还可以用来生成 padding 报文,进行带宽探测。RtpPacketHistory 支持三种 padding 模式,如下所示。
enum class PaddingMode { // 选择最近缓存的报文作为 Padding 报文 kDefault, // 基于发送时间、重传次数等因素选择更好的历史报文作为 Padding 报文 kPriority, // 使用最近缓存的大包作为Padding报文 kRecentLargePacket };
对于 kPriority 模式,优先级定义如下:
bool RtpPacketHistory::MoreUseful::operator()(StoredPacket* lhs, StoredPacket* rhs) const { // 没有重传过的报文优先级更高 if (lhs->times_retransmitted() != rhs->times_retransmitted()) { return lhs->times_retransmitted() < rhs->times_retransmitted(); } // 时间越近的报文优先级越高 return lhs->insert_order() > rhs->insert_order(); }
最新代码已经不再使用 kDefault 模式。
RtpPacketHistory::PaddingMode GetPaddingMode(const FieldTrialsView* field_trials) { if (!field_trials || !field_trials->IsDisabled("WebRTC-PaddingMode-RecentLargePacket")) { return RtpPacketHistory::PaddingMode::kRecentLargePacket; } return RtpPacketHistory::PaddingMode::kPriority; }
3. 接收端
3.1. 调用流程
NackRequester 是接收端的 NACK 控制核心,调用流程如下图所示。接收端收到报文都要传递给 NackRequester,NackRequester 根据序列号空洞创建 NACK 项保存起来。定时器基于时间维度检查是不是该发送 NACK 请求,每次收到报文会基于抖动维度检查是不是应该发送第一个 NACK 请求。
3.2. NackRequester
每一个 RtpVideoStreamReceiver2 都持有一个 NackRequester,用来发起 NACK 请求。NackRequester 被 NackPeriodicProcessor 定时驱动,NACK 请求通过 NackSender 发送出去。如果丢包特别严重,NackRequester 会使用 KeyFrameRequestSender 发起关键帧请求。
3.2.1. NackList
每次收到新的报文,与最近收到的报文 SN 进行比较,如果两个 SN 之间有空洞,认为有丢包,以空洞 SN 创建 NACK 项加入 NackList。
// 队列中首尾报文Sequence Number的最大跨度,适用于NackList、KeyFrameList和RecoveredList constexpr int kMaxPacketAge = 10'000; // 队列中最大报文数 constexpr int kMaxNackPackets = 1000; // 最大重传次数 constexpr int kMaxNackRetries = 10;
因为空洞也可能是乱序导致,所以不能立即发送 NACK 请求。WebRTC 会启动一个定时器,定时检查 NackList 中的 NACK 项,判断是否需要发送 NACK 请求。决定选取哪些 NACK 项发起 NACK 请求,有不同筛选条件:
enum NackFilterOptions { kSeqNumOnly, kTimeOnly, kSeqNumAndTime };
1)kSeqNumOnly
基于报文乱序情况,每个 NACK 项插入队列时都会计算一个触发重传的 SN,表示后续收到此 SN 报文时,如果NACK 项还在队列中,且还没有发起过 NACK 请求,则立即触发一次。
2)kTimeOnly
每次发送 NACK 请求都会更新 NACK 的最近请求时间,如果最近请求时间距当前时间超过一个 RTT,则会重新触发 NACK 请求。
3)kSeqNumAndTime
相当于“kSeqNumOnly || kTimeOnly”,只要一个条件满足就会触发 NACK 请求。
3.2.2. KeyFrameList
KeyFrameList 主要用来协助 NackList 进行收缩。对于视频来说,GOP 中的帧是有依赖关系的,如果前面的帧没有恢复,恢复后面的帧没有意义。因此,当 NackList 表项溢出需要移除表项腾出空间时,WebRTC 是按照 GOP 粒度去丢弃历史久远的 NACK 项。下面举例说明。
假设有一个视频流,每个 GOP 由 5 个非 I 帧 报文和 2 个 I 帧报文组成,报文序列如下所示:
1,2,3,4,5,6,7,8,9,10,11,12,13,14,...
如果没有及时收到 3、4、11、13 四个报文,NackList 和 KeyFrameList 状态如下:
此时,如果需要创建新的 NACK 项,但 NackList 空间不够,需要丢弃 GOP1(3和4两个Nack项),状态如下:
NackList 空出两个表项,如果空间还不够,则从 KeyFrameList 中弹出表项,直到SN比NackList中的大,然后重复删除过程。
3.2.3. RecoveredList
NackRequester 收到的报文可能是经过 FEC 或 RTX 恢复的报文。对于通过 FEC 或 RTX 恢复的报文,不会用来生成 NACK 项。这些恢复报文会被保存到 RecoveredList 中,在创建 NACK 项时,如果此报文已经被恢复了,则需要跳过。至于为什么不把恢复报文当成普通的报文来处理,可能是因为如果这样做会影响算法对网络模型的评估,比如乱序情况等。
3.3. 源码分析
3.3.1. OnReceivedPacket
int NackRequester::OnReceivedPacket(uint16_t seq_num, bool is_keyframe, bool is_recovered) { bool is_retransmitted = true; // 初始化 if (!initialized_) { newest_seq_num_ = seq_num; if (is_keyframe) keyframe_list_.insert(seq_num); initialized_ = true; return 0; } // 重复接收 if (seq_num == newest_seq_num_) return 0; // 乱序包 if (AheadOf(newest_seq_num_, seq_num)) { auto nack_list_it = nack_list_.find(seq_num); int nacks_sent_for_packet = 0; // 报文已经收到,移除 nack 项 if (nack_list_it != nack_list_.end()) { nacks_sent_for_packet = nack_list_it->second.retries; nack_list_.erase(nack_list_it); } // 直方图统计乱序情况,重传报文的乱序不统计 if (!is_retransmitted) UpdateReorderingStatistics(seq_num); return nacks_sent_for_packet; } // 保存关键帧报文序列号 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); // 经 FEC 或 RTX 恢复的报文 if (is_recovered) { recovered_list_.insert(seq_num); // 恢复报文太多,清理下 auto it = recovered_list_.lower_bound(seq_num - kMaxPacketAge); if (it != recovered_list_.begin()) recovered_list_.erase(recovered_list_.begin(), it); // Do not send nack for packets recovered by FEC or RTX. return 0; } // 走到这里 seq_num 肯定比 newest_seq_num 大,newest_seq_num_ + 1, seq_num 之间 // 可能存在 0 个或多个空洞,这些空洞就是需要发送nack的报文 AddPacketsToNack(newest_seq_num_ + 1, seq_num); // 更新收到的最新序列号 newest_seq_num_ = seq_num; // 这里仅发送基于序列号触发的 NACK 请求 std::vector<uint16_t> nack_batch = GetNackBatch(kSeqNumOnly); if (!nack_batch.empty()) { nack_sender_->SendNack(nack_batch, /*buffering_allowed=*/true); } return 0; }
3.3.2. AddPacketsToNack
void NackRequester::AddPacketsToNack(uint16_t seq_num_start, uint16_t seq_num_end) { // NACK 项太多了,清理下 auto it = nack_list_.lower_bound(seq_num_end - kMaxPacketAge); nack_list_.erase(nack_list_.begin(), it); // 计算空洞数量 uint16_t num_new_nacks = ForwardDiff(seq_num_start, seq_num_end); // 确保添加空洞 NACK 项后总 NACK 项不会超过最大限制 if (nack_list_.size() + num_new_nacks > kMaxNackPackets) { // 先移除关键帧之前的 NACK 项 while (RemovePacketsUntilKeyFrame() && nack_list_.size() + num_new_nacks > kMaxNackPackets) { } // 还是腾不出足够的空间,则清空 NACK 队列,直接请求 I 帧 if (nack_list_.size() + num_new_nacks > kMaxNackPackets) { nack_list_.clear(); keyframe_request_sender_->RequestKeyFrame(); return; } } // 遍历所有空洞创建 NACK 项 for (uint16_t seq_num = seq_num_start; seq_num != seq_num_end; ++seq_num) { // 空洞报文可能已经被 FEC 或 RTX 恢复 if (recovered_list_.find(seq_num) != recovered_list_.end()) continue; // 使用乱序长度的中位数来计算触发重传的序列号 NackInfo nack_info(seq_num, seq_num + WaitNumberOfPackets(0.5), clock_->CurrentTime()); nack_list_[seq_num] = nack_info; } }
3.3.3. GetNackBatch
std::vector<uint16_t> NackRequester::GetNackBatch(NackFilterOptions options) { // 仅考虑序列号 bool consider_seq_num = options != kTimeOnly; // 仅考虑时间 bool consider_timestamp = options != kSeqNumOnly; // 当前时间 Timestamp now = clock_->CurrentTime(); // 筛选结果 std::vector<uint16_t> nack_batch; auto it = nack_list_.begin(); while (it != nack_list_.end()) { // 等待发送 NACK 的时间已经到了 bool delay_timed_out = now - it->second.created_at_time >= send_nack_delay_; // 距离上一次发送 NACK 的时间也已经过去很久了 bool nack_on_rtt_passed = now - it->second.sent_at_time >= rtt_; // 基于序列号的发送,只有在第一次发送Nack时生效 bool nack_on_seq_num_passed = it->second.sent_at_time.IsInfinite() && AheadOrAt(newest_seq_num_, it->second.send_at_seq_num); // 已经过了等待时间,基于时间和基于序列号两者满足其一 if (delay_timed_out && ((consider_seq_num && nack_on_seq_num_passed) || (consider_timestamp && nack_on_rtt_passed))) { nack_batch.emplace_back(it->second.seq_num); ++it->second.retries; // 更新发送 NACK 请求次数 it->second.sent_at_time = now; // 更新最近发送 NACK 请求时间 // 已经达到最大请求次数限制,从队列中移除,不再请求了 if (it->second.retries >= kMaxNackRetries) { it = nack_list_.erase(it); } else { ++it; } continue; } ++it; } return nack_batch; }
3.3.4. ProcessNacks
void NackRequester::ProcessNacks() { // 定时器驱动,只获取基于时间条件判断需要处理的 NACK 项 std::vector<uint16_t> nack_batch = GetNackBatch(kTimeOnly); if (!nack_batch.empty()) { nack_sender_->SendNack(nack_batch, /*buffering_allowed=*/false); } }
4. 总结
NACK 实现发送端和接收端关注重点不太一样。发送端实现相对简单一些,被动接收 NACK 请求,重点关注缓存队列的长度。接收端需要主动发送发送 NACK 请求,由于 UDP 报文可能存在乱序,因此,什么时候发送 NACK 请求是一个值得权衡的事情。一般的思路是基于时间来触发 NACK 请求,WebRTC 还考虑了乱序触发的场景,更加全面。另外,基于关键帧的 NackList 收缩和针对恢复报文的特殊处理,凸显了 WebRTC 对细节的掌控。
深入探索WebRTC实现原理