【音视频】 WebRTC GCC 拥塞控制算法

参考文章:https://blog.csdn.net/weixin_38102771/article/details/127780907

WebRTC GCC 拥塞控制算法详解

一、GCC 概述

GCC(Google Congestion Control)是 Google 为实时音视频通信(RTC)设计的拥塞控制算法,是 WebRTC 框架默认的拥塞控制方案。其核心目标是在避免网络拥塞的前提下,最大化利用网络带宽,同时保证音视频传输的低延迟和低丢包率 ------ 这对实时场景(如视频会议、直播)至关重要。

1.1 GCC 的核心设计思路

传统 TCP 拥塞控制(如 Reno、CUBIC)依赖 "丢包即拥塞" 的判断逻辑,且重传机制会引入额外延迟,不适合实时音视频。GCC 则采用双指标融合的策略:

  • 延时梯度(Delay Gradient):提前感知拥塞(网络队列堆积会导致延时增加,早于丢包发生);
  • 丢包率(Packet Loss Rate):验证拥塞是否已发生(丢包是拥塞的直接结果)。

通过两种指标的结合,GCC 既能快速响应网络变化,又能避免单一指标的误判(如瞬时延时波动、偶发丢包)

1.2 GCC 的两个版本

GCC 经历了两次架构演进,核心差异在于带宽预估逻辑的部署位置:

版本 核心特点 反馈报文 适用场景
REMB-GCC 接收端计算延时梯度并预估带宽,发送端基于丢包率预估带宽,取两者最小值 RTCP REMB 早期 WebRTC 版本,简单场景
TCC-GCC 接收端仅反馈收包时序信息,发送端统一处理延时梯度 + 丢包率,集中式预估带宽 RTCP TCC 主流版本(替代 REMB-GCC),复杂网络

二、REMB-GCC 算法原理

REMB-GCC 是 GCC 的初代版本,采用 "接收端 + 发送端" 分工的架构,核心流程分为接收端延时梯度预估 和发送端丢包率预估两部分,最终发送端取两者最小值作为实际发送码率。

2.1 接收端:基于延时梯度的带宽预估

接收端通过分析数据包的 "发送 - 接收时序差",计算延时梯度,判断网络是否拥塞,并预估最大可用带宽,再通过 RTCP REMB 报文反馈给发送端。核心模块分为 5 个:

(1)Arrival-time Filter(到达时间滤波)

作用 :避免单包时序波动的干扰,基于 "分组" 计算稳定的时间差(而非单包)。

WebRTC 中,发送端会将 RTP 包按5ms 间隔分组发送(由 Pacer 模块控制),接收端同样按 "组" 统计时序信息,核心逻辑如下:

  • 分组依据 :通过 RTP 扩展字段 abs-send-time(24bit,精度 3.8us)获取包的发送时间,若某包的发送时间与当前组首包的发送时间差 >5ms,则归为新组。

  • 核心数据结构 :TimestampGroup(存储每组的关键信息):

字段 含义
first_timestamp 组内首包的 RTP 发送时间戳
first_arrival_ms 组内首包的接收时间(毫秒)
complete_time_ms 组内最后一包的接收时间(毫秒,代表 "组接收完成时间")
size 组内所有包的总大小(字节)
last_system_time_ms 组内最后一包的系统接收时间(用于校准时钟偏移)
  • 时间差计算:当新组完成接收后,计算当前组与上一组的:

    • 接收时间差:arrival_delta = current.complete_time_ms - prev.complete_time_ms
    • 发送时间差:send_delta = current.first_timestamp - prev.first_timestamp
    • 包大小差:size_delta = current.size - prev.size

这些差值将传入下一个模块,用于计算延时梯度。

下面是 WebRTC 计算延时梯度的代码:

