webrtc系列(三)——3分钟带你看懂NACK机制

2,279 阅读8分钟

认真看完这篇文章,相信你能收获一些东西

webrtc是基于udp协议来进行传输音视频数据的,所以基于udp的特性,rtc采用了2种方式来优化丢包问题

  • fec,前向纠错,在每个数据包中,添加一些关于前一个信息的信息,以防丢失,您需要重新构建它们,如果fec为5%,那么在丢包小于5%的情况下,都可以通过fec进行恢复,组成完整的帧,但是需要发送额外的包,会占用更大的带宽,具体如何实现,可以自行研究下,不在本文的讨论范围之中。
  • NACK机制,和TCP的ACK机制正好相反,NACK是用来确认丢包的发送协议,当接收方检测到有丢包时,它会发送NACK类型的RTCP包给发送方,发送方会重发这些数据。

NACK 模块是 WebRTC 对抗弱网的核心 QoS 技术之一,有两种发送模式,一种是基于时间序列的发送模式,一种是基于包序列号的发送模式。很明显,NACK机制,也是需要两端配合进行同时处理,我们分别来讨论下。

接收方

当接收方检测到有丢包时,它会发送NACK类型的RTCP包给发送方

带着问题去思考

看上去非常简单的一个逻辑,但是我们思考下几个问题

  • 如何判断丢包?也是如何选择发送NACK的时机?因为UDP是无连接状态的,不能保证数据的连续性,比如我们先收到了序号1的包, 第二次收到了序号3的包,那么此时是否可以认为序号2的包已经丢失,需要发送NACK报告丢包情况,但很有可能下一时刻2号包就到了
  • 是否需要一直发送NACK包?假如我们发送NACK后,可能因为网络原因等等,一直没有收到接受方发送的重发包,那么还要一直继续发送NACK吗,会不会导致服务链路拥塞
  • 如果丢包数量过多,超过了一定的数量,是否需要放弃之前的丢包数据,不再进行发送NACK?

针对现实中网络的复杂程度,上面的问题都是需要我们考虑之内的。

实施策略

rtc内部也考虑到了这些问题,目前有一些实施的策略来保证,记住几个关键的数字如下。

const int kMaxNackRetries = 10;
const int kProcessIntervalMs = 20;
const int kDefaultRttMs = 100;
const int kMaxNackPackets = 1000;
const int kMaxPacketAge = 10000;
  • NACK 模块对同一包号的最大请求次数是10次,超过这个最大次数限制,会把该包号移出 nack_list,放弃对该包的重传请求。
  • NACK 模块每隔 20 毫秒批量处理 nack_list,获取一批请求包号存储到 nack_batch,生成 nack 包并发送。不过,nack_list 的处理周期并不是固定的 20ms ,而是基于 20ms 动态变化
  • NACK 模块默认 rtt 时间,如果距离上次 nack 发送时间不到一个 rtt 时间,那么不会发送 nack 请求,注意,100ms 只是 rtt 的默认值,在实际应用中,rtt 应该要根据网络状况动态计算,计算方式有很多种,比如对于接收端来说,可以通过发送 xr 包来计算 rtt。
  • nack_list 的最大长度,即本次发送的 nack 包至多可以对 1000 个丢失的包进行重传请求。
  • nack_list 中包号的距离不能超过 10000 个包号。即 nack_list 中的包号始终保持 [cur_seq_num - 10000, cur_seq_num] 这样的跨度,以保证 nack 请求列表中不会有太老旧的包号

关于第四点,nack_list 的最大长度,这里拉出来单独理解下,如果丢失的包数量超过 1000,会循环清空 nack_list 中关键帧之前的包,直到其长度小于 1000,但是并不是清除到刚好到1000的数量,也就是说,放弃对关键帧首包之前的包的重传请求,直接而快速的以关键帧首包之后的包号作为重传请求的开始。

怎么理解呢?有过音视频相关知识的同学知道,在一个GOP内,解码时,后面的帧都是参考前面的帧进行解码的,如果一个GOP内,前面的帧被清掉了,后面的也没有重传的必要

举个例子,假如我们接收方,收到的包序号是 1/701/1201,并且都是关键帧的包,那按照上面的算法,我们丢失包是700+500 = 1200个,此时触发了大于1000的条件,那么需要清空超过的包体,按照上面关键帧的算法,那么这里会将701之前的包都会清除掉保证重传的意义。因为如果按照只清除超过的包体算法,只会清除1-201的包,但是如果这样,201-700的包体,重传了也没有意义,因为无法进行解码。

