CUBIC,RFC 9438 標準化,是 Linux 的預設擁塞控制器,因此它決定了網際網路上大多數 TCP 和 QUIC 連線如何探測可用頻寬、在偵測到封包遺失時如何縮減,以及之後如何恢復。

在 Cloudflare,我們開源的 QUIC 實作 quiche,將 CUBIC 作為其預設擁塞控制器,這意味著這段程式碼對我們服務的流量有顯著的影響。

在這篇文章中,我們將講述一個 bug,其中 CUBIC 的擁塞窗口(cwnd)會永久卡在其最小值,並且永遠無法從擁塞崩潰事件中恢復。

這個故事始於一項 Linux 核心的變更,旨在讓 CUBIC 符合 RFC 9438 §4.2-12 中描述的應用程式限制排除(app-limited exclusion)—— 這是 TCP 中一個真實問題的修復,但當它被移植到我們的 QUIC 實作時,卻在 quiche 中暴露了意想不到的行為。

這個故事有一個圓滿的結局:一個優雅的(接近)單行修復,打破了這個循環。

在我們深入探討核心問題之前,快速回顧一下擁塞控制演算法(CCAs)可能會有助於鋪墊。

CCA 的核心旋鈕是擁塞窗口(cwnd):這是發送端對任何時刻可以處於「飛行中」(已發送但尚未確認)的位元組數量的上限。

較大的 cwnd 讓發送端在每個往返時間內可以推送更多數據;較小的 cwnd 則會限制它。

所有基於遺失的 CCA,包括 CUBIC,最終都是一種策略,用於在網路看起來健康時如何增加 cwnd,以及在不健康時如何縮減它。

屬於 CUBIC 的基於遺失的演算法家族,其運作基於一個基本假設:(1) 如果沒有封包遺失,則增加發送速率(即增加頻寬利用率);(2) 如果有遺失,基於遺失的演算法假設網路容量已被超出,發送端必須縮減(即降低頻寬利用率)。

這個邏輯建立在幾個近年來被重新審視的假設之上。然而,我們將把這個討論留待以後。

我們的調查始於對我們入口代理整合測試管道中意外失敗的報告。

這種不穩定的行為出現在 CUBIC 在連線早期經歷嚴重遺失的場景下進行評估的測試中。

擁塞崩潰後的恢復是一個不常見的狀態,但這正是擁塞控制器存在的目的。

大多數擁塞控制測試都在測試演算法的穩定狀態和增長階段;很少有測試會探測 cwnd 的最小值,在連線受到嚴重打擊之後會發生什麼。

這個狀態空間角落的 bug 在吞吐量儀表板中是看不見的,靜態審查也無法偵測到,只有當你刻意將 CCA 驅動到其中並觀察它是否能爬出來時才會顯現——這正是這個測試所做的。

模擬的測試設置包含以下細節:

預期的行為很直接:CUBIC 在遺失階段會受到一些衝擊,減少其擁塞窗口,一旦遺失停止,就會穩定地 ramp up 並在寬限期內完成下載。

相反,我們在多次 100 次運行測試中觀察到,大約 60% 的測試無法在寬鬆的 10 秒超時內完成下載。

我們透過封包遺失事件來 instrument quiche 的 qlog 輸出,並建立視覺化來理解擁塞控制器內部發生了什麼:

失敗測試的連線概覽。

T=2 秒後,封包遺失完全停止——但 cwnd 仍卡在最小值,擁塞狀態在恢復和擁塞規避之間每約 14 毫秒振盪。

在兩秒(2000 毫秒)標記之後,封包遺失完全停止。

然而,飛行中的位元組數保持平穩,這與 CUBIC 演算法的核心邏輯相悖:在沒有遺失的情況下,施加更多油門以增加節流(在我們的世界裡是更多的位元組)。

這引發了一個問題:如果網路不再丟失封包,為什麼擁塞窗口無法增長?

當我們放大該區域時,我們的分析顯示 CUBIC 進入了快速振盪,在我們的圖表中顯示為擴展的恢復階段,在擁塞規避狀態(操作狀態階段)和恢復狀態(封包遺失恢復狀態)之間振盪——在約 6.7 秒內發生了 999 次轉換。

