深入浅出WebRTC—NACK

WebRTC 中的 NACK(Negative Acknowledgment)机制是实时通信中处理网络丢包的关键组件。网络丢包是常见的现象,尤其是在无线网络或不稳定连接中。NACK 机制旨在通过请求重传丢失的数据包来减少这种影响,从而保持通信的连续性和质量。

1. 总体架构

WebRTC NACK 总体架构如下图所示。

1)发送端发送 RTP 报文时会缓存一份到 RtpPacketHistory,收到 NACK 请求的时候从 RtpPacketHistory 获取对应缓存报文发送出去。RtpPacketHistory 收到 TransportFeedback 会将接收端确认的报文从缓存中移除。

2)接收端收到的所有报文都从 NackRequester 过一遍(只需要序列号),丢失了哪个报文门清。由于丢包和乱序无法分辨,NackRequest 在定时器驱动下发送 NACK 请求(极端丢包情况会发送关键帧请求)。

2. 发送端

2.1. 调用流程

发送端的调用流程由三条子流程组成:

1)发送出去的报文会被缓存到 RtpPacketHistory,用来响应 NACK 请求。

2)收到 TransportFeedback 将对应报文从 RtpPacketHistory 中移除。

3)收到 NACK 请求从 RtpPacketHistory 获取缓存报文发送出去。

2.2. RtpPacketHistory

RtpSenderEgress 负责报文发送,发送完后将报文缓存到 RtpPacketHistory。ModuleRtpRtcpImpl2 处理所有 RTCP 报文,NACK 请求交给 RTPSender 处理,RTPSender 从 RtpPacketHistory 获取请求重传的报文然后发送出去。

2.2.1. 重传条件

RtpSenderEgress 只会将满足条件的报文缓存到 RtpPacketHistory。正常的视频帧需要重传,但 FEC 报文不重传。另外,对于 simulcast 或 SVC,需要根据重传策略来决定,判断逻辑比较复杂,这里暂不分析。

cpp 复制代码
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 是一个硬性数量限制,不管缓存的报文是否新鲜,都不能超过这个限制。

cpp 复制代码
// 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;
cpp 复制代码
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 报文,用真实报文当 padding 报文,既填充了码率又实现了冗余,一石二鸟。

RtpPacketHistory 中缓存了很多报文,挑选哪些报文做 padding 报文,支持三种 padding 模式:

cpp 复制代码
enum class PaddingMode {
	// 选择最近缓存的报文作为 Padding 报文
	kDefault,
	// 基于发送时间、重传次数等因素选择更好的历史报文作为 Padding 报文
	kPriority,
	// 使用最近缓存的大包作为Padding报文
	kRecentLargePacket
};

对于 kPriority 模式,优先级定义如下:

cpp 复制代码
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 模式。

cpp 复制代码
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 控制核心,调用流程如下图所示。

1)RtpVideoStreamReceiver2 收到报文,在进行处理的同时也要通知 NackRequester。

2)NackRequester 内部有一个 NACK 请求队列,如果发现有丢包就会添加一个 NACK 请求项。

3)NackPeriodicProcessor 会定时调用 NackRequester 发送 NACK 请求。

4)通过层层调用将 NACK 请求发送出去。

3.2. NackRequester

每一个 RtpVideoStreamReceiver2 都持有一个 NackRequester,用来发起 NACK 请求。NackRequester 被 NackPeriodicProcessor 定时驱动,NACK 请求通过 NackSender 发送出去。如果丢包特别严重,NackRequester 会使用 KeyFrameRequestSender 发起关键帧请求。

3.2.1. NackList

NackList 是 NackRequester 内部的 NACK 请求队列。每次收到新的报文,与最近收到的报文 SN 进行比较,如果两个 SN 之间有空洞(SN 跳跃),认为有丢包,以空洞 SN 创建 NACK 请求项插入 NackList。

cpp 复制代码
// 队列中首尾报文Sequence Number的最大跨度,适用于NackList、KeyFrameList和RecoveredList
constexpr int kMaxPacketAge = 10'000;
// 队列中最大报文数
constexpr int kMaxNackPackets = 1000;
// 最大重传次数
constexpr int kMaxNackRetries = 10;