cpp 复制代码
bool InterArrival::ComputeDeltas(uint32_t timestamp,
                                 int64_t arrival_time_ms,
                                 int64_t system_time_ms,
                                 size_t packet_size,
                                 uint32_t* timestamp_delta,
                                 int64_t* arrival_time_delta_ms,
                                 int* packet_size_delta) {
  assert(timestamp_delta != NULL);
  assert(arrival_time_delta_ms != NULL);
  assert(packet_size_delta != NULL);
  bool calculated_deltas = false;
  if (current_timestamp_group_.IsFirstPacket()) {
    // We don't have enough data to update the filter, so we store it until we
    // have two frames of data to process.
    current_timestamp_group_.timestamp = timestamp;
    current_timestamp_group_.first_timestamp = timestamp;
    current_timestamp_group_.first_arrival_ms = arrival_time_ms;
  } else if (!PacketInOrder(timestamp)) {
    return false;
  } else if (NewTimestampGroup(arrival_time_ms, timestamp)) {
    // First packet of a later frame, the previous frame sample is ready.
    if (prev_timestamp_group_.complete_time_ms >= 0) {
      *timestamp_delta =
          current_timestamp_group_.timestamp - prev_timestamp_group_.timestamp;
      *arrival_time_delta_ms = current_timestamp_group_.complete_time_ms -
                               prev_timestamp_group_.complete_time_ms;
      // Check system time differences to see if we have an unproportional jump
      // in arrival time. In that case reset the inter-arrival computations.
      int64_t system_time_delta_ms =
          current_timestamp_group_.last_system_time_ms -
          prev_timestamp_group_.last_system_time_ms;
      if (*arrival_time_delta_ms - system_time_delta_ms >=
          kArrivalTimeOffsetThresholdMs) {
        RTC_LOG(LS_WARNING)
            << "The arrival time clock offset has changed (diff = "
            << *arrival_time_delta_ms - system_time_delta_ms
            << " ms), resetting.";
        Reset();
        return false;
      }
      if (*arrival_time_delta_ms < 0) {
        // The group of packets has been reordered since receiving its local
        // arrival timestamp.
        ++num_consecutive_reordered_packets_;
        if (num_consecutive_reordered_packets_ >= kReorderedResetThreshold) {
          RTC_LOG(LS_WARNING)
              << "Packets are being reordered on the path from the "
                 "socket to the bandwidth estimator. Ignoring this "
                 "packet for bandwidth estimation, resetting.";
          Reset();
        }
        return false;
      } else {
        num_consecutive_reordered_packets_ = 0;
      }
      assert(*arrival_time_delta_ms >= 0);
      *packet_size_delta = static_cast<int>(current_timestamp_group_.size) -
                           static_cast<int>(prev_timestamp_group_.size);
      calculated_deltas = true;
    }
    prev_timestamp_group_ = current_timestamp_group_;
    // The new timestamp is now the current frame.
    current_timestamp_group_.first_timestamp = timestamp;
    current_timestamp_group_.timestamp = timestamp;
    current_timestamp_group_.first_arrival_ms = arrival_time_ms;
    current_timestamp_group_.size = 0;
  } else {
    current_timestamp_group_.timestamp =
        LatestTimestamp(current_timestamp_group_.timestamp, timestamp);
  }
  // Accumulate the frame size.
  current_timestamp_group_.size += packet_size;
  current_timestamp_group_.complete_time_ms = arrival_time_ms;
  current_timestamp_group_.last_system_time_ms = system_time_ms;
 
  return calculated_deltas;
}
(2)Overuse Estimator(过载估计器)

作用 :通过卡尔曼滤波 平滑延时梯度,减少网络噪声(如瞬时抖动)的影响。 核心公式(延时梯度定义):
( d ( t i ) = t i − t i − 1 − T i − T i − 1 ) (d(t_i) = t_i - t_{i-1} - T_i - T_{i-1}) (d(ti)=ti−ti−1−Ti−Ti−1)

  • (t_i):第 i 组的接收完成时间;(T_i):第 i 组的首包发送时间;
  • 理想网络中 (d(t_i) = 0);网络拥塞时,队列堆积导致 (t_i) 增大,(d(t_i) > 0);网络空闲时 (d(t_i) < 0)。

