Google 的 TCP BBR 擁塞控制算法深度解析

原作者:dog250,授權發佈

重新整理:極客重生


hi ,大家好,今天推薦一篇我認爲在 TCP BBR 技術裏面分析非常透徹的文章,希望大家可以學習到一些真正的知識,理解其背後的設計原理,才能應對各種面試和工作挑戰!

宏觀背景下的 BBR

1980 年代的擁塞崩潰導致了 1980 年代的擁塞控制機制的出爐,某種意義上這屬於見招拆招的策略,針對 1980 年代的擁塞,提出了 1980 年代的擁塞控制算法,分爲四個部分:慢啓動、擁塞避免、快速重傳、快速恢復

說實話,這些機制完美適應了 1980 年代的網絡特徵,低帶寬淺緩存隊列,美好持續到了 2000 年代。

隨後互聯網大爆發,多媒體應用特別是圖片,音視頻類的應用促使帶寬必須猛增,而摩爾定律促使存儲設施趨於廉價而路由器隊列緩存猛增,這便是 BBR 誕生的背景。換句話說,1980 年代的 CC 已經不適用了,2010 年代需要另外的一次見招拆招。

如果說上一次 1980 年代的 CC 旨在收斂,那麼這一次 BBR 則旨在 效能 E 最大化,這裏的 E 就是本文上面大量篇幅描述的那個 E,至少我個人是這麼認爲的,這也和 BBR 的初衷 **提高帶寬利用率 **相一致!

插個形象的 gif:

https://cloud.google.com/blog/products/networking/tcp-bbr-congestion-control-comes-to-gcp-your-internet-just-got-faster

正文開始

國慶節前,我看到了 bbr 算法,發現它就是那個唯一正確的做法 (可能有點誇張,但起碼它是一個通往正確道路的起點!),所以花了點時間研究了一下它,包括其 patch 的註釋,patch 代碼,並親自移植了 bbr patch 到更低版本的內核,在這個過程中,我也產生了一些想法,作爲備忘,整理了一篇文章,記如下,多年以後,再看 TCP bbr 算法的資料時,我的記錄也算是中文社區少有的第一個喫螃蟹記錄了,也算夠了!

正文之前,給出本文的圖例:

BBR 的組成

bbr 算法實際上非常簡單,在實現上它由 5 部分組成:

BBR 的組成

1. 即時速率的計算

計算一個即時的帶寬 bw,該帶寬是 bbr 一切計算的基準,bbr 將會根據當前的即時帶寬以及其所處的 pipe 狀態來計算 pacing rate 以及 cwnd,後面我們會看到,這個即時帶寬計算方法的突破式改進是 bbr 之所以簡單且高效的根源。計算方案按照標量計算,不再關注數據的含義。在 bbr 運行過程中,系統會跟蹤當前爲止最大的即時帶寬。

2.RTT 的跟蹤

bbr 之所以可以獲取非常高的帶寬利用率,是因爲它可以非常安全且豪放地探測到帶寬的最大值以及 rtt 的最小值,這樣計算出來的 BDP 就是目前爲止 TCP 管道的最大容量。bbr 的目標就是達到這個最大的容量!這個目標最終驅動了 cwnd 的計算。在 bbr 運行過程中,系統會跟蹤當前爲止最小 RTT。

3.BBR 狀態機的維持

bbr 算法根據互聯網的擁塞行爲有針對性地定義了 4 中狀態:

STARTUPDRAINPROBE_BWPROBE_RTT。bbr 通過對上述計算的即時帶寬 bw 以及 rtt 的持續觀察,在這 4 個狀態之間自由切換,相比之前的所有擁塞控制算法,其革命性的改進在於 bbr 擁塞算法不再跟蹤系統的 TCP 擁塞狀態機,而旨在用統一的方式來應對 pacing rate 和 cwnd 的計算,不管當前 TCP 是處在 Open 狀態還是處在 Disorder 狀態,抑或已經在 Recovery 狀態,換句話說,bbr 算法感覺不到丟包,它能看到的就是 bw 和 rtt!

4. 結果輸出 - pacing rate 和 cwnd

