深入浅出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.mzph.cn/news/873529.shtml

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

相关文章

Open3D 非线性最小二乘法拟合空间球

目录 一、概述 1.1原理 1.2实现步骤 二、代码实现 2.1关键代码 2.1.1定义残差函数 2.1.2拟合球面 2.2完整代码 三、实现效果 3.1原始点云 3.2拟合后点云 3.3结果数据 前期试读&#xff0c;后续会将博客加入下列链接的专栏&#xff0c;欢迎订阅 Open3D点云算法与点…

spark 动态资源分配dynamicAllocation

动态资源分配&#xff0c;主要是spark在运行中可以相对合理的分配资源。 初始申请的资源远超实际需要&#xff0c;减少executor初始申请的资源比实际需要少很多&#xff0c;增多executorSpark运行多个job&#xff0c;这些job所需资源有的多有的少&#xff0c;动态调整executor…

Automation Anywhere推出新一代AI+自动化企业系统,助力企业实现10倍商业增长

RPA厂商纷纷进军AI Agent ( AI 代理)领域&#xff0c;陆续推出创新产品。最近&#xff0c;Automation Anywhere宣布推出其新的AI 自动化企业系统&#xff0c;该系统结合AI和自动化技术&#xff0c;以实现指数级的业务成果。 在Imagine 2024大会上首次亮相的这款新产品&#xf…

前端实现视频播放添加水印