源码分析

nack_module.h

class NackModule : public Module {
 public:
  ..............
  int OnReceivedPacket(uint16_t seq_num, bool is_keyframe);
  int OnReceivedPacket(uint16_t seq_num, bool is_keyframe, bool is_recovered);
​
  void ClearUpTo(uint16_t seq_num);
  void UpdateRtt(int64_t rtt_ms);
  void Clear();
​
  // Module implementation
  int64_t TimeUntilNextProcess() override;
  void Process() override;
​
 private:
  struct NackInfo {
    NackInfo();
    NackInfo(uint16_t seq_num,
             uint16_t send_at_seq_num,
             int64_t created_at_time);
​
    uint16_t seq_num;
    uint16_t send_at_seq_num;
    int64_t created_at_time;
    int64_t sent_at_time;
    int retries;
  };
  std::map<uint16_t, NackInfo, DescendingSeqNumComp<uint16_t>> nack_list_
      RTC_GUARDED_BY(crit_);
  std::set<uint16_t, DescendingSeqNumComp<uint16_t>> keyframe_list_
      RTC_GUARDED_BY(crit_);
  std::set<uint16_t, DescendingSeqNumComp<uint16_t>> recovered_list_
      RTC_GUARDED_BY(crit_);
  video_coding::Histogram reordering_histogram_ RTC_GUARDED_BY(crit_);
  bool initialized_ RTC_GUARDED_BY(crit_);
  int64_t rtt_ms_ RTC_GUARDED_BY(crit_);
  uint16_t newest_seq_num_ RTC_GUARDED_BY(crit_);
  // Only touched on the process thread.
  int64_t next_process_time_ms_;
  // Adds a delay before send nack on packet received.
  const int64_t send_nack_delay_ms_;
​
  const absl::optional<BackoffSettings> backoff_settings_;
};
}  // namespace webrtc#endif  // MODULES_VIDEO_CODING_DEPRECATED_NACK_MODULE_H_

成员变量

  • nack_list_,丢包的数组,如果判断符合NACK条件,添加到数组之中
  • keyframe_list_,关键帧数组
  • recovered_list_,丢包重传恢复的数组
  • newest_seq_num_,当前最新的包的序号,用来判断包是否连续等等
  • NackInfo ,非常重要的一个结构体,上面我们说到NACK 有两种发送模式,基于时间和基于包序号的,如果sent_at_time 为-1,那么这是一个基于序列号发送的 nack,而且要在当前接收的最新包号 newest_seq_num_ 大于等于 send_at_seq_num 时才会发送。sent_at_time如果有值,那么这是一个基于时间序列发送的 nack,要将这个参数结合当前 rtt 来决定是否发送重传请求。

OnReceivedPacket

int NackModule2::OnReceivedPacket(uint16_t seq_num,
                                  bool is_keyframe,
                                  bool is_recovered) {
  RTC_DCHECK_RUN_ON(worker_thread_);
  // TODO(philipel): When the packet includes information whether it is
  //                 retransmitted or not, use that value instead. For
  //                 now set it to true, which will cause the reordering
  //                 statistics to never be updated.
  bool is_retransmitted = true;
​
  if (!initialized_) {
    newest_seq_num_ = seq_num;
    if (is_keyframe)
      keyframe_list_.insert(seq_num);
    initialized_ = true;
    return 0;
  }
​
  // Since the |newest_seq_num_| is a packet we have actually received we know
  // that packet has never been Nacked.
  if (seq_num == newest_seq_num_)
    return 0;
​
  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);
    }
    if (!is_retransmitted)
      UpdateReorderingStatistics(seq_num);
    return nacks_sent_for_packet;
  }
​
  // Keep track of new keyframes.
  if (is_keyframe)
    keyframe_list_.insert(seq_num);
​
  // And remove old ones so we don't accumulate keyframes.
  auto it = keyframe_list_.lower_bound(seq_num - kMaxPacketAge);
  if (it != keyframe_list_.begin())
    keyframe_list_.erase(keyframe_list_.begin(), it);
​
  if (is_recovered) {
    recovered_list_.insert(seq_num);
​
    // Remove old ones so we don't accumulate recovered packets.
    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;
  }
  AddPacketsToNack(newest_seq_num_ + 1, seq_num);
  newest_seq_num_ = seq_num;
​
  // Are there any nacks that are waiting for this seq_num.
  std::vector<uint16_t> nack_batch = GetNackBatch(kSeqNumOnly);
  if (!nack_batch.empty()) {
    // This batch of NACKs is triggered externally; the initiator can
    // batch them with other feedback messages.
    nack_sender_->SendNack(nack_batch, /*buffering_allowed=*/true);
  }