因为空洞也可能是乱序导致,后续可能立即就会收到丢失报文,所以不能立即发送 NACK 请求。WebRTC 会启动一个定时器,确定 NackRequester 定时检查 NackList 中的 NACK 项,判断是否需要发送 NACK 请求。

决定选取哪些 NACK 项发起 NACK 请求,有不同筛选条件:

cpp 复制代码
enum NackFilterOptions { kSeqNumOnly, kTimeOnly, kSeqNumAndTime };

1)kSeqNumOnly

基于报文乱序情况,每个 NACK 项插入队列时都会计算一个触发重传的 SN,表示后续收到此 SN 报文时,如果NACK 项还在队列中,且还没有发起过 NACK 请求,则立即触发一次。

每收到一个报文会检查此条件,当瞬时丢包比较严重的时候,能够比定时器更快触发 NACK 请求的发送,类似于 TCP 的快速重传机制。

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 内部有一个 RecoveredList,如果收到的是通过 FEC 或 RTX 恢复的报文,不会用来生成 NACK 请求项,而是被保存到 RecoveredList 中。在创建 NACK 请求项时,如果此报文已经被恢复了,则需要跳过。

为什么不把恢复报文当成普通的报文来处理,目前看是如果那样做会影响乱序的统计,而乱序的统计,又会影响前面讲到的 kSeqNumOnly 快速重传序号的计算。

3.3. 源码分析

3.3.1. OnReceivedPacket

这是 NackRequester 主函数,收到每个报文都需要调用此函数来生成或移除 NACK 请求项。

cpp 复制代码
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

当新收到报文与最近收的报文之间有空洞时,会调用此函数插入 NACK 请求项。这里要关注下,极端情况会清空 NACK 请求列表,直接发送关键帧请求。

cpp 复制代码
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

定时器驱动调用此函数,定时检查发送 NACK 请求项。

cpp 复制代码
void NackRequester::ProcessNacks() {
	// 定时器驱动,只获取基于时间条件判断需要处理的 NACK 项
  std::vector<uint16_t> nack_batch = GetNackBatch(kTimeOnly);
  if (!nack_batch.empty()) {
    nack_sender_->SendNack(nack_batch, /*buffering_allowed=*/false);
  }
}

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;
}

4. 总结

WebRTC NACK 的实现简单明了,发送端缓存报文,接收端请求重传。但发送端和接收端实现关注重点不太一样。发送端是被动接收 NACK 请求,实现相对简单一些,重点关注缓存队列的长度。接收端需要主动发送发送 NACK 请求,实现会相对复杂一些,由于存在报文乱序,什么时候发起 NACK 请求是一个值得斟酌的事情。

除此之外,WebRTC 还考虑到了瞬间突发丢包的快速重传机制和基于关键帧的队列收缩等,这些都凸显了 WebRTC 对细节的掌控和重视。

相关推荐
EasyCVR2 天前
多品牌摄像机视频平台EasyCVR视频融合平台+应急布控球:打造城市安全监控新体系
大数据·网络·人工智能·音视频·webrtc
ZachOn1y4 天前
计算机网络:运输层 —— TCP 的拥塞控制
网络·网络协议·tcp/ip·计算机网络·tcp·拥塞控制
SG_Hao5 天前
人门网络(数据中心网络,高速网络)
网络·拥塞控制·大数据中心
安步当歌6 天前
【WebRTC】视频发送链路中类的简单分析(上)
音视频·webrtc·视频编解码·video-codec
MetaverseMan7 天前
WebRTC 和 WebSocket
websocket·网络协议·webrtc
Mao.O7 天前
WebRTC实现双端音视频聊天(Vue3 + SpringBoot)
音视频·webrtc
红米饭配南瓜汤8 天前
WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇
音视频·webrtc·媒体
红米饭配南瓜汤8 天前
WebRTC视频 05 - 视频采集类 VideoCaptureDS 下篇
音视频·webrtc·媒体
安步当歌9 天前
【WebRTC】视频发送链路中类的简单分析(下)
网络·音视频·webrtc·视频编解码·video-codec
从后端到QT9 天前
WebRTC API分析
webrtc