一行降低 100000kg 碳排放量的程式碼!

SOFAStack發表於2021-12-28

文|張稀虹(花名:止語 )

螞蟻集團技術專家

負責螞蟻集團雲原生架構下的高可用能力的建設
主要技術領域包括 ServiceMesh、Serverless 等

本文 3631 字 閱讀 8 分鐘

PART. 1 故事背景

今年雙十一大促後,按照慣例我們對大促期間的系統執行資料進行了詳細的分析,對比去年同期的效能資料發現,MOSN 的 CPU 使用率有大約 1% 的上漲。

為什麼增加了?

是合理的嗎?

可以優化嗎?

是不可避免的熵增,還是人為的浪費?

帶著這一些列靈魂拷問我們對系統進行了分析

圖片

PART. 2 問題定位

我們從監控上發現,這部分額外的開銷是在系統空閒時已有,並且不會隨著壓測流量增加而降低,CPU 總消耗增加 1.2%,其中 0.8% 是由 cpu_sys 帶來。

通過 perf 分析發現新版本的 MOSN 相較於老版本, syscall 有明顯的增加。

舊版本

新版本

圖片

經過層層分析,發現其中一部分原因是 MOSN 依賴的 sentinel-golang 中的一個StartTimeTicker 的 func 中的 Sleep 產生了大量的系統呼叫,這是個什麼邏輯?

PART. 3 理論分析

圖片

檢視原始碼發現有一個毫秒級別的時間戳快取邏輯,設計的目的是為了降低高呼叫頻率下的效能開銷,但空閒狀態下頻繁的獲取時間戳和 Sleep 會產生大量的系統呼叫,導致 cpu sys util 上漲。我們先從理論上分析一下為什麼這部分優化在工程上通常是無效的,先來看看 Sentinel 的程式碼:

package util

import (
  "sync/atomic"
  "time"
)

var nowInMs = uint64(0)

// StartTimeTicker starts a background task that caches current timestamp per millisecond,
// which may provide better performance in high-concurrency scenarios.
func StartTimeTicker() {
  atomic.StoreUint64(&nowInMs, uint64(time.Now().UnixNano())/UnixTimeUnitOffset)
  go func() {
    for {
      now := uint64(time.Now().UnixNano()) / UnixTimeUnitOffset
      atomic.StoreUint64(&nowInMs, now)
      time.Sleep(time.Millisecond)
    }
  }()
}

func CurrentTimeMillsWithTicker() uint64 {
  return atomic.LoadUint64(&nowInMs)
}

從上面的程式碼可以看到,Sentinel 內部用了一個 goroutine 迴圈的獲取時間戳存到 atomic 變數裡,然後呼叫 Sleep 休眠 1ms,通過這種方式快取了毫秒級別的時間戳。外部有一個開關控制這段邏輯是否要啟用,預設情況下是啟用的。從這段程式碼上看,效能開銷最大的應該是 Sleep,因為 Sleep 會產生 syscall,眾所周知 syscall 的代價是比較高的。

time.Sleep 和 time.Now 對比開銷到底大多少呢?

查證資料(1)後我發現一個反直覺的事實,由於 Golang 特殊的排程機制,在 Golang 中一次 time.Sleep 可能會產生 7 次 syscall,而 time.Now 則是 vDSO 實現的,那麼問題來了 vDSO 和 7 次系統呼叫相比提升應該是多少呢?

我找到了可以佐證的資料,恰好有一個 Golang 的優化(2),其中提到在老版本的 Golang 中(golang 1.9-),Linux/386 下沒有這個 vDSO 的優化,此時會有 2 次 syscall,新版本經過優化後理論效能提高 5~7x+,可以約等於一次 time.Now <= 0.3 次 syscall 的開銷。

Cache 設計的目的是為了減少 time.Now 的呼叫,所以理論上這裡呼叫量足夠大的情況下可能會有收益,按照上面的分析,假設 time.Now 和 Sleep 系統呼叫的開銷比是 0.3:7.3(7+0.3),Sleep 每秒會執行 1000 次(不考慮系統精度損失的情況下),這意味著一秒內 CurrentTimeMillsWithTicker 的呼叫總次數要超過 2.4W 才會有收益。

所以我們再分析一下 CurrentTimeMillsWithTicker 的呼叫次數,我在這個地方加了一個 counter 進行驗證,然後模擬請求呼叫 Sentinel 的 Entry,經過測試發現:

  1. 當首次建立資源點時,Entry 和 CurrentTimeMillsWithTicker 的放大比為 20,這主要是因為建立底層滑動視窗時需要大量的時間戳計算
  2. 當相同的 resource 呼叫 Entry 時,呼叫的放大比⁰為 5:1

|注 0: 內部使用的 MOSN 版本基於原版 Sentinel 做了一些定製化,社群版本放大比理論上低於該比值。

考慮到建立資源點是低頻的,我們可以近似認為此處呼叫放大比為 5。所以理論上當單機 QPS 至少超過 4800 以上才可能會取得收益......我們動輒聽說什麼 C10K、C100K、C1000K 問題,這個值看上去似乎並不很高?但在實際業務系統中,這實際上是一個很高的量。

我隨機抽取了多個日常請求量相對大的應用檢視 QPS(這裡的 QPS 包含所有型別的資源點,入口/出口呼叫以及子資源點等,總之就是所有會經過 Sentinel Entry 呼叫的請求量),日常峰值也未超過 4800QPS,可見實際的業務系統中,單機請求量超過這個值的場景是非常罕見的。¹