這大約是每 14 毫秒一次轉換——可疑地接近連線的 RTT(10 毫秒)。

在這整個時期,cwnd 被鎖定在最小值:2700 位元組,或兩個全尺寸封包。

顯然 CUBIC 的邏輯中有某些東西誤解了連線的狀態。

關鍵線索是振盪週期:約 14 毫秒符合 RTT。

任何觸發恢復/規避切換的因素,都是每往返一次,與連線的 ACK 時鐘同步;這是每個往返的 ACK 從客戶端觸發伺服器下一次發送的自時鐘節奏。

由於這是下載(伺服器到客戶端),相關的 ACK 正在從客戶端傳輸到伺服器,而 CUBIC 的狀態機運行在伺服器端:每次這些 ACK 到達時,bytes_in_flight 會降至零,伺服器會發送下一個兩封包的突發,這就是觸發 bug 的原因。

為了確認這種行為是 CUBIC 特有的,我們使用 Reno 進行了相同的測試,Reno 是基於遺失的家族的另一個成員,但增長率不同。

結果是決定性的:100% 通過率,顯示 Reno 在遺失階段結束後乾淨地恢復,並揭示這是一個與 CUBIC 相關的 bug。

Reno 在 T=2 秒結束遺失階段後乾淨地恢復,並在大約 5 秒內完成下載。

基於遺失的演算法有兩個踏板,油門和剎車,它們的加速方式有所不同。

好吧,CUBIC 帶有一些額外的功能。

在這裡,我們將專注於 bytes_in_flight == 0。

為了理解這個 bug,我們首先需要理解它來自的優化。

2017 年,Linux 核心的 CUBIC 實作中發現了一個問題。

commit 訊息解釋道:

Epoch 僅在初始時和經歷遺失時更新/重置。

delta "t" of now - epoch_start 在應用程式閒置後可能任意大,bic_target 也是如此。

因此,斜率(ca->cnt 的倒數)將會非常大,最終 ca->cnt 將會被下限為 2,以實現延遲 ACK 的慢啟動行為。

這尤其在 slow_start_after_idle 被禁用時顯現,因為在幾秒鐘的閒置時間後,cwnd 會出現危險的膨脹(1.5 倍 RTT)。

Epoch 是 CUBIC 用來錨定其增長曲線的參考時間戳:W_cubic(delta_t) 由 delta_t = now - epoch_start 參數化,並且每當 CUBIC 重啟其增長函數時,epoch 就會重置——最顯著的是在遺失事件減少 cwnd 之後。

在重置之間,delta_t 隨實際時間單調增長。

當應用程式閒置(停止發送)一段時間然後恢復時,CUBIC 增長函數 W_cubic(delta_t) 計算 delta_t 為 now - epoch_start,如圖所示。

由於 epoch 在閒置期間沒有更新,delta_t 非常大,產生了一個巨大的目標窗口——CUBIC 會立即嘗試將 cwnd 膨脹到一個不合理的數值。

Jana Iyengar 的初步修復是當應用程式恢復發送時重置 `epoch_start`。

但 Neal Cardwell 指出了這種方法的缺陷:

這個優雅的解決方案,由 Eric Dumazet、Yuchung Cheng 和 Neal Cardwell 編寫,是將 epoch 向前移動閒置持續時間,而不是重置它。

這保留了 CUBIC 增長曲線的形狀——只是在時間上滑動它,以便演算法可以從上次停止的地方繼續。

當 CUBIC 最初在 quiche 中實作時,這個閒置期間的調整被移植了。

然而,在使用者空間運行的 QUIC 沒有 TCP 的核心級 CA_EVENT_TX_START 回調。

相反,quiche 實作在 on_packet_sent() 中檢查閒置條件:

移植到 quiche 的修復包含了原始核心變更中的一個 bug,該 bug 在大約一週後被核心 cubic 模組的後續變更修復。

第二個修復的 commit 訊息解釋道:

tcp_cubic : 不要在未來設定 epoch_start

