Skip to content
Published on

TCP輻輳制御を目で見る — CUBICとBBRv3、そしてブラウザで動くgVisorネットスタック

シェア
Authors

はじめに — ブラウザのタブで動く二つのTCPスタック

2026年7月28日、Hacker Newsに Simulating TCP loss and congestion in browser using Go/WASM というリンクが上がりました。開くと ccsimというページ が出てきますが、そこで動いているのはデモ用のアニメーションではありません。ソースリポジトリ の説明が正確です — gVisorのユーザー空間TCP/IPスタック(gvisor.dev/gvisor/pkg/tcpip)を 二つ、それぞれ送信者と受信者として立て、その間にトークンバケット方式のボトルネックリンクモデルを挟み込んだイベント駆動シミュレータを、まるごとWebAssemblyへビルドしてブラウザのワーカーで実行します。

作った側の 技術ブログ に動機が書かれています。彼らはトンネリング基盤でパケットを注入するのにgVisorのネットスタックを使っているのですが、共同創業者の不安定な自宅回線でプローブが死に続けました。損失のあるリンクでCUBICがうまくやれないという心証はあり、それを確かめようとしてBBRv3をnetstackの上に自分たちで実装し、検証ハーネスまで作ることになった、という流れです。シミュレータはその検証ハーネスの副産物です。

ブラウザのデモだと侮れない理由は、リポジトリの検証文書にあります。CUBICのウィンドウ成長曲線がRFC 9438の三次関数に C=0.410R²=0.9999 で合い、損失応答関数は規格値の0.68〜0.95倍の中に入り、REDのマーキング曲線は正確なランプ確率質量関数とカイ二乗検定で比較しています。そのうえネイティブビルドとWASMビルドの出力サンプルストリームが バイト単位で同一です。教材であり回帰テストでもある、というわけです。

この記事はこのシミュレータをフックにしますが、実際の価値はその下にあるアルゴリズムのほうに置きます。輻輳制御は30年以上同じ問題を解いてきましたし、画面で見たのこぎり形が実際のサーバーでどんな症状として現れるのかが、結局のところ私たちの知るべきことだからです。

骨格 — slow start、congestion avoidance、fast retransmit

TCPの送信者は二つの窓を同時に見ます。一つは受信者が広告した受信ウィンドウ(rwnd)で「受け取る側が耐えられる量」、もう一つが輻輳ウィンドウ(cwnd)で「ネットワークが耐えられると送信者が 推定した 量」です。実際に送り出せる量は二つのうち小さいほうです。輻輳制御アルゴリズムとは、結局のところcwndをいつどれだけ上げ下げするかについての規則の集まりです。

Slow start(スロースタート)は名前とは裏腹に指数的に速いものです。接続が始まるとcwndを初期ウィンドウ(最近のLinuxはRFC 6928に従って10 MSS)に置き、ACKが一つ来るたびにcwndを1 MSSずつ増やします。ACK一つが新しいセグメントを二つ解放するので、RTTごとにcwndが二倍になります。経路の容量をまったく知らない状態から、対数時間に近い速さでそこへ近づくための探索です。この段階はssthreshに達するか損失が検知されるまで続きます。

Congestion avoidance(輻輳回避)はその次です。もう容量の近くにいると見なして、増加速度をRTTあたりおよそ1 MSSへ下げます。損失に出会うとcwndを掛け算で減らします。このAIMD(additive increase, multiplicative decrease)構造がインターネットを崩壊から救った核心です — 複数のフローが同じボトルネックを共有するとき、AIMDは公平性のほうへ収束しますが、掛け算の増加はそうなりません。

Fast retransmit(高速再送)は、損失を再送タイムアウト(RTO)よりずっと早く捕まえるための仕掛けです。受信者は順序から外れたセグメントを受け取ると、最後に連続して受信した地点を繰り返しACKします。送信者が同じACKを三回重複で受け取ると、RTOを待たずにそのセグメントを即座に再送し、fast recoveryに入ってcwndを半分ほどへ下げたまま送り続けます。RTOを待っていたら最低でも数百msを捨てていた場面です。

ここで一つ触れておかなければなりません。上の段落は教科書のReno基準であって、いま実際にLinuxがやっている損失検知はRACK-TLP(RFC 8985)です。重複ACKの個数を数える代わりに、各セグメントの送信時刻とSACKで確認された到着順を比べ、「このセグメントより後に送ったものがすでに到着しており、再順序化ウィンドウも過ぎた」なら損失と判定します。重複ACK三つという規則が再順序化に弱く、テール損失(最後のセグメントが消えて重複ACK自体が生じない場合)に無力だったからです。ccsimもRACK/TLPを有効にして動かします。

CUBICが最適化しているもの