|注 1: 此處監控為分鐘級的資料監控,可能與秒級監控存在一定的出入,僅用於指導日常請求量評估。

圖片

考慮到這個優化還有一個好處,是可以降低同步請求時間戳時的耗時,所以我們可以再對比一下直接從 atomic 變數讀取快取值和通過 time.Now() 讀取時間戳的速度。

圖片

可以看到單次直接獲取時間戳確實比從記憶體讀取開銷大很多,但是仍然是 ns 級別的,這種級別的耗時增長對於一筆請求而言是可以忽略不計的。

圖片

大概是 0.06 微秒,即使乘以 5,也就是 0.3 微秒的增加。在 4000QPS 這個流量檔位下我們也可以看一下 MOSN 實際 RT。

圖片

兩臺機器的 MOSN RT 也沒有明顯的差異,畢竟只有 0.3 微秒...

PART. 4 測試結論

同時我們也找了兩臺機器,分別禁用/啟用這個 Cache 進行測試,測試結果佐證了上述分析的結論。

圖片

從上圖的資料可以看出來,啟用 Cache 的情況下 cpu sys util 始終比不啟用 Cache 的版本要大,隨著請求量增加,效能差距在逐步縮小,但是直至 4000QPS 仍然沒有正向的收益。

經過測試和理論分析可得知,在常規的場景下,Sentinel 的這個 Cache 特性是沒有收益的,反而對效能造成了損耗,尤其是在低負載的情況下。即使在高負載的情況下,也可以推論出:沒有這個 Cache 不會對系統造成太大的影響。

這次效能分析也讓我們意識到了幾個問題:

  1. 不要過早優化,正所謂過早優化是萬惡之源;
  2. 一定要用客觀資料證明優化結果是正向的,而不是憑藉直覺;
  3. 要結合實際場景進行分析,而不應該優先考慮一些小概率場景;
  4. 不同語言間底層實現可能存在區別,移植時應該仔細評估。

PART. 5 有必要嗎?

你上面不是說了,不要過早優化,那這個算不算過早優化呢,你是不是雙標?

“過早優化是萬惡之源”實際上被誤用了,它是有上下文的。

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%. —— Donald Knuth

Donald Knuth 認為許多優化是沒必要的,我們可能花費了大量的時間去做了投入產出比不高的事情,但他同時強調了一些關鍵性優化的必要性。簡而言之就是要考慮價效比,不能盲目地、沒有資料支撐地去做效能優化,premature 似乎翻譯成“不成熟、盲目的”更為貼切,因此這句話的本意是“盲目的優化是萬惡之源”。這裡只需要一行程式碼的改動,即可省下這部分不必要的開銷,價效比極高,何樂而不為呢?

從資料上看,這個優化只是降低了 0.7% 的 cpu sys util,我們缺這 0.7% 嗎?

從系統水位的角度思考或許還好,畢竟我們為了保險起見預備了比實際需求更多的資源,這 0.7% 並不會成為壓垮我們系統的最後一顆稻草。但從環保的角度,很有必要!今年我們強調的是綠色環保,提效降本。這區區一行程式碼,作為 Sidecar 跑在數十萬的業務 Pod 中,背後對應的是上萬臺的伺服器。

圖片

用不太嚴謹的一種方式進行粗略的估算,以常規的伺服器 CPU Xeon E5 為例,TDP² 為 120W,0.7% 120W 24 * 365 / 1000 = 73584 度電,每一萬臺機器一年 7 萬度電,這還不包括為了保持機房溫度而帶來的更大的熱交換能耗損失(簡單說就是空調費,常規機房 PUE 大概 1.5),按照不知道靠譜不靠譜的專家估算,節約 1 度電=減排 0.997 千克二氧化碳,這四捨五入算下來大概減少了 100000kg 的二氧化碳吧。

同時這也是一行開源社群的程式碼,社群已經採納我們的建議(3)將該特性預設設定為關閉,或許有上千家公司數以萬計的伺服器也將得到收益。

圖片

|注 2: TDP 即熱功耗設計,不能等價於電能功耗,熱設計功耗是指處理器在執行實際應用程式時,可產生的最大熱量。TDP 主要用於和處理器相匹配時,散熱器能夠有效地冷卻處理器的依據。處理器的 TDP 功耗並不代表處理器的真正功耗,更沒有算術關係,但通常可以認為實際功耗會大於 TDP。

「擴充套件閱讀」

(1)查證資料:https://github.com/golang/go/issues/25471

(2)Golang 的優化:https://go-review.googlesource.com/c/go/+/69390

(3)我們的建議:https://github.com/alibaba/sentinel-golang/issues/441

感謝藝剛、茂修、浩也、永鵬、卓與等同學對問題定位做出的貢獻,本文部分引用了 MOSN 大促版本效能對比文件提供的資料。同時感謝宿何等 Sentinel 社群的同學對相關 issue 和 PR 的積極支援。

本週推薦閱讀

技術風口上的限流

深入 HTTP/3(一)|從 QUIC 連結的建立與關閉看協議的演進

網商雙十一基於 ServiceMesh 技術的業務鏈路隔離技術及實踐

降本提效!註冊中心在螞蟻集團的蛻變之路

img

相關文章