首先必須要說一下,bbr 的輸出並不僅僅是一個 cwnd,更重要的是 pacing rate。在傳統意義上,cwnd 是 TCP 擁塞控制算法的唯一輸出,但是它僅僅規定了當前的 TCP 最多可以發送多少數據,它並沒有規定怎麼把這麼多數據發出去,在 Linux 的實現中,如果發出去這麼多數據呢?簡單而粗暴,突發!忽略接收端通告窗口的前提下,Linux 會把 cwnd 一窗數據全部突發出去,而這往往會造成路由器的排隊,在深隊列的情況下,會測量出 rtt 劇烈地抖動。

bbr 在計算 cwnd 的同時,還計算了一個與之適配的 pacing rate,該 pacing rate 規定 cwnd 指示的一窗數據的數據包之間,以多大的時間間隔發送出去。

5. 其它外部機制的利用 - fq,rack 等

bbr 之所以可以高效地運行且如此簡單,是因爲很多機制並不是它本身實現的,而是利用了外部的已有機制,比如下一節中將要闡述的它爲什麼在計算帶寬 bw 時能如此放心地將重傳數據也計算在內...

帶寬計算細節以及狀態機

1. 即時帶寬的計算

bbr 作爲一個純粹的擁塞控制算法,完全忽略了系統層面的 TCP 狀態,計算帶寬時它僅僅需要兩個值就夠了:

1). 應答了多少數據,記爲 delivered;

2). 應答 1) 中的 delivered 這麼多數據所用的時間,記爲 interval_us。

將上述二者相除,就能得到帶寬:

bw = delivered/interval_us

非常簡單!以上的計算完全是標量計算,只關注數據的大小,不關注數據的含義,比如 delivered 的採集中,bbr 根本不管某一個應答是重傳後的 ACK 確認的,正常 ACK 確認的,還是說 SACK 確認的。bbr 只關心被應答了多少!

這和 TCP/IP 網絡模型是一致的,因爲在中間鏈路上,路由器交換機們也不會去管這些數據包是重傳的還是亂序的,然而擁塞也是在這些地方發生的,既然擁塞點都不關心數據的意義,TCP 爲什麼要關注呢?反過來,我們看一下擁塞發生的原因,即數據量超過了路由器的帶寬限制,利用這一點,只需要精心地控制發送的數據量就好了,完全不用管什麼亂序,重傳之類的。當然我的意思是說,擁塞控制算法中不用管這些,但這並不意味着它們是被放棄的,其它的機制會關注的,比如 SACK 機制RACK 機制RTO 機制等。

接下來我們看一下這個 delivered 以及 interval_us 的採集是如何實現的。還是像往常一樣,我不準備分析源碼,因爲如果分析源碼的話,往往難以抓住重點,過一段時間自己也看不懂了,相反,畫圖的話,就可以過濾掉很多諸如 unlikely 等異常流或者當前無需關注的東西:

上圖中,我故意用了一個極端點的例子,在該例子中,我幾乎都是使用的 SACK,當 X 被 SACK 時,我們可以根據圖示很容易算出從 Delivered 爲 7 時的數據包被確認到 X 被確認爲止,一共有 12-7=5 個數據包被確認,即這段時間網絡上清空了 5 個數據包!我們便很容易算出帶寬值了。我的這個圖示在解釋帶寬計算方法之外,還有一個目的,即說明 bbr 在計算帶寬時是不關注數據包是否按序確認的,它只關注數量,即數據包被網絡清空的數量。實實在在的計算,不猜 Lost,不猜亂序,這些東西,你再怎麼猜也猜不準!

計算所得的 bw 就是 bbr 此後一切計算的基準。

2. 狀態機

bbr 的狀態機轉換圖以及註釋如下圖所示:

通過上述的狀態機以及上一節的帶寬計算方式,我們知道了 bbr 的工作方式:不斷地基於當前帶寬以及當前的增益係數計算 pacing rate 以及 cwnd,以此 2 個結果作爲擁塞控制算法的輸出,在 TCP 連接的持續過程中,每收到一個 ACK,都會計算即時的帶寬,然後將結果反饋給 bbr 的 pipe 狀態機,不斷地調節增益係數,這就是 bbr 的全部,我們發現它是一個典型的封閉反饋系統,與 TCP 當前處於什麼擁塞狀態完全無關,其簡圖如下:

這非常不同於之前的所有擁塞控制算法,在之前的算法中,我們發現擁塞算法內部是受外部的擁塞狀態影響的,比如說在 Recovery 狀態下,甚至都不會進入擁塞控制算法,在 bbr 進入內核之前,Linux 使用 PRR 算法控制了 Recovery 狀態的窗口調整,即便說這個時候網絡已經恢復,TCP 也無法發現,因爲 TCP 的 Recovery 狀態還未恢復到 Open,這就是根源!

pacing rate 以及 cwnd 的計算

這一節好像是重點中的重點,但是我覺得如果理解了 bbr 的帶寬計算,狀態機以及其增益係數的概念,這裏就不是重點了,這裏只是一個公式化的結論。

pacing rate 怎麼計算?很簡單,就是是使用時間窗口內 (默認 10 輪採樣) 最大 BW。上一次採樣的即時 BW,用它來在可能的情況下更新時間窗口內的 BW 採樣值集合。這次能否按照這個時間窗口內最大 BW 發送數據呢?這樣看當前的增益係數的值,設爲 G,那麼 BW*G 就是 pacing rate 的值,是不是很簡單呢?!

至於說 cwnd 的計算可能要稍微複雜一點,但是也是可以理解的,我們知道,cwnd 其實描述了一條網絡管道 (rwnd 描述了接收端緩衝區),因此 cwnd 其實就是這個管道的容量,也就是 BDP

BW 我們已經有了,缺少的是 D,也就是 RTT,不過別忘了,bbr 一直在持續蒐集最小的 RTT 值,注意,bbr 並沒有採用什麼移動指數平均算法來 “猜測”RTT(我用猜測而不是預測的原因是,猜測的結果往往更加不可信!),而是直接冒泡採集最小的 RTT(注意這個 RTT 是 TCP 系統層面移動指數平均的結果,即 SRTT,但 brr 並不會對此結果再次做平均!)。我們用這個最小 RTT 幹什麼呢?

當前是計算 BDP 了!這裏 bbr 取的 RTT 就是這個最小 RTT。最小 RTT 表示一個曾經達到的最佳 RTT,既然曾經達到過,說明這是客觀的可以再次達到的 RTT,這樣有益於網絡管道利用率最大化!

我們採用 BDP*G'就算出了 cwnd,這裏的 G'是 cwnd 的增益係數,與帶寬增益係數含義一樣,根據 bbr 的狀態機來獲取!

BBR 的細節淺述

該節的題目比較怪異,既然是細節爲什麼又要淺述??

這是我的風格,一方面,說是細節是因爲這些東西還真的很少有人注意到,另一方面,說是淺述,是因爲我一般都不會去分析代碼以及代碼裏每一個異常流,我認爲那些對於理解原理幫助不大,那些東西只是在研發和優化時纔是有用的,所以說,像往常一樣,我這裏的這個小節還是一如既往地去談及一些 “細節”。

1. 豪放且大膽的安全探測

在看到 bbr 之後,我覺得之前的 TCP 擁塞控制算法都錯了,並不是思想錯了,而是實現的問題。

bbr 之所以敢大膽的去探測預估帶寬是因爲 TCP 把更多的權力交給了它!在 bbr 之前,很多本應該由擁塞控制算法去處理的細節並不歸擁塞控制算法管。在詳述之前,我們必須分清兩件事:

1). 傳輸多少數據?

2). 傳輸哪些數據?

按照 “上帝的事情上帝管,凱撒的事情凱撒管” 的原則,這兩件事本來就該由不同的機制來完成,不考慮對端接收窗口的情況下,擁塞窗口是唯一的主導因素,“傳輸多少數據”這件事應該由擁塞算法來回答,而 “傳輸哪些數據” 這個問題應該由 TCP 擁塞狀態機以及 SACK 分佈來決定,誠然這兩個問題是不同的問題,不應該雜糅在一起。

然而,在 bbr 進入內核之前的 Linux TCP 實現中,以上兩個問題並不是分得特別清。TCP 的擁塞狀態只有在 Open 時纔是上述的職責分離的完美樣子,一旦進入 Lost 或者 Recovery,那麼擁塞控制算法即便對 “問題 1):傳輸多少數據” 都無能爲力,在 Linux 的現有實現中,PRR 算法將接管一切,一直把窗口下降到 ssthresh,在 Lost 狀態則反應更加激烈,直接 cwnd 硬着陸!隨後等丟失數據傳輸成功後再執行慢啓動.... 在重新進入 Open 狀態之前,擁塞控制算法幾乎不會起作用,這並不是一種高速公路上的模式(小碰擦,拍照後停靠路邊,自行解決),更像是鬧市區的交通事故處理方式(無論怎樣,保持現場,直到交警和保險公司的人來現場處置)。