Linux・Windows・Appleのスタックの既定値である CUBICはRFC 9438 として2023年8月に標準化されました(RFC 8312を廃止)。名前のとおりウィンドウの成長を三次関数で定義します。

W_cubic(t) = C * (t - K)^3 + W_max

  t     : 最後の輻輳イベントからの経過時間(秒)
  W_max : 輻輳イベント直前のウィンドウサイズ
  C     : スケーリング定数 (既定 0.4)
  K     : cbrt(W_max * (1 - beta) / C),   beta = 0.7

この曲線の形が設計意図のすべてです。損失の直後は W_max の近くまで 凹型に速く 回復し、W_max に届くとほとんど平らになってその付近に長くとどまりながら探索し、何も起きなければその上へ 凸型に加速 して新しい容量を探しに行きます。減少係数はRenoの0.5ではなく0.7なので、損失一回で失う量も少なくて済みます。

何を最適化したのかというと、二つです。

第一に、高BDPリンクの利用率 です。RenoのRTTあたり1 MSSの増加は、帯域が大きく遅延が長い経路では絶望的に遅くなります。CUBICの増加は経過 時間 の関数なので、RTTに直接縛られていません。

第二に、まさにその理由から RTT公平性 です。Reno系ではRTTの短いフローが同じ時間のあいだにより多く増加して帯域を多く取ります。時間の関数であるCUBICは、この偏りがずっと小さくなります。

代わりにCUBICが諦めたものは明確です。CUBICの輻輳信号は依然としてパケット損失ひとつだけです。キューがどれだけ埋まっているか、遅延がどれだけ伸びたかは見ません。損失が出るまで押し上げ、損失が出たら減らします。この一文から、後に出てくる二つの症状がどちらも派生します。

BBRが違う見方をしているもの — 損失ではなく経路モデル

BBRは問いを変えます。「いつ損失が出るか」ではなく「この経路のボトルネック帯域と最小往復遅延はいくらか」を推定し続け、その積(BDP)の近くを保とうとします。ccsimの画面説明がこの対比をうまく圧縮しています — 1 BDPが最適点であり、CUBICは損失のほうへ越えながらその地点を見つけ、BBRは経路を推定してその地点の近くにとどまります

この最適点はKleinrockが1979年に整理したものでもあります。in-flightがBDPより少なければリンクが遊び、BDPより多ければ超過分はすべてボトルネックのキューに積み上がって遅延を伸ばすだけで、スループットは同じままです。問題は帯域と最小遅延を同時に測れないところにあります — 帯域の最大値を測るにはキューを作らなければならず、最小遅延を測るにはキューを空にしなければなりません。だからBBRは二つの値を 交互に 測ります。

ccsimが実装したBBRv3は draft-ietf-ccwg-bbr の状態機械に従います(現在06版、ステータスはExperimental)。リポジトリの文書に載っている定数まで移すとこうなります。

  • Startup — pacingゲイン710/256(約2.77)、cwndゲイン2.0。帯域の推定値がこれ以上伸びなくなるまで押し上げます。
  • Drain — pacingゲイン88/256(約0.34)。Startupで作ってしまったキューを空にします。
  • ProbeBW — DOWN(232/256)→ CRUISE(1.0)→ REFILL(1.0)→ UP(1.25)の循環。ほとんどの時間をここで過ごし、UP区間でだけ少し余計に押して帯域が増えたかどうかを確認します。
  • ProbeRTT — cwndゲイン0.5で約200ms。最低5秒間隔でスケジュールされ、キューを空にして min_rtt フィルタを更新します。min_rtt 自体は10秒の窓で保持されます。

ここにBBRv3がv1に対して追加したのが 損失とECNに対する明示的な応答境界 です。inflight_hiinflight_lobw_lo のような上限を置き、経路が実際に損失やCEマーキングを返してきたらモデルをそのまま押し通さずに引き下がります。BBRv1がCUBICと共存するとき攻撃的すぎると批判された部分への答えです。

そしてBBRを実際に使うなら必ず付いてくるのが pacing です。cwndだけ開けてバーストで送り出すとボトルネックのキューが瞬間的に破裂するので、推定した帯域に合わせてパケットを時間軸に均等にまき散らさなければなりません。Linuxでは fq qdiscやカーネル内部のpacingがこの仕事をします。ccsimはこれをCCの中ではなく送信統合レイヤーに置き、min(pacing_rate * 1ms, 64KB) サイズの量子単位で仮想クロックタイマーを通して送り出します。

エンジニアが実際に見る症状 三つ

ここまでが理論で、以下がチケットとして上がってくる姿です。ccsimリポジトリのシナリオ測定値をそのまま移すと、三つの症状が数字で見えます。