​
  return 0;
}
​
  • 如果seq_num == newest_seq_num_,那说明是连续的包,不需要处理

  • 接下里判断,如果AheadOf(newest_seq_num_, seq_num),也就是收到的包序号比当前的最新的序号要小,那这里有两种情况

    • 因为包的乱序导致之前的包晚一点才到
    • 经过NACK后重传后的包到达

    通过nack_list_.find(seq_num)看看能否找到,如果能找到,则说明是重传的包,那么需要将这个包进行移除,反之则是乱序到达的包。

  • 判断包的连续性,如果当前包号不连续,则将中间断掉的包号加入到 nack 请求列表,并更新 newest_seq_num_

  • 一旦发现 nack_list 的大小已经超过 1000,那么就要根据关键帧序列号来调整其大小,也就是上面到的策略4

  • 最后,批量获取 nack_list 中的包序列号到数组 nack_batch 中,生成并发送 nack 包。

Process

该函数实现了基于时间周期(20ms)的 nack 发送模式,参考策略 2。具体的处理周期计算方法如下:

void DEPRECATED_NackModule::Process() {
  if (nack_sender_) {
    std::vector<uint16_t> nack_batch;
    {
      rtc::CritScope lock(&crit_);
      nack_batch = GetNackBatch(kTimeOnly);
    }
​
    if (!nack_batch.empty()) {
      // This batch of NACKs is triggered externally; there is no external
      // initiator who can batch them with other feedback messages.
      nack_sender_->SendNack(nack_batch, /*buffering_allowed=*/false);
    }
  }
​
  // Update the next_process_time_ms_ in intervals to achieve
  // the targeted frequency over time. Also add multiple intervals
  // in case of a skip in time as to not make uneccessary
  // calls to Process in order to catch up.
  int64_t now_ms = clock_->TimeInMilliseconds();
  if (next_process_time_ms_ == -1) {
    next_process_time_ms_ = now_ms + kProcessIntervalMs;
  } else {
    next_process_time_ms_ = next_process_time_ms_ + kProcessIntervalMs +
                            (now_ms - next_process_time_ms_) /
                                kProcessIntervalMs * kProcessIntervalMs;
  }
}

上面说到的策略,rtc内部并不是每隔 20 毫秒批量处理 nack_list,而是动态变化的,这里的算法主要是

int64_t now_ms = clock_->TimeInMilliseconds();
next_process_time_ms_ = next_process_time_ms_ + kProcessIntervalMs +
                            (now_ms - next_process_time_ms_) /
                                kProcessIntervalMs * kProcessIntervalMs;

主要是引入了now_ms根据当前处理的时间点进行叠加,这么做的原因是为了应对 cpu 繁忙时线程调度滞后的场景,追赶上正常的处理进度,这就是动态处理周期的意义所在。

GetNackBatch

std::vector<uint16_t> DEPRECATED_NackModule::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()) {
​
    bool delay_timed_out =
        now.ms() - it->second.created_at_time >= send_nack_delay_ms_;
    bool nack_on_rtt_passed =
        now.ms() - it->second.sent_at_time >= resend_delay.ms();
    bool nack_on_seq_num_passed =
        it->second.sent_at_time == -1 &&
        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;
      it->second.sent_at_time = now.ms();
      if (it->second.retries >= kMaxNackRetries) {
        RTC_LOG(LS_WARNING) << "Sequence number " << it->second.seq_num
                            << " removed from NACK list due to max retries.";
        it = nack_list_.erase(it);
      } else {
        ++it;
      }
      continue;
    }
    ++it;
  }
  return nack_batch;
}

该函数传入 nack 过滤选项参数,根据时间或者序列号批量获取 nack_list 中的包序列号,并返回存储了这些包号的数组 nack_batch。

  • 基于序列号的发送模式。consider_seq_num = true,且当前收到的最新包号已经等于或者超过该 nack_info 期望发送时的包号 send_at_seq_num。
  • 基于时间序列的发送模式。consider_timestamp = true,且当前时间距上一次发送已经超过一个 rtt 时间

满足条件后,将该 nack_info 中请求重传的包号加入到 nack_batch 数组,重传请求次数 +1,更新 nack 发送时间,如果大于kMaxNackRetries,那么则直接移除即可

文章比较长,大家可以针对源码来对比看下逻辑实现,下一章我们再分析发送端如何处理NACK模块。

感谢您的观看和支持~