在 bictcp_cwnd_event() 中追蹤閒置時間是不精確的,因為 epoch_start 通常在處理 ACK 時設定,而不是在發送時設定。

進行一個正確的修復需要增加一個額外的狀態變數,而且鑑於 Jana 注意到 CUBIC bug 存在了很久,似乎不值得麻煩。

我們乾脆不要在未來設定 epoch_start,否則 bictcp_update() 可能會溢位,CUBIC 會再次過快地增長 cwnd。

正如 commit 訊息中所提到的,恢復開始時間是在處理 ACK 時設定的,並且基於發送時間的調整計算可能會將恢復開始時間推遲到未來。

這解釋了在我們的測試中看到的恢復和擁塞規避之間的振盪。

這個陷阱只有在每次進來的 ACK 都將 bytes_in_flight 推到零時才會持續觸發——這在實務中意味著 cwnd 已崩潰到最小值(兩個封包),並且應用程式在收到 ACK 的那一刻就有數據準備好發送下一個完整窗口。

在這個範圍之外,bytes_in_flight == 0 較不可能在每次發送時都成立,因此觸發 bug 的可能性較小。

為什麼這在連線開始時也不會發生?

這個 bug 只在連線退出慢啟動並切換到擁塞規避時觸發。

在退出慢啟動之前,congestion_recovery_start_time 未設定,因此 on_packet_sent() 中有 bug 的分支沒有恢復邊界可以推進。

在慢啟動期間,CUBIC 的 cwnd 遵循與所有基於遺失的 CCA 共享的、類似 Reno 的基於 ACK 的規則增長——CUBIC 曲線及其對 congestion_recovery_start_time 的敏感性,只有在連線處於擁塞規避狀態時才會發揮作用,這意味著這個陷阱需要同時滿足三個條件:一個真實的遺失事件來設定恢復邊界,正在運行擁塞規避,以及 cwnd 崩潰到兩封包的最小值。

自我維持的恢復陷阱。

在最小 cwnd(兩個封包)時,連線的動態轉變為「死亡螺旋」,其中閒置期間優化成為一種自我實現的預言。

這個陷阱以一個連續循環運作:

然後這個循環重複數千次,直到由於調度器抖動和 ACK 處理變異累積的小偏差,使得 in_congestion_recovery() 中的 <= 邊界滑到下一個封包的發送時間之後,打破了這個循環。

修復死亡螺旋涉及從 bytes_in_flight 實際轉換為零(最後處理的 ACK)的時間來測量閒置持續時間,而不是從最後發送的封包。

隨著 delta 現在反映了自上次 ACK 以來的實際間隔,恢復邊界停止追趕發送時間:

舊程式碼:邊界每個週期前進一個 RTT,總是落在下一次發送時間之上或之上。

修復:邊界幾乎不動;下一次發送落在其前面,cwnd 增長。

對於真正閒置的連線,last_ack_time 遠在過去,並且相同的表達式捕捉了完整的閒置持續時間,原始的 epoch 轉移行為得以保留。

應用修復後,我們的 quiche 測試套件的 100% 通過率得以恢復。

修復後,cwnd 沿著預期的 CUBIC 曲線增長,下載在大約 4-5 秒內完成。

我們不擔心連線末端的遺失——這是預期的,因為我們完全利用了路由器的分配緩衝區。

換句話說,在這個測試案例中,我們完全利用了可用頻寬。

本文所述的修復已貢獻給 cloudflare/quiche,Cloudflare 的開源 QUIC 和 HTTP/3 實作。

我們的 CCA 工作不僅限於基於遺失的演算法:我們還利用 quiche 的模組化擁塞控制設計來實驗和調整我們的基於模型的 BBRv3 實作,該實作現已為越來越多的 QUIC 部署啟用。

請繼續關注 QUIC 擁塞控制實作和效能的進一步更新。

如果您對擁塞控制、傳輸協定感興趣,或想為開源網路程式碼做出貢獻,請查看 quiche 儲存庫。

我們一直在尋找熱愛深入研究這類問題的優秀工程師,請探索我們的開放職位。

我們絕不會分享您的電子郵件地址。