bbr 算法逃離了這一切錯誤的做法,在 bbr 的 patch 中,並非只是完成了一個 tcp_bbr.c,而是對整個 TCP 擁塞狀態控制框架進行了大手術,我們可以從以下的擁塞控制核心函數中可見一斑:

static void tcp_cong_control(struct sock *sk, u32 ack, u32 acked_sacked,
int flag, const struct rate_sample *rs)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
if (icsk->icsk_ca_ops->cong_control) {
// 如果是bbr,則完全被bbr接管,不管現在處在什麼狀態!
/* 目前而言,只有bbr使用了這個機制,但我相信,不久的將來,
         * 會有越來越多的擁塞控制算法使用這個統一的完全接管機制!
         * 就我個人而言,在幾個月前就寫過一個patch,接管了tcp_cwnd_reduction
         * 這個prr的降窗過程。如果當時有了這個框架,我就有福了!
         */
        icsk->icsk_ca_ops->cong_control(sk, rs);
return;
    }
// 否則繼續以往的錯誤方法!
if (tcp_in_cwnd_reduction(sk)) {
/* Reduce cwnd if state mandates */
// 非Open狀態中擁塞算法不受理窗口調整
        tcp_cwnd_reduction(sk, acked_sacked, flag);
    } else if (tcp_may_raise_cwnd(sk, flag)) {
/* Advance cwnd if state allows */
        tcp_cong_avoid(sk, ack, acked_sacked);
    }
    tcp_update_pacing_rate(sk);
}

在這個框架下,無論處在哪個狀態 (Open,Disorder,Recovery,Lost...),如果擁塞控制算法自己聲明有這個能力,那麼具體可以傳輸多少數據,完全由擁塞控制算法自行決定,TCP 擁塞狀態控制機制不再幹預!

2. 爲什麼 bbr 可以忽略 Recovery 和 Lost 狀態

看懂了以上第 1 點,這一點就很容易理解了。

在第 1 點中,我描述了 bbr 確實忽略了 Recovery 等非 Open 的擁塞狀態,但是爲什麼可以忽略呢?一般而言,很多人都會質疑,會說 bbr 採用這麼魯莽的方式,最終一定會讓窗口卡住不再滑動,但是我要反駁,你難道不知道 cwnd 只是個標量嗎?我畫一個圖來分析:

看懂了嗎?不存在任何問題!基本上,我們在討論擁塞控制算法的時候,會忽略流量控制,因爲不想讓 rwnd 和 cwnd 雜糅起來,但是在這裏,它們相遇了,幸運的是,並沒有引發衝突!

然而,這並不是全部,本節旨在 “淺析”,因此就不會關注代碼處理的細節。在 bbr 的實現中,如果算法外部的 TCP 擁塞狀態已經進入了 Lost,那麼 cwnd 該是多少呢?在 bbr 之前的擁塞算法中,包括 cubic 在內的所有算法中,當 TCP 核心實現從將 cwnd 調整到 1 或者 prr 到 ssthresh 一直到恢復到 Open 狀態,擁塞算法無權干預流程,然而 bbr 不。雖然說進入 Lost 狀態後,cwnd 會硬着陸到 1,然而由於 bbr 的接管,在 Lost 期間,cwnd 還是可以根據即時帶寬調整的!

這意味着什麼? 這意味着 bbr 可以區別噪聲丟包和擁塞丟包了!

a). 噪聲丟包

如果是噪聲丟包,在收到 reordering 個重複 ACK 後,由於 bbr 並不區分一個確認是 ACK 還是 SACK 引起的,所以在 bbr 看來,即時帶寬並沒有降低,可能還有所增加,所以一個數據包的丟失並不會引發什麼,bbr 依舊會給出一個比較大的 cwnd 配額,此時雖然 TCP 可能已經進入了 Recovery 狀態,但 bbr 依舊按照自己的 bw 以及調整後的增益係數來計算 cwnd 的新值,過程中並不會受到任何 TCP 擁塞狀態的影響。