シナリオ条件結果
cubic-single100Mbps、損失なし96.5 Mbps、cwnd削減3回、RTO 0回
random-lossランダム損失1%CUBIC 3.0 Mbps 対 BBRv3 57 Mbps
bufferbloat深いtaildropバッファ、base RTT 30msCUBICの定常状態srtt 1,070ms 対 BBRv3 32ms
ecn-codelCoDel + ECNCEマーキング654回、ドロップ・再送0回、srttはbaseの2.1倍以下
rate-step帯域の階段状変化即座に24 Mbps到達、max-bwフィルタ2周期後に新しいBDPの1.07倍
fairness100Mbps一本のリンクにCUBICとBBRが共存リンク使用率96%、分配は約33%対67%

症状1 — 損失リンクでのスループット崩壊。1%のランダム損失でCUBICが3.0 Mbpsまで落ち込みます。100Mbpsのリンクで3%です。これはバグではなく損失ベース設計の直接的な帰結であり、Mathisの近似式がそのまま予測します。

throughput  ~  MSS / (RTT * sqrt(p))  * C

  MSS 1448B, RTT 40ms, p = 0.01, C ~ 1.22
  -> 1.22 * 1448 * 8 / (0.04 * 0.1)  ~  3.5 Mbps

式で損失率が平方根として入ってくるという点が重要です。損失が0.1%から1%へ十倍に増えると、スループットは3分の1ではなく約3.16分の1へ落ちます。そして、その損失が輻輳のせいなのか、無線干渉や回線品質のせいなのかをCUBICは区別できません。無線区間や長距離回線を通るフローが理由もなく遅い典型的な原因であり、BBRが57 Mbpsを出す理由でもあります — BBRは損失が出ても伝達率の推定値が保たれていればモデルを大きく揺さぶりません。

症状2 — bufferbloat。深いバッファの付いたボトルネックで、CUBICの定常状態のsrttが1,070msになります。経路の実際の往復遅延は30msです。1秒がまるごとキューの待ち時間です。ここでは損失はむしろほとんど出ません — バッファが深いのでドロップではなく遅延として吸収されるからです。だからCUBICは「何も問題ない」と判断して、その状態で安定します。同じ条件でBBRv3は32msを保ちます。base RTTが30msなので、キューが事実上空だという意味です。

症状3 — バッファを大きくするともっと悪くなる。上の二つの段落を合わせれば自動的に出てきます。損失ベースCCの定常状態のキュー占有量は バッファサイズが決めます。バッファを2倍にすれば定常状態の遅延もおよそ2倍になります。「パケットドロップが見えるからバッファを増やそう」という直感はドロップを遅延に変えるだけで、対話的トラフィックの立場からはもっと悪い取引です。ルーターのバッファが安くなるにつれてこの間違いがインターネット全域に広がったことが、bufferbloat問題の歴史です。

処方はバッファサイズではなくキュー規律です。表の ecn-codel の行がその絵です — CoDelがキュー遅延を見て先にCEをマーキングするので ドロップも再送も0回 なのに、srttはbaseの2.1倍以下に抑えられます。損失という信号を遅延信号で置き換えたわけで、Linuxなら fq_codelcake がこの仕事をします。コンシューマー向けルーターなら、こちらが事実上唯一の実用的な解法です。

最後の行の公平性の数字も実務的に意味があります。CUBICとBBRが同じリンクを共有すると33%対67%に分かれます。リンクは96%まで埋まっているので無駄はありませんが、均等でもありません。BBRv3がv1より穏やかになったとはいえ、サーバー群の一部だけをBBRに変えると残りのCUBICフローが損をするという意味です。切り替えは部分単位ではなくドメイン単位でやるほうがましです。

実際のホストでcwndと再送を見る方法

シミュレータがきれいに描いてくれる値は、Linuxホストでも全部見られます。計測コードは要りません。

# 現在の既定アルゴリズムと利用可能な一覧
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control

# BBRを使うにはモジュールとpacing qdiscが一緒に必要だ
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
tc qdisc show dev eth0        # fq または fq_codel かどうか確認

# ソケットごとの状態: cwnd, ssthresh, rtt, 再送, 伝達率, pacing rate が全部ここに出る
ss -tin state established
#   cubic wscale:7,7 rto:236 rtt:35.6/2.1 mss:1448 cwnd:73 ssthresh:52
#   bytes_sent:... retrans:0/14 delivery_rate:76.4Mbps busy:... pacing_rate:...

# 特定の宛先だけ
ss -tin dst 203.0.113.10

# ホスト全体の再送カウンタ (増分だけ)
nstat -az | grep -Ei 'TcpRetransSegs|TcpExtTCPLostRetransmit|TcpExtTCPTimeouts|TcpExtTCPFastRetrans'

