【音视频】 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_timerecv_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(重传)配合,平衡延迟与可靠性。
相关推荐
大熊背16 小时前
(3dnr)多帧视频图像去噪 (二)
计算机视觉·音视频·isp·3dnr
Antonio91517 小时前
【音视频】VP8 与 VP9 技术详解及与 H.264 H.265 的对比
音视频·vp8·vp9
水印云17 小时前
视频转文字软件哪个免费好用?2025年5款实用工具实测,助力办公效率!
音视频
千里马学框架19 小时前
安卓15 audio新专题发布:安卓系统手机车机音频audio子系统深入实战开发专题
android·智能手机·音视频
余(18538162800)21 小时前
手机版碰一碰发视频源码搭建,技术实现与实操指南
智能手机·音视频
AirDroid_cn1 天前
苹果手机文本转音频,自行制作背诵素材
智能手机·音视频
忆萧1 天前
Nginx实现P2P视频通话
webrtc·p2p
Antonio9151 天前
【音视频】火山引擎实时、低延时拥塞控制算法的优化实践
音视频·火山引擎
A尘埃1 天前
FFmpeg音视频处理解决方案
ffmpeg·音视频