1. 程式人生 > 程式設計 >[譯] 使用因果分析優化 Go HTTP/2 伺服器

[譯] 使用因果分析優化 Go HTTP/2 伺服器

使用因果分析優化 Go HTTP/2 伺服器

簡介

如果你一直都有關注本部落格,那麼你應該看過這篇介紹因果分析的論文。這種分析方式旨在建立效能消耗週期與效能優化之間的聯絡。我已經在 Go 語言中實踐了這種分析方式。我覺得是時候在一個真正的軟體中 —— Go 標準庫的 HTTP/2 實現中去實踐一下了。

HTTP/2

HTTP/2 是我們熟悉並且受夠了的 HTTP/1 協議的全新實現。它的一個連線可以被用來多次傳送或接收請求,以減少建立連線時的開銷。Go 中的實現會對每一個請求分配一個 goroutine,或者在一次連線中分配多個 goroutine 以處理非同步通訊,為了決定誰在何時可以向連線中寫入資料,多個 goroutine 之間會互相協調配合。

這種設計非常適合因果分析。如果有什麼東西暗中阻塞了一個請求,那麼在因果分析中會很容易發現它,而在傳統的分析方式中可能就沒那麼容易了。

實驗配置

為了方便度量,我基於 HTTP/2 伺服器和其客戶端構建了一個綜合性的基準測試。伺服器請求 Google 首頁獲取請求的報頭和正文,並把每一個請求都記錄下來。客戶端使用 Firefox 的客戶端報頭請求根路徑下的檔案。客戶端的最大併發請求量為 10。這個數量是隨意選擇的,但這應該足以保持 CPU 飽和。

我們需要對程式進行跟蹤以便執行因果分析。我們會設定一個 Progress 標記,它會記錄兩行程式碼之間消耗的執行時間。HTTP/2 伺服器會呼叫 runHandler 函式,它會在 goroutine 中執行 HTTP 處理程式。我們在建立 goroutine 前就標記了開始,以便評估併發排程延遲和 HTTP 處理的消耗。結束標記則設定在處理程式向通道中寫入所有資料之後。

為了獲得測試基線,讓我們使用傳統的方式從伺服器獲取一份 CPU 分析資料,結果如下圖:

好吧,這就是我們從一個已經優化過的大型應用程式中獲得的東西,一個巨大的難以優化的呼叫關係圖。紅色的大框是系統呼叫,這部分我們是不可能優化的。

下面的資料給了我們更多相關的內容,但對我們也沒有實質性的幫助。

(pprof) top
Showing nodes accounting for 40.32s,49.44% of 81.55s total
Dropped 453 nodes (cum <= 0.41s)
Showing top 10 nodes out of 186
      flat  flat%   sum%        cum   cum%
    18.09s 22.18% 22.18%     18.84s 23.10%  syscall.Syscall
     4.69s  5.75% 27.93%      4.69s  5.75%  crypto/aes.gcmAesEnc
     3.88s  4.76% 32.69%      3.88s  4.76%  runtime.futex
     3.49s  4.28% 36.97%      3.49s  4.28%  runtime.epollwait
     2.10s  2.58% 39.55%      6.28s  7.70%  runtime.selectgo
     2.02s  2.48% 42.02%      2.02s  2.48%  runtime.memmove
     1.84s  2.26% 44.28%      2.13s  2.61%  runtime.step
     1.69s  2.07% 46.35%      3.97s  4.87%  runtime.pcvalue
     1.26s  1.55% 47.90%      1.39s  1.70%  runtime.lock
     1.26s  1.55% 49.44%      1.26s  1.55%  runtime.usleep
複製程式碼

看起來程式主要包含了執行時方法的呼叫和加密方法的呼叫。讓我們先把加密方法放在一邊,因為它已經足夠優化了。

使用因果分析來拯救這個程式

我們最好在使用因果分析得到分析結果之前回顧一下程式的工作方式。當因果分析被啟用時,程式將執行一系列測試。測試首先選擇一個呼叫並執行一些加速程式。當該呼叫被執行時(通過對程式底層的分析來檢測),我們會通過加速程式來降低其他執行緒的執行速度。

這似乎有悖直覺,但由於我們知道從 Progress 標記開始執行時程式會慢多少,我們就可以消除這種影響,以獲得加速訪問站點後程式將會花費的時間。我建議你閱讀我的其他關於因果分析的文章或是最初的論文來深入瞭解其中的原理。

最終,因果分析看上去就像是一些被加速了的請求,使得 Progress 標記之間的程式碼執行時間發生了改變。對於 HTTP/2 伺服器來說,一次請求的結果如下:

0x4401ec /home/daniel/go/src/runtime/select.go:73
  0%    2550294ns
 20%    2605900ns    +2.18%    0.122%
 35%    2532253ns    -0.707%    0.368%
 40%    2673712ns    +4.84%    0.419%
 75%    2722614ns    +6.76%    0.886%
 95%    2685311ns    +5.29%    0.74%
複製程式碼

在這個例子中,我們觀察 select 執行時程式碼中的 unlock 呼叫。我們實際上加速了這一次呼叫,從而改變了呼叫的數量、消耗的時間和與基線的差異。結果表明,我們並沒有從這樣的加速中獲得更多潛在的效能提升。事實上,當我們加速 select 程式碼時,程式反而變得更慢了。

第四列資料看上去有點奇怪。它是在這次請求中檢測到的樣本佔比資料,應該和加速成正比。在傳統分析方式中,它可以粗略地表示為加速帶來的期望效能提升。

現在來看一個更有趣的呼叫分析結果:

0x4478aa /home/daniel/go/src/runtime/stack.go:881
  0%    2650250ns
  5%    2659303ns    +0.342%    0.84%
 15%    2526251ns    -4.68%    1.97%
 45%    2434132ns    -8.15%    6.65%
 50%    2587378ns    -2.37%    8.12%
 55%    2405998ns    -9.22%    8.31%
 70%    2394923ns    -9.63%    10.1%
 85%    2501800ns    -5.6%    11.7%
複製程式碼

該呼叫位於堆疊程式碼中,上面的資料顯示這裡的加速可能會得到不錯的結果。第四列資料表明,程式執行時這部分程式碼佔比相當大。讓我們基於上面的測試資料再來看看重點關注堆疊程式碼的傳統分析方式的分析結果。

(pprof) top -cum newstack
Active filters:
   focus=newstack
Showing nodes accounting for 1.44s,1.77% of 81.55s total
Dropped 36 nodes (cum <= 0.41s)
Showing top 10 nodes out of 65
      flat  flat%   sum%        cum   cum%
     0.10s  0.12%  0.12%      8.47s 10.39%  runtime.newstack
     0.09s  0.11%  0.23%      8.25s 10.12%  runtime.copystack
     0.80s  0.98%  1.21%      7.17s  8.79%  runtime.gentraceback
         0     0%  1.21%      6.38s  7.82%  net/http.(*http2serverConn).writeFrameAsync
         0     0%  1.21%      4.32s  5.30%  crypto/tls.(*Conn).Write
         0     0%  1.21%      4.32s  5.30%  crypto/tls.(*Conn).writeRecordLocked
         0     0%  1.21%      4.32s  5.30%  crypto/tls.(*halfConn).encrypt
     0.45s  0.55%  1.77%      4.23s  5.19%  runtime.adjustframe
         0     0%  1.77%      3.90s  4.78%  bufio.(*Writer).Write
         0     0%  1.77%      3.90s  4.78%  net/http.(*http2Framer).WriteData
複製程式碼

上面的資料表明 newstack 是從 writeFrameAsync 中呼叫的。每當 HTTP/2 伺服器向客戶端傳送資料幀時,都會建立一個 goroutine 並呼叫該方法。而在任何時刻,只有一個 writeFrameAsync 可以執行,如果程式試圖傳送更多的資料幀,那麼它將被阻塞,直到前一個 writeFrameAsync 返回。

由於 writeFrameAsync 的呼叫跨越多個邏輯層,因此不可避免會產生大量的堆疊呼叫。

我是如何將 HTTP/2 伺服器的效能提升 28.2% 的

堆疊的增長拖慢了程式的執行,那麼我們需要採取一些措施來避免它。每次建立 goroutine 的時候都會呼叫 writeFrameAsync,因此寫入每一個資料幀時我們都需要付出堆疊增長的代價。

反過來說,如果我們可以重用 goroutine,我們就可以讓堆疊只增長一次,而隨後的每一次呼叫都可以重用已經生成好的堆疊了。我將這個改動部署到伺服器上,因果分析的測試基線從 2.650ms 下降到 1.901ms,效能提升了 28.2%。

需要注意的是,HTTP/2 伺服器通常不會在本地全速執行。我估計,如果將伺服器連線到網際網路中,收益將會小得多,因為堆疊增長所消耗的 CPU 時間比網路延遲要小得多。

結論

因果分析方法目前還不太成熟,但我認為這個小例子明確地展示了它所具有的潛力。你可以檢視該專案的分支,其中已經加入了因果分析的埋點。你也可以向我推薦其他的測試基線,來看看我們還能得出哪些結論。

附註:我現在正在找工作。如果你們需要對 Go 語言底層的內部實現有所瞭解並且熟悉分散式架構的人才,請檢視我的簡歷或傳送郵件到 [email protected]

相關文章

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