當閒置不等於閒置:解決 QUIC 擁塞死亡螺旋

擁塞控制演算法 (CCAs) 是網際網路中隱形的指揮家,決定了發送者可以在不導致網路崩潰的情況下,向網路推送多少數據。CUBIC 是 Linux 中的預設擁塞控制器,旨在探測頻寬並在發生丟包時退避。然而,最近在 Cloudflare 的開源 QUIC 實作 quiche 中,核心層級的優化與 QUIC 的使用者空間特性之間產生了微妙的交互作用,導致了「死亡螺旋」。

這是一個關於原本旨在處理閒置期間的修復方案如何變成一個陷阱,將擁塞窗口釘在最小值,以及如何透過近乎一行程式碼的變更來恢復穩定性的故事。

症狀:61% 的失敗率

此問題最初出現在入口代理 (ingress proxy) 的整合測試中。場景非常特定:使用 CUBIC 進行 HTTP/3 下載 10 MB 的檔案,並在連線的前兩秒注入 30% 的隨機封包丟失。

在正常情況下,CUBIC 應該在丟包階段減少其擁塞窗口 (cwnd),然後在網路穩定後重新增加。然而,大約 60% 的測試未能在此 10 秒的寬鬆超時限制內完成下載。

異常現象

透過 qlog 進行的檢測顯示了一個令人震驚的模式。在兩秒鐘的丟包停止後,cwnd 始終維持在最小值 (2700 bytes,或大約兩個完整大小的封包) 。甚至更奇怪的是,擁塞狀態每隔約 14ms 就會在「恢復」與「擁塞避免」之間震盪——這幾乎正好等於連線的往返時間 (RTT) 。

雖然 Reno 演算法在相同的場景下能乾淨地恢復,但 CUBIC 卻進入了停滯狀態。連線並沒有經歷任何進一步的封包丟失,但擁塞窗口卻拒絕增長。

追溯根本原因: 「閒置」優化

要理解為什麼 CUBIC 會失敗,必須查看它如何處理閒置期間。CUBIC 使用一個 epoch(參考時間戳)來錨定其增長曲線。如果應用程式停止發送數據一段時間(進入閒置狀態)然後又恢復,自從上次 epoch 而言經過的時間可能非常長。如果沒有進行調整,CUBIC 會看到這個巨大的時間差,並立即嘗試將 cwnd 膨脹到一個不合理的數值。

為了防止這種情況,Linux 核心引入了一項優化,將 epoch 向前移動閒置期間的持續時間,有效地在時間上「滑動」增長曲線,使演算法能從上次中斷的地方繼續。

移植到使用者空間

當 Cloudflare 在 quiche 中實作 CUBIC 時,他們移植了這項閒置期間的調整。然而,Linux 核心與使用者空間實作之間存在根本差異:核心具有特定的回調函數(例如 CA_EVENT_TX_START)來發送恢復時發出訊號。在 quiche 中,這項檢查是在 on_packet_sent() 內部進行的:

if bytes_in_flight == 0 {
    let delta = now - self.last_sent_time;
    self.congestion_recovery_start_time += delta;
}

死亡螺旋

這個錯誤發生是因為實作方式是從 最後一個發送的封包 開始測量閒置持續時間,而不是從連線實際進入閒置狀態時開始。當 cwnd 處於最小值時,這會造成一個自我持續的循環:

  1. 窗口耗盡: 發送者傳輸兩個封包(最小的 cwnd)。
  2. ACK 週期: 在一個 RTT (~14ms) 之後,兩個封包都被確認 (ACK),且 bytes_in_flight 降至零。
  3. 錯誤的閒置檢測: 當下一次突發傳輸時,on_packet_sent() 看到 bytes_in_flight == 0 並假設連線處於閒置狀態。
  4. 膨脹的 Delta: 它計算閒置持續時間為 now - last_sent_time。因為窗口非常小,last_sent_time 是上一個 RTT 週期的開始。因此產生的 delta 為 ~14ms——即完整的 RTT——即使實際的閒置時間幾乎為零。
  5. 陷阱: 這個膨脹的 delta 會將 congestion_recovery_start_time 推向未來。因為恢復開始時間是在未來,演算法會認為自己仍處於恢復期,從而跳過 cwnd 的增長。

這個循環會重複數千次。只有當調度器抖動或 ACK 變異導致時間點意外地足以打破這個循環時,連線才能恢復。

修復方案:精確的閒置測量

解決方案是從管道實際排空(最後一個處理的 ACK)的時刻開始測量閒置持續時間,而不是從最後一個發送的封包開始。透過引入一個 last_ack_time 時間戳,邏輯被更新為使用最後一次 ACK 時間與最後一次發送時間的最大值:

let idle_start = cmp::max(cubic.last_ack_time, cubic.last_sent_time);
if let Some(idle_start) = idle_start {
    if idle_start < now {
        let delta = now - idle_start;
        r.congestion_recovery_start_time = Some(recovery_start_time + delta);
; 
    }
}

這確保了恢復邊界不會「追逐」發送時間,從而允許 cwnd 在丟包停止後沿著預期的 CUBIC 曲線增長。

技術要點

這次事件突顯了系統程式設計與網路協定實作中的幾個關鍵教訓:

  • 移植的危險性: 正如一位社群觀察者所指出的,單純將邏輯從核心移植到使用者空間而不考慮執行環境的差異(以及遺漏隨後的核心錯誤修復),可能會引入微妙的退化。
  • 定義 「閒置」: 在高效能網路中,「閒置」是一個微妙的狀態。當擁塞窗口很小時,簡單的檢查 bytes_in_flight == 0 可能會被正常的流水線延遲所誤導。
  • 邊緣案例的可見性: 這個錯誤在標準的吞吐量儀表板和靜態代碼審查中是不可見的。它只有在刻意地將 CCA 驅動至擁塞崩潰狀態時才會顯現——這種狀態在標準測試中很少被觸及,但對於可靠性至關重要。

透過精確化閒置時間的測量方式,Cloudflare 恢復了其測試套件的 100% 通過率,並確保了 quiche 可以從嚴重的網路不穩定狀態中優雅地恢復。

Sources