ss -tin の出力で実務的にいちばん重要な三つの値はこうです。

  • cwnd — パケット単位です。cwnd * mssrtt で割ると、その瞬間の上限スループットが出ます。delivery_rate と比べれば、アプリケーションが埋め切れていないのか(app-limited)ネットワークが詰まらせているのかが分かれます。
  • retrans:X/Y — 前が現在未解決の再送、後ろが累積です。後ろの数字が bytes_sent に対して何パーセントかが、前節の損失率 p です。
  • rtt:A/B — Aが平滑化されたRTT、Bが変動(mdev)です。Aが経路の最小RTTよりはるかに大きく、再送はほとんどないなら、それがbufferbloatの署名です。

周期的に取ってcwndののこぎり形を目で見たいなら、これくらいで十分です。

# 200ms間隔でcwnd/rtt/retransだけ抜き出して時系列として残す
while :; do
  date +%s.%N | tr -d '\n'
  ss -tin dst 203.0.113.10 | tr '\n' ' ' \
    | grep -oE 'cwnd:[0-9]+|rtt:[0-9.]+|retrans:[0-9]+/[0-9]+' | tr '\n' ' '
  echo
  sleep 0.2
done

カーネルのイベントレベルまで降りるなら、tracepointがすでに用意されています。

# 輻輳状態の遷移と再送をイベントとして見る
sudo bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb { @retrans[comm] = count(); }
tracepoint:tcp:tcp_cong_state_set { @state[args->cong_state] = count(); }'

# 再現環境を作る: 損失1% + 遅延20ms + 浅いキュー
sudo tc qdisc replace dev eth0 root netem loss 1% delay 20ms limit 100
# bufferbloatの再現: キューをとても深く
sudo tc qdisc replace dev eth0 root netem delay 20ms limit 10000
# 処方の確認: AQMへ入れ替える
sudo tc qdisc replace dev eth0 root fq_codel

netem で損失を作っておいて同じファイルをCUBICとBBRでそれぞれ転送してみると、前節の表の3.0 Mbps対57 Mbpsがどんな種類の差なのかが30秒で体感できます。Linuxネットワークスタック内部の構造全般は Linuxネットワーキング内部 編に、TCP自体の動作は TCP深層分析 編に別途まとめてあります。

シミュレータが見せてくれないもの

教材としてのccsimの強みは明確です。実際のプロトコル実装を動かすので、SACKのスコアボード、RACKの再順序化判定、ECNエコーのようなものが近似ではなく実物であり、決定的(deterministic)なので同じシードで同じ結果が出ます。スライダーを動かすとシミュレーションが実行中のままパラメータが変わるので、帯域が急に落ちるとき二つのアルゴリズムがどれだけ違う反応をするかが一画面で見えます。

同時にシミュレーションの境界もはっきりしています。

  • フローが一つか二つです。実際のボトルネックには数千のフローがあり、そのほとんどは短くてslow startを抜けることすらできません。インターネットのトラフィックで定常状態の動作が占める割合は思ったより小さいのです。
  • ボトルネックが一つで固定です。実際の経路ではボトルネックが移動し、無線区間の容量は秒単位で揺れます。
  • ミドルボックスがありません。ECNビットを消す機器、SACKを壊すファイアウォール、TCPを代わりに終端してしまうプロキシは実際のインターネットではありふれています。BBRを有効にしたのに期待した効果が出ない原因のかなりの部分がここにあります。
  • CPUと割り込み処理がありません。GRO、TSO、NICのキュー、そしてどのコアがソフトIRQを処理するかが、実際には遅延分布を大きく変えます。

だからシミュレータから得るべきものは「うちの環境でBBRが何Mbps出る」ではなく、このアルゴリズムはどの信号に反応し、何には反応しないのか です。その感覚があると、ss -tin の出力一行から読み取れるものが確実に変わります。

おわりに — 輻輳制御はいまも推定の問題だ

まとめるとこうなります。

  • cwndはネットワーク容量に対する送信者の 推定値 です。アルゴリズムの違いは、何を証拠にしてその推定を更新するかの違いです。
  • CUBICは損失ひとつだけを証拠に使います。だから無線損失を輻輳と誤認し、深いバッファの前では証拠を受け取れずキューを満たしたまま安定します。
  • BBRは伝達率と最小RTTで経路モデルを立ててBDPの近くを狙います。BBRv3はそこに損失・ECNの応答境界を足して共存性を改善しましたが、CUBICと分け合うときの分配は依然として均等ではありません。
  • バッファを大きくするのは、ドロップを遅延に変える取引です。答えはキューのサイズではなく fq_codel のようなAQMです。
  • これらすべては ss -tinnstattc netem の三つの道具で自分のホスト上に再現し観測できます。

輻輳制御を長く覗いていないまま「遅い」というチケットを受け取ると、たいていは帯域を疑うことになります。実際にはcwndが損失率に閉じ込められているか、RTTがキューのせいで膨らんでいる場合のほうが多いのです。どちらの場合も帯域を増やしても何もよくなりません。

参考資料