卡尔曼滤波的作用是对连续多组的 (d(t_i)) 进行平滑处理,输出更稳定的 "拥塞趋势值",避免单组波动导致误判。

OveruseEstimator::Update 函数用于估计延时梯度,它并不是直接使用上一次计算得到的延时梯度值,而是将延时梯度传入该函数,通过卡尔曼滤波算法后得到延时梯度值:

cpp 复制代码
void OveruseEstimator::Update(int64_t t_delta,
                              double ts_delta,
                              int size_delta,
                              BandwidthUsage current_hypothesis,
                              int64_t now_ms) {
    // 代码省略,详见 src/modules/remote_bitrate_estimator/overuse_estimator.cc
}
(3)Overuse Detector(过载检测器)

作用:根据平滑后的延时梯度,判断当前网络状态(3 种状态),判断依据是 "延时梯度与动态阈值的关系"。

网络状态 判断条件 含义
Overuse(拥塞) 1. 平滑后的延时梯度 > 动态阈值; 2. 该状态持续时间 > 阈值(如 100ms); 3. 延时梯度呈增长趋势 网络队列持续堆积,需立即降低码率
Underuse(空闲) 平滑后的延时梯度 < - 动态阈值 网络带宽未被充分利用,可维持或小幅提升码率
Normal(正常) 延时梯度在 - 动态阈值,动态阈值 之间 网络处于平衡状态,可缓慢提升码率以探测更大带宽

关键 :阈值并非固定值,而是通过 Adaptive Threshold(自适应阈值) 动态调整。

(4)Adaptive Threshold(自适应阈值)

作用:解决 "固定阈值太敏感 / 不敏感" 的问题,根据历史延时梯度动态调整阈值。 核心更新公式:

t h r e s h o l d ( t i ) = t h r e s h o l d ( t i − 1 ) + k ⋅ Δ t ⋅ ( ∣ d ( t i ) ∣ − t h r e s h o l d ( t i − 1 ) threshold(t_i) = threshold(t_{i-1}) + k \cdot \Delta t \cdot (|d(t_i)| - threshold(t_{i-1}) threshold(ti)=threshold(ti−1)+k⋅Δt⋅(∣d(ti)∣−threshold(ti−1)

  • Δ t \Delta t Δt:当前组与上一组的时间间隔(毫秒);
  • k k k:调整系数(动态变化):
    • 若 ∣ d ( t i ) ∣ < t h r e s h o l d ( t i − 1 ) ( N o r m a l 状态): k = 0.00018 |d(t_i)| < threshold(t_{i-1})(Normal 状态):k=0.00018 ∣d(ti)∣<threshold(ti−1)(Normal状态):k=0.00018(阈值缓慢减小,提高灵敏度);
    • 若 ∣ d ( t i ) ∣ ≥ t h r e s h o l d ( t i − 1 ) ( O v e r u s e / U n d e r u s e 状态): k = 0.01 |d(t_i)| \geq threshold(t_{i-1})(Overuse/Underuse 状态):k=0.01 ∣d(ti)∣≥threshold(ti−1)(Overuse/Underuse状态):k=0.01(阈值快速增大,避免频繁切换状态)。

通过该逻辑,阈值能自适应网络波动,平衡 "检测灵敏度" 和 "稳定性"。

(5)Remote Rate Controller(远端速率控制器)

作用 :根据 Overuse Detector 输出的网络状态,计算接收端可承受的最大带宽(REMB 值),调整策略如下:

网络状态 码率调整策略
Overuse 降低码率:新码率 = 过去 500ms 内 "已确认接收带宽(acked_bitrate)" × 0.85
Normal 提升码率:新码率 = 当前码率 × 1.08(缓慢提升,避免突然拥塞)
Underuse 维持码率:不调整(避免盲目提升导致后续拥塞)

acked_bitrate:接收端通过 RTCP 反馈的 "已成功接收的数据包总大小 / 时间" 计算,反映实际可用带宽

WebRTC 对应的 Rate Controller 调整最大码率的代码如下

cpp 复制代码
void AimdRateControl::ChangeBitrate(const RateControlInput& input,
                                    Timestamp at_time) {
  absl::optional<DataRate> new_bitrate;
  DataRate estimated_throughput =
      input.estimated_throughput.value_or(latest_estimated_throughput_);
  if (input.estimated_throughput)
    latest_estimated_throughput_ = *input.estimated_throughput;
 
  // An over-use should always trigger us to reduce the bitrate, even though
  // we have not yet established our first estimate. By acting on the over-use,
  // we will end up with a valid estimate.
  if (!bitrate_is_initialized_ &&
      input.bw_state != BandwidthUsage::kBwOverusing)
    return;
 
  ChangeState(input, at_time);
 
  // We limit the new bitrate based on the troughput to avoid unlimited bitrate
  // increases. We allow a bit more lag at very low rates to not too easily get
  // stuck if the encoder produces uneven outputs.
  const DataRate troughput_based_limit =
      1.5 * estimated_throughput + DataRate::KilobitsPerSec(10);
 
  switch (rate_control_state_) {
    case kRcHold:
      break;
 
    case kRcIncrease:
      if (estimated_throughput > link_capacity_.UpperBound())
        link_capacity_.Reset();
 
      // Do not increase the delay based estimate in alr since the estimator
      // will not be able to get transport feedback necessary to detect if
      // the new estimate is correct.
      // If we have previously increased above the limit (for instance due to
      // probing), we don't allow further changes.
      if (current_bitrate_ < troughput_based_limit &&
          !(send_side_ && in_alr_ && no_bitrate_increase_in_alr_)) {
        DataRate increased_bitrate = DataRate::MinusInfinity();
        if (link_capacity_.has_estimate()) {
          // The link_capacity estimate is reset if the measured throughput
          // is too far from the estimate. We can therefore assume that our
          // target rate is reasonably close to link capacity and use additive
          // increase.
          DataRate additive_increase =
              AdditiveRateIncrease(at_time, time_last_bitrate_change_);
          increased_bitrate = current_bitrate_ + additive_increase;
        } else {
          // If we don't have an estimate of the link capacity, use faster ramp
          // up to discover the capacity.
          DataRate multiplicative_increase = MultiplicativeRateIncrease(
              at_time, time_last_bitrate_change_, current_bitrate_);
          increased_bitrate = current_bitrate_ + multiplicative_increase;
        }
        new_bitrate = std::min(increased_bitrate, troughput_based_limit);
      }
 
      time_last_bitrate_change_ = at_time;
      break;
 
    case kRcDecrease: {
      DataRate decreased_bitrate = DataRate::PlusInfinity();
 
      // Set bit rate to something slightly lower than the measured throughput
      // to get rid of any self-induced delay.
      decreased_bitrate = estimated_throughput * beta_;
      if (decreased_bitrate > current_bitrate_ && !link_capacity_fix_) {
        // TODO(terelius): The link_capacity estimate may be based on old
        // throughput measurements. Relying on them may lead to unnecessary
        // BWE drops.
        if (link_capacity_.has_estimate()) {
          decreased_bitrate = beta_ * link_capacity_.estimate();
        }
      }
      if (estimate_bounded_backoff_ && network_estimate_) {
        decreased_bitrate = std::max(
            decreased_bitrate, network_estimate_->link_capacity_lower * beta_);
      }
 
      // Avoid increasing the rate when over-using.
      if (decreased_bitrate < current_bitrate_) {
        new_bitrate = decreased_bitrate;
      }
 
      if (bitrate_is_initialized_ && estimated_throughput < current_bitrate_) {
        if (!new_bitrate.has_value()) {
          last_decrease_ = DataRate::Zero();
        } else {
          last_decrease_ = current_bitrate_ - *new_bitrate;
        }
      }
      if (estimated_throughput < link_capacity_.LowerBound()) {
        // The current throughput is far from the estimated link capacity. Clear
        // the estimate to allow an immediate update in OnOveruseDetected.
        link_capacity_.Reset();
      }
 
      bitrate_is_initialized_ = true;
      link_capacity_.OnOveruseDetected(estimated_throughput);
      // Stay on hold until the pipes are cleared.
      rate_control_state_ = kRcHold;
      time_last_bitrate_change_ = at_time;
      time_last_bitrate_decrease_ = at_time;
      break;
    }
    default:
      assert(false);
  }
 
  current_bitrate_ = ClampBitrate(new_bitrate.value_or(current_bitrate_));
}
(6)Remb Processing(REMB 报文处理)

接收端计算出最大带宽后,通过 RTCP REMB 报文 反馈给发送端,核心细节:

  • 报文类型:RTCP 扩展报文,PT(Payload Type)=206,FMT(Format)=15;
  • 关键字段 :
    • Unique Identifier:固定为 0x52454D42(即 "REMB" 的 ASCII 码);
    • BR Exp + BR Mantissa:共同表示最大带宽(单位:bps),计算公式:带宽 = Mantissa × 2^Exp;
    • SSRC feedback:需要限制码率的发送端 SSRC 列表(支持多流场景);
  • 发送频率:默认每 200ms 发送一次;若检测到新带宽 < 上一次的 97%(拥塞加剧),则立即发送。

具体报文格式如下:

WebRTC 中 RTCP REMB 报文一般是每 200ms 反馈一次,但是当检测到可用带宽小于上次预估的 97% 时则会立刻反馈

2.2 发送端:基于丢包率的带宽预估

发送端除了接收 REMB 反馈的带宽,还会独立基于丢包率 预估带宽,最终取两者的最小值作为实际发送码率(双重保险)。

(1)丢包率获取

发送端通过 RTCP RR 报文 (Receiver Report)的 fraction lost 字段获取丢包率:

  • fraction lost:8bit 无符号数,表示 "从上一次 RR 到本次 RR 期间,丢失的数据包占总发送包的比例";
  • 计算方式:丢包率 = fraction lost / 256 × 100%(精度约 0.39%)。

RR报文格式如下:

(2)码率调整策略

根据丢包率判断网络状态,调整发送码率:

丢包率范围 网络状态 码率调整策略
>10% 严重拥塞 大幅降码率:新码率 = 当前码率 × 0.5(快速减少发送量,缓解拥塞)
2% ~ 10% 轻度拥塞 小幅降码率:新码率 = 当前码率 × 0.85(缓慢调整,平衡带宽与稳定性)
<2% 网络空闲 提升码率:新码率 = 当前码率 × 1.1(探测更大带宽,避免浪费)
对于目标码率的调整方式,WebRTC 处理代码如下所示
(3)最终码率确定

发送端将 "接收端 REMB 带宽" 与 "本地丢包率预估带宽" 比较,取较小值作为最终发送码率,并同步给:

  • Encoder(编码器):调整视频分辨率、帧率或码率因子(如 H.264 的 CRF);
  • Pacer(发送 pacing 模块):控制数据包的发送节奏,避免突发发送导致队列堆积;
  • FEC(前向纠错):根据码率调整 FEC 冗余度,提升抗丢包能力。

三、TCC-GCC 算法原理(主流版本)

REMB-GCC 存在明显缺陷:接收端需承担带宽预估计算,且 REMB 报文仅反馈 "最大带宽",缺乏精细化时序信息。因此 Google 推出 TCC-GCC(Transport-wide Congestion Control),作为当前 WebRTC 的默认方案。

3.1 TCC-GCC 与 REMB-GCC 的核心差异

对比维度 REMB-GCC TCC-GCC
带宽预估位置 接收端(延时梯度)+ 发送端(丢包率) 仅发送端(统一处理延时梯度 + 丢包率)
接收端反馈内容 仅 "最大带宽(REMB 值)" 详细收包时序(TCC 报文:包的发送 / 接收时间)
反馈报文 RTCP REMB RTCP TCC(Transport-wide CC)
计算复杂度 接收端 / 发送端均有复杂度 接收端无复杂度,发送端集中计算
精度 较低(依赖接收端预估,信息有限) 较高(发送端掌握全量时序,可做更精细判断)

3.2 TCC-GCC 的核心流程

  1. 发送端打标 :为每个 RTP 包分配一个全局唯一的 Transport Sequence Number(TSN) ,并记录包的发送时间(send_time);
  2. 接收端反馈 :接收端按 TSN 顺序统计收包情况,生成 RTCP TCC 报文 ,包含:
    • 每个 TSN 的接收时间(recv_time);
    • 丢包标记(哪些 TSN 未收到);
  3. 发送端计算 :发送端根据 TCC 报文的 send_time 和 recv_time,计算延时梯度(逻辑与 REMB-GCC 一致),同时结合丢包率,统一预估带宽;
  4. 码率调整:发送端直接根据预估带宽调整 Encoder、Pacer 等模块,无需与接收端带宽取最小值。

3.3 TCC-GCC 的优势

  • 减少接收端负担:尤其适合弱终端(如手机、IoT 设备),接收端仅需统计时序,无需复杂计算;
  • 更高精度:发送端掌握全量包的发送 / 接收时序,可更精准判断拥塞趋势(如区分 "网络抖动" 和 "持续拥塞");
  • 支持多流统一控制:同一 Transport 下的多 RTP 流(如视频 + 音频)可共享 TCC 反馈,实现全局拥塞控制。

四、GCC 的应用与优势

4.1 适用场景

GCC 专为实时音视频设计,核心适用场景包括:

  • 视频会议(如 Zoom、Teams、WebRTC 会议系统);
  • 实时直播(如游戏直播、互动直播);
  • 低延迟互动场景(如在线教育、远程控制)。

4.2 核心优势

  1. 低延迟优先:通过延时梯度提前感知拥塞,避免丢包(丢包会导致重传,增加延迟);
  2. 带宽利用率高:Normal 状态下缓慢提升码率,最大化利用空闲带宽;
  3. 自适应网络:动态阈值和双指标融合,适应复杂网络(如 4G/5G、WiFi、公网);
  4. 与实时场景适配:不依赖 TCP 重传,通过 FEC/RTX(重传)配合,平衡延迟与可靠性。
相关推荐
微帧Visionular36 分钟前
AI视频单帧清晰,播放时却闪烁,原因是什么?
人工智能·深度学习·音视频
辰辰观影2 小时前
Windows网盘视频播放器推荐:无广告4K HDR怎么选
音视频
2601_966827454 小时前
电视端云盘在线播放怎么选?支持4K HDR的应用推荐
音视频
开开心心就好5 小时前
音频降噪剪辑工具,音量裁剪一键处理
运维·服务器·前端·spring·ocr·音视频·1024程序员节
AI视频工具指南9 小时前
AI视频的声音和画面怎么配合?先分清谁在传递信息
人工智能·aigc·音视频
辰辰观影11 小时前
无广告播放器推荐来啦
音视频
TrueDei11 小时前
GStreamer 使用 valve 分别控制音视频流的暂停与恢复
音视频
一条破秋裤12 小时前
基于 Hi3516CV610 的立定跳远视频测量系统调研与实施方案
arm开发·音视频
2601_9668274512 小时前
视频里的特效字幕怎么翻译?外挂字幕与AI字幕一键处理教程
音视频
八月的雨季 最後的冰吻13 小时前
音视频面试--延迟优化
面试·职场和发展·音视频