深入浅出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,需要根据重传策略来决定,判断逻辑比较复杂,这里暂不分析。

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

RtpPacketHistory 中缓存了很多报文,挑选哪些报文做 padding 报文,支持三种 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 控制核心,调用流程如下图所示。

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。

// 队列中首尾报文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 请求,有不同筛选条件:

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 请求项。

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 请求列表,直接发送关键帧请求。

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 请求项。

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 对细节的掌控和重视。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/382780.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

vue学习笔记(十一)——开发心得(axios的封装、promise细节、vue-router开发中的使用)

1. axios的网络请求的封装 1.1 为什么要封装api? 代码分层&#xff0c;便于以后的修改&#xff0c;无需触碰逻辑页面 目标&#xff1a; 网络请求&#xff0c;不散落在各个逻辑页面里&#xff0c;封装起来方便以后修改 1.2 封装api步骤 ① 在项目 src 下新建目录 utlis &am…

C++——初识模板

前言 模板是C中的重大板块&#xff0c;是使C真正超越C语言的工具&#xff0c;在C模板没有设计出来之前其实C是没有那么被行业和社会所认可的&#xff0c;本节我们将初步了解C中的模板&#xff08;仅作大致讲解&#xff0c;具体的细枝末节将会再过几节讲解&#xff09;&#xf…

Qt多语言功能实现

本文介绍Qt多语言功能实现。 应用程序多语言支持是常用功能&#xff0c;比如产品需要出口到不同语种的国家。采用Qt的多语言支持工具可以方便实现应用程序的多语言功能。本文以中英文语言切换为例&#xff0c;简要介绍Qt的多语言功能实现。 1.界面设计 界面设计需要考虑使用…

【数据分享】2013-2022年我国省市县三级的逐日SO2数据(excel\shp格式\免费获取)

空气质量数据是在我们日常研究中经常使用的数据&#xff01;之前我们给大家分享了2000——2022年的省市县三级的逐日PM2.5数据和2013-2022年的省市县三级的逐日CO数据&#xff08;均可查看之前的文章获悉详情&#xff09;&#xff01; 本次我们分享的是我国2013——2022年的省…

数据隐私保护与区块链技术的结合:新兴趋势分析

在当今数字化时代&#xff0c;数据隐私保护成为了一个备受关注的重要话题。随着个人数据的不断生成和流通&#xff0c;如何有效保护用户的隐私成为了技术创新的一个重要方向。区块链技术作为一种去中心化、安全性高且可追溯的技术手段&#xff0c;正在逐渐成为解决数据隐私保护…

golang 基础 泛型编程

&#xff08;一&#xff09; 示例1 package _caseimport "fmt"// 定义用户类型的结构体 type user struct {ID int64Name stringAge uint8 }// 定义地址类型的结构体 type address struct {ID intProvince stringCity string }// 集合转列表函数&#…

杰发科技Bootloader(2)—— 基于7840的Keil配置地址

序 在7840的sample代码里面有一个简单的Boot跳转APP的示例 PFlash地址从0开始 DFlash的地址从1000000开始 Boot解析 他的boot地址配置为0 Boot的代码主要是这几行&#xff0c;主要作用就是Flash的跳转 int main(void) {SystemClock_Config();InitDebug();printf("demo…

Leetcode 721.账户合并(hash+dfs)☆

思路&#xff1a; 最核心的地方在于如何合并&#xff1f;这里是通过具有相同的email进行账户的合并&#xff0c;这个相同的email类似于图中的共同节点将两个账户连接起来&#xff0c;所以将原来 账户名 -> 邮件1 邮件2.。。变成hash 邮件1 ->账户id1&#xff0c;账户id2…

ModelArts中sinh算子的开发

一、环境配置 1、创建notebook并连接 使用ModelArts新建一个notebook,我这里镜像选择第一个,里面含有cann和Ascend910处理器,我这里环境只能使用ssh连接,创建一个密钥对,保存到C盘中的user/Administrator/目录下。 在网页中选择使用vscode接入,等待vscode打开后,选择密…

【数据结构初阶】一篇文章带你超深度理解【单链表】

hi &#xff01; 目录 前言&#xff1a; 1、链表的概念和结构 2、单链表&#xff08;Single List&#xff0c;简写SList&#xff09;的实现 2.1 定义链表&#xff08;结点&#xff09;的结构 2.2 创建一个链表 2.3 打印链表 2.4 尾插 2.5 头插 2.6 尾删 2.7 头…