如此一來,所有的噪聲丟包就被區別開來了!bbr 的宗旨是:“首先,在我的 bw 計算指示我發生擁塞之前,任何傳統的 TCP 擁塞判斷 - 丟包 / 時延增加,均全部失效,我並不 care 丟包和 RTT 增加”,隨後 brr 又會說:“但是我比較 care 的是,RTT 在一段時間內 (隨你怎麼配,但我個人傾向於自學習) 都沒有達到我所採集到的最小值或者更小的值!這也許意味着着鏈路真的發生擁塞了!”...

b). 擁塞丟包

將 a)的論述反過來,我們就會得到奇妙的封閉性結論。這樣,bbr 不光是消除了吞吐曲線的鋸齒 (ssthresh 所致,bbr 並不使用 ssthresh!),而且還消除了傳統擁塞控制算法的判斷滯後性問題。在 cubic 發現丟包進而判斷爲擁塞時,擁塞可能已經緩解了,但是 cubic 無法發現這一點。爲什麼?原因在於 cubic 在計算新的 cwnd 的時候,並沒有把當前的網絡狀態(比如 bw) 當作參數,而只是一味的按照數學意義上的三次方程去計算,這是錯誤的,這不是一個正確的反饋系統的做法!

基於 a) 和 b),看到了吧,這就是新的擁塞判斷機制!綜合考慮丟包和 RTT 的增加:

b-1). 如果丟包時真的發生了擁塞,那麼測量的即時帶寬肯定會減少,否則,丟包即擁塞就是謊言。

b-2). 如果 RTT 增加時真的發生了擁塞,那麼測量的即時帶寬肯定會減少,否則,時延增加即擁塞就是謊言。

bbr 測量了即時帶寬,這個統一 cwnd 和 rtt 的計量,完全忽略了丟包,因此 bbr 的算法思想是 TCP 擁塞控制的正軌!事實上,丟包本就不應該作爲一種擁塞的標誌,它只是擁塞的表現。

3. 狀態機的點點滴滴

我在上文已經呈現了關於 STARTUPDRAINPROBE_BWPROBE_RTT 的狀態圖以及些許細節,當時我指出這個狀態圖的目標是爲了完成 bbr 的目標,即填滿整個網絡!在這個狀態圖看來,所有已知的東西就是當前的即時帶寬,所有可以計算的東西就是增益係數,然後根據這兩個元素就可以輕易計算出 pacing rate 和 cwnd,是不是很簡單呢?整體看來就是就是這麼簡單,但是從細節上看,不同的 pipe 狀態中的增益係數的計算卻是值得推敲的,以下是 bbr 處在各個狀態時的增益係數:

STARTUP:2~3

DRAIN:pacing rate 的增益係數爲 1000/2885,cwnd 的增益係數爲 1000/2005+1。

PROBE_BW:5/4,1,3/4,bbr 在 PROBE_BW 期間會隨機在這些增益係數之間選擇當前的增益係數。

PROBE_RTT:1。但是在探測 RTT 期間,爲了防止丟包,cwnd 會強制 cut 到最小值,即 4 個 MSS。

我們可以看到,bbr 並沒有明確的所謂 “降窗時刻”,一切都是按照狀態機來的,期間絲毫不會理會 TCP 是否處在 OpenRecovery 等狀態。在此前的擁塞控制算法中,除了 Vegas 等基於延時的算法會在計算得到的 target cwnd 小於當前 cwnd 時視爲擁塞而在算法中降窗外,其它的所有基於丟包的算法中均是檢測到丟包 (RTO 或者 reordering 個重複 ACK) 時降窗的,可悲的是,這個降窗過程並不受擁塞算法的控制,擁塞算法只能消極地給出一個 ssthresh 值,即降窗的目標,這顯然是令人無助的!

bbr 不再關注丟包事件,它並不把丟包當成很嚴重的事,這事也不歸它管,只要 TCP 擁塞狀態機控制機制可以合理地將一些包標記爲 LOST,然後重傳它們便是了,bbr 能做的僅僅是告訴 TCP 一共可以發出去多少數據,僅此而已!然而,如果 TCP 並沒有把 LOST 數據包合理標記好,bbr 並不 care,它只是根據當前的 bw 和增益係數給出下一個 pacing rate 以及 cwnd 而已!