一、效果如下 二、代码 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Document</title> </head> <body><style>.container {position: relative;}.base {width: 300px;hei…

鸿蒙开发StableDiffusion绘画应用

Stable Diffusion AI绘画 基于鸿蒙开发的Stable Diffusion应用。 Stable Diffusion Server后端代码 Stable Diffusion 鸿蒙应用代码 AI绘画 ​ 使用Axios发送post网络请求访问AI绘画服务器 api &#xff0c;支持生成图片保存到手机相册。后端服务是基于flaskStable Diffusion …

ACM中国图灵大会专题 | 图灵奖得主Manuel Blum教授与仓颉团队交流 | 华为论坛:面向全场景应用编程语言精彩回顾

ACM 中国图灵大会&#xff08;ACM Turing Award Celebration Conference TURC 2024&#xff09;于2024年7月5日至7日在长沙举行。本届大会由ACM主办&#xff0c;in cooperation with CCF&#xff0c;互联网之父Vinton Cerf、中国计算机学会前理事长梅宏院士和廖湘科院士担任学术…

移动端如何离线使用GPT

在移动端离线使用GPT&#xff0c;只需要一个app&#xff1a;H2O AI Personal GPT 是H2OAI上架的一款app&#xff0c;可离线使用&#xff0c;注重数据隐私&#xff0c;所有数据都只存储在本地。对H2OAI感兴趣的伙伴&#xff0c;可移步&#xff1a;https://h2o.ai 该app支持的模…

Intel和AMD用户再等等!微软确认Win11 24H2年底前登陆

微软近日确认&#xff0c;Windows 11 24H2版本将于2024年底前正式登陆使用英特尔和AMD处理器的PC。 根据微软介绍&#xff0c;Windows 11 24H2将作为传统功能更新&#xff0c;将在今年晚些时候提供给所有设备。 此前&#xff0c;微软已向搭载骁龙X Plus和X Elite系列处理器的Co…

作为爬虫工程师,在封装API时如何做得更好

在数据驱动的时代&#xff0c;爬虫工程师的角色日益重要。他们不仅是数据的收集者&#xff0c;更是数据的桥梁构建者&#xff0c;通过编写高效、稳定的爬虫程序&#xff0c;将互联网上的海量信息转化为有价值的数据集。而在这一过程中&#xff0c;API&#xff08;应用程序接口&…

深度剖析机构号矩阵系统:如何根据业务需求做出明智选择

在数字化营销的浪潮中&#xff0c;短视频平台如抖音、快手等已成为品牌传播和用户互动的重要渠道。为了更高效地管理这些平台的账号&#xff0c;机构号矩阵系统应运而生。本文将深度剖析机构号矩阵系统&#xff0c;并探讨如何根据业务需求做出明智的选择。 机构号矩阵系统概述…

网络爬虫入门(学习笔记)

爬取网页源代码 抓取百度首页的HTML源代码&#xff0c;并将其保存到一个名为baidu.html的文件中。打开这个文件&#xff0c;可以看到一个和百度首页一模一样的页面。 from urllib.request import urlopen# 发送请求并获取响应 response urlopen("http://www.baidu.com&q…

jenkins+gitlab+harbor+maven自动化容器部署

一、gitlab安装配置 1.1、安装 由于比较懒啊&#xff01;这里就直接使用docker安装了啊&#xff01; 没事先更新一个yum源&#xff1a;yum update -y 整一个gitlab镜像&#xff1a;docker pull gitlab/gitlab-ce 运行一个gitlab容器&#xff1a;docker run -d -p 8443:443 -p…

函数定义、合约与面向对象(以太坊solidity合约)

函数定义、合约与面向对象&#xff08;以太坊solidity合约&#xff09; 1-函数定义、构造与多态2-事件日志3-面向对象特征 1-函数定义、构造与多态 创建合约就是创建类&#xff0c;部署合约就是实例化 合约的方法还支持多态 还能使用第三方的库进行开发 整个合约部署后&…

【第4章】Spring Cloud之Nacos单机模式支持mysql

文章目录 前言一、初始化1. 初始化数据库2. 修改配置文件 二、效果1. 重新启动2. 新增用户 总结 前言 在0.7版本之前&#xff0c;在单机模式时nacos使用嵌入式数据库实现数据的存储&#xff0c;不方便观察数据存储的基本情况。0.7版本增加了支持mysql数据源能力&#xff0c;具…

【devops】gitlab 实现cicd 实践

本站以分享各种运维经验和运维所需要的技能为主 《python零基础入门》&#xff1a;python零基础入门学习 《python运维脚本》&#xff1a; python运维脚本实践 《shell》&#xff1a;shell学习 《terraform》持续更新中&#xff1a;terraform_Aws学习零基础入门到最佳实战 《k8…

HarmonyOS页面跳转和页面间传值

IndexPage 定义一个参数类 export class RouterParams {src:stringconstructor(str:string) {this.src str} }点击按钮后&#xff0c;将创建的RouterParams 对象传递到第二页 Button($r(app.string.next)).fontSize(16).width(300).height(50).backgroundColor($r(app.color.…

Android 视频音量图标

attrs.xml <?xml version"1.0" encoding"utf-8"?> <resources><!--图标颜色--><attr name"ijkSolid" format"color|reference" /><!--喇叭底座宽度--><attr name"ijkCornerWidth" form…

WINUI或WPF灵活使用样式、控件模板、自定义控件、用户控件

在WINUI与WPF 中&#xff0c;控件模板&#xff08;ControlTemplate&#xff09;、样式&#xff08;Style&#xff09;、自定义控件&#xff08;CustomControl&#xff09;和用户控件&#xff08;UserControl&#xff09;都是构建复杂和灵活用户界面的重要工具&#xff0c;但它们…

【electron6】浏览器实时播放PCM数据

pcm介绍&#xff1a;PCM&#xff08;Puls Code Modulation&#xff09;全称脉码调制录音&#xff0c;PCM录音就是将声音的模拟信号表示成0,1标识的数字信号&#xff0c;未经任何编码和压缩处理&#xff0c;所以可以认为PCM是未经压缩的音频原始格式。PCM格式文件中不包含头部信…

ForCloud全栈安全体验,一站式云安全托管试用 开启全能高效攻防

对于正处于业务快速发展阶段的企业&#xff0c;特别是大型央国企而言&#xff0c;日常的安全部署和运营管理往往横跨多家子公司&#xff0c;所面临的挑战不言而喻。尤其是在面对当前常态化的大型攻防演练任务时&#xff0c;难度更是呈“几何级数”上升&#xff1a; 合规难 众…