PT2262-IR

PT2262是一款很古老的编码芯片&#xff0c;其兼容型号有&#xff1a;SC2262&#xff0c;AD2262&#xff0c;SC2260(需改变匹配电阻)等。 依据其datasheet&#xff0c;PT2262射频模式工作原理: CODE BITS A Code Bit is the basic component of the encoded waveform, and ca…

Docker核心技术:Docker原理之Cgroups

云原生学习路线导航页&#xff08;持续更新中&#xff09; 本文是 Docker核心技术 系列文章&#xff1a;Docker原理之Cgroups&#xff0c;其他文章快捷链接如下&#xff1a; 应用架构演进容器技术要解决哪些问题Docker的基本使用Docker是如何实现的 Docker核心技术&#xff1a;…

Maven学习——Maven的下载、安装与配置(详细攻略!)

目录 前言 1.下载与安装 2.配置Maven的环境变量 3.配置Maven的本地仓库 4. 配置Maven的镜像远程仓库 前言 我在之前写了一篇博客&#xff0c;是介绍Maven的基本概念和下载安装&#xff0c;但是由于篇幅过长&#xff0c;Maven的下载与安装写的并不详细&#x1f436;&#x…

Windows系统设置暂停更新,暂停时间可达3000天,“永久”暂停更新,亲测有效

好多小伙伴被Windows系统的更新搞得很烦&#xff0c;经常在使用中自己下载更新包&#xff0c;占用网路资源&#xff0c;过段时间就要更新&#xff0c;特别讨厌 今天教你一招&#xff0c;可以暂停更新长达3000天&#xff0c;亲测有效 1、打开系统CMD命令执行窗口&#xff0c;输…

Ideal窗口中左右侧栏消失了

不知道大家在工作过程中有没有遇到过此类问题&#xff0c;不论是Maven项目还是Gradle项目&#xff0c;突然发现Ideal窗口右侧图标丢失了&#xff0c;同事今天突然说大象图标不见了&#xff0c;不知道怎样刷新gradle。 不要慌张&#xff0c;下面提供一些解决思路&#xff1a; 1…

超声波俱乐部:AI应用大爆发前夜,场景、闭环与LLM进化

7月13日&#xff0c;第十九期超声波俱乐部内部分享会在北京望京举行&#xff0c;本期的主题是&#xff1a;AI应用大爆发前夜&#xff0c;场景、闭环与LLM进化。 到场的嘉宾有&#xff1a;超声波创始人杨子超&#xff0c;超声波联合创始人、和牛商业创始人刘思雨&#xff0c;豆…

硅纪元视角 | 语音克隆突破:微软VALL-E 2,Deepfake新纪元!

在数字化浪潮的推动下&#xff0c;人工智能&#xff08;AI&#xff09;正成为塑造未来的关键力量。硅纪元视角栏目紧跟AI科技的最新发展&#xff0c;捕捉行业动态&#xff1b;提供深入的新闻解读&#xff0c;助您洞悉技术背后的逻辑&#xff1b;汇聚行业专家的见解&#xff0c;…

IP协议和路由转发

文章目录 IP协议IP报头网段划分特殊的IP私有IP和公有IP IP分片 路由 IP协议 IP协议提供了一种能力&#xff0c;将数据报从A主机送到B主机&#xff0c;TCP可以保证可靠性&#xff0c;所以TCP/IP协议可以将数据可靠的从A主机送到B主机。 IP报头 4位版本号(version): 指定IP协议…

Java 面试 | Redis

目录 1. 在项目中缓存是如何使用的&#xff1f;2. 为啥在项目中要用缓存&#xff1f;3. 缓存如果使用不当会造成什么后果&#xff1f;4. redis 和 memcached 有什么区别&#xff1f;5. redis 的线程模型是什么&#xff1f;6. 为什么单线程的 redis 比多线程的 memcached 效率要…

android13禁用某个usb设备

总纲 android13 rom 开发总纲说明 目录 1.前言 2.触摸设备查看 3.功能修改 3.1 禁用usb触摸 3.2 禁用usb键盘 3.3 禁用usb遥感 4.查看生效与否 5.彩蛋 1.前言 用户想要禁止使用某些usb设备,需要系统不能使用相关的usb设备,例如usb触摸屏,usb键盘,usb遥感等等usb…