4. 關於 Sched FQ

這裏涉及的是 bbr 之外的東西,Fair queue!(公平隊列)在 bbr 的 patch 最後,會發現幾行註釋:

NOTE: BBR must be used with the fq qdisc ("man tc-fq") with pacing enabled, since pacing is integral to the BBR design andimplementation. BBR without pacing would not function properly, and may incur unnecessary high packet loss rates.

記住這幾行文字並理解它們。

這是 bbr 最爲重要的一方面。雖然說 Linux 的 TCP 實現早就支持的 pacing rate,但直到 4.8 版本都沒有在 TCP 層面支持它,很大的一部分原因是因爲藉助已有的 FQ 可以很完美地實現 pacing rate!TCP 可以藉助 FQ 來實現平緩而非突發的數據發送!

關於 FQ 的詳細內容可以去看相關的 manual 和源碼,這裏要說的僅僅是,FQ 可以根據 bbr 設置的 pacing rate 將一個 cwnd 內的數據的發送從 “突發到網絡” 這種行爲變換到 “平緩發送到網路” 的行爲,所謂的平緩發送指的就是數據包是按照帶寬速率計算的間隔一個個發送到網絡的,而不是突發進網絡的!

這樣一來,就給了網絡緩存以緩解的機會!記住,關鍵問題是 bbr 會在每收到 ACK/SACK 時計算 bw,這個精確的測量不會漏掉任何可乘之機,即便當前網絡擁塞了,它只要能在下一時刻恢復,bbr 就可以發現,因此即時帶寬通常可以表現這一點!

5. 其它

還有關於令牌桶監管發現 (lt policed) 的主題,long term 採樣的主題,留到後面的文章具體闡述吧,本文已經足夠長了。

6.bufferbloat 問題

關於深隊列,數據包如何如何長時間排隊但不丟包卻引發 RTO,對於淺隊列,數據包如何如何頻繁丟包... 談起這個話題我一開始想滔滔不絕,任何人都知道端到端的 QoS 是一個典型的反饋系統,但是任何人都只是誇誇其談,我選擇的是閉口不說.

這是一個怎麼說都能對又怎麼說都能錯的話題,就像股票預測那樣,所以我選擇不討論。

bbr 算法到來後,單單從公共測試結果上看,貌似解決了 bufferbloat 問題,也許吧,也許。bbr 好像真的開始在高速公路上飈車了... 最後給出一個測試圖,來自《A quick look at TCP BBR》:

bbr 代碼的簡單性和複雜性

我一向覺得 TCP 擁塞控制算法太過複雜,而複雜的東西基本上就是用來發 paper,直到遇到了 bbr。

Neal Cardwell 提供的 patch 簡單而又直接,大家可以從該 bbr 的 patch 上一看究竟!在 bbr 模塊之外,Neal Cardwell 主要更改了 tcp_ack 函數里面關於 delivered 計數的部分以及擁塞控制主函數,這一切都十分顯然,只要 patch 代碼就可以一目瞭然。在數據包被髮送的時候 - 不管是初次發送還是重傳,均會被當前 TCP 的連接狀況記錄在該數據包的 tcp_skb_cb 中,在數據包被應答的時候 - 不管是被 ACK 還是被 SACK,均會根據當前的狀態和其 tcp_skb_cb 中狀態計算出一個帶寬,這些顯而易見的邏輯相比任何人都應該知道哪裏的代碼被修改了!

patch 鏈接:

https://patchwork.ozlabs.org/project/netdev/list/?submitter=10078&state=*

然而,這種查找和確認的工作太令人感到悲哀,讀懂代碼是容易的,移植代碼是無聊的,因爲時間卡的太緊!我必須要說的是,如果一件感興趣的事情變成了必須要完成的工作,那麼做它的激情起碼減少了 1/4,OK,還不算太壞,然而如果這個必須完成的工作有了 deadline,那麼激情就會再減少 1/4,最後,如果有人在背後一直催,那麼完蛋,這件事可以瞬間完成,但是我可以鄭重說明這是湊合的結果!但是實際上,這件事本應該可以立即快速有高質量的完成並驗收!

寫在最後

我比較喜歡工匠精神,一種時間打磨精品的精神,一種自由引導創造的精神。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DnpNB4L6y9Qkx5lVqeEiXA