Linux中國

五種加速 Go 的特性

我想以一個問題開始我的演講。

為什麼選擇 Go

當大家討論學習或在生產環境中使用 Go 的原因時,答案不一而足,但因為以下三個原因的最多。

Gocon 2014

這就是 TOP3 的原因。

第一,並發。

Go 的 並發原語 Concurrency Primitives 對於來自 Nodejs,Ruby 或 Python 等單線程腳本語言的程序員,或者來自 C++ 或 Java 等重量級線程模型的語言都很有吸引力。

易於部署。

我們今天從經驗豐富的 Gophers 那裡聽說過,他們非常欣賞部署 Go 應用的簡單性。

Gocon 2014

然後是性能

我相信人們選擇 Go 的一個重要原因是它 快。

Gocon 2014 (4)

在今天的演講中,我想討論五個有助於提高 Go 性能的特性。

我還將與大家分享 Go 如何實現這些特性的細節。

Gocon 2014 (5)

我要談的第一個特性是 Go 對於值的高效處理和存儲。

Gocon 2014 (6)

這是 Go 中一個值的例子。編譯時,gocon 正好消耗四個位元組的內存。

讓我們將 Go 與其他一些語言進行比較

Gocon 2014 (7)

由於 Python 表示變數的方式的開銷,使用 Python 存儲相同的值會消耗六倍的內存。

Python 使用額外的內存來跟蹤類型信息,進行 引用計數 Reference Counting 等。

讓我們看另一個例子:

Gocon 2014 (8)

與 Go 類似,Java 消耗 4 個位元組的內存來存儲 int 型。

但是,要在像 ListMap 這樣的集合中使用此值,編譯器必須將其轉換為 Integer 對象。

Gocon 2014 (9)

因此,Java 中的整數通常消耗 16 到 24 個位元組的內存。

為什麼這很重要? 內存便宜且充足,為什麼這個開銷很重要?

Gocon 2014 (10)

這是一張顯示 CPU 時鐘速度與內存匯流排速度的圖表。

請注意 CPU 時鐘速度和內存匯流排速度之間的差距如何繼續擴大。

兩者之間的差異實際上是 CPU 花費多少時間等待內存。

Gocon 2014 (11)

自 1960 年代後期以來,CPU 設計師已經意識到了這個問題。

他們的解決方案是一個緩存,一個更小、更快的內存區域,介入 CPU 和主存之間。

Gocon 2014 (12)

這是一個 Location 類型,它保存物體在三維空間中的位置。它是用 Go 編寫的,因此每個 Location 只消耗 24 個位元組的存儲空間。

我們可以使用這種類型來構造一個容納 1000 個 Location 的數組類型,它只消耗 24000 位元組的內存。

在數組內部,Location 結構體是順序存儲的,而不是隨機存儲的 1000 個 Location 結構體的指針。

這很重要,因為現在所有 1000 個 Location 結構體都按順序放在緩存中,緊密排列在一起。

Gocon 2014 (13)

Go 允許您創建緊湊的數據結構,避免不必要的填充位元組。

緊湊的數據結構能更好地利用緩存。

更好的緩存利用率可帶來更好的性能

Gocon 2014 (14)

函數調用不是無開銷的。

Gocon 2014 (15)

調用函數時會發生三件事。

創建一個新的 棧幀 Stack Frame ,並記錄調用者的詳細信息。

在函數調用期間可能被覆蓋的任何寄存器都將保存到棧中。

處理器計算函數的地址並執行到該新地址的分支。

Gocon 2014 (16)

由於函數調用是非常常見的操作,因此 CPU 設計師一直在努力優化此過程,但他們無法消除開銷。

函調固有開銷,或重於泰山,或輕於鴻毛,這取決於函數做了什麼。

減少函數調用開銷的解決方案是 內聯 Inlining

Gocon 2014 (17)

Go 編譯器通過將函數體視為調用者的一部分來內聯函數。

內聯也有成本,它增加了二進位文件大小。

只有當調用開銷與函數所做工作關聯度的很大時內聯才有意義,因此只有簡單的函數才能用於內聯。

複雜的函數通常不受調用它們的開銷所支配,因此不會內聯。

Gocon 2014 (18)

這個例子顯示函數 Double 調用 util.Max

為了減少調用 util.Max 的開銷,編譯器可以將 util.Max 內聯到 Double 中,就象這樣

Gocon 2014 (19)

內聯後不再調用 util.Max,但是 Double 的行為沒有改變。

內聯並不是 Go 獨有的。幾乎每種編譯或及時編譯的語言都執行此優化。但是 Go 的內聯是如何實現的?

Go 實現非常簡單。編譯包時,會標記任何適合內聯的小函數,然後照常編譯。

然後函數的源代碼和編譯後版本都會被存儲。

Gocon 2014 (20)

此幻燈片顯示了 util.a 的內容。源代碼已經過一些轉換,以便編譯器更容易快速處理。

當編譯器編譯 Double 時,它看到 util.Max 可內聯的,並且 util.Max 的源代碼是可用的。

就會替換原函數中的代碼,而不是插入對 util.Max 的編譯版本的調用。

擁有該函數的源代碼可以實現其他優化。

Gocon 2014 (21)

在這個例子中,儘管函數 Test 總是返回 false,但 Expensive 在不執行它的情況下無法知道結果。

Test 被內聯時,我們得到這樣的東西。

Gocon 2014 (22)

編譯器現在知道 Expensive 的代碼無法訪問。

這不僅節省了調用 Test 的成本,還節省了編譯或運行任何現在無法訪問的 Expensive 代碼。

Go 編譯器可以跨文件甚至跨包自動內聯函數。還包括從標準庫調用的可內聯函數的代碼。

Gocon 2014 (23)

強制垃圾回收 Mandatory Garbage Collection 使 Go 成為一種更簡單,更安全的語言。

這並不意味著垃圾回收會使 Go 變慢,或者垃圾回收是程序速度的瓶頸。

這意味著在堆上分配的內存是有代價的。每次 GC 運行時都會花費 CPU 時間,直到釋放內存為止。

Gocon 2014 (24)

然而,有另一個地方分配內存,那就是棧。

與 C 不同,它強制您選擇是否將值通過 malloc 將其存儲在堆上,還是通過在函數範圍內聲明將其儲存在棧上;Go 實現了一個名為 逃逸分析 Escape Analysis 的優化。

Gocon 2014 (25)

逃逸分析決定了對一個值的任何引用是否會從被聲明的函數中逃逸。

如果沒有引用逃逸,則該值可以安全地存儲在棧中。

存儲在棧中的值不需要分配或釋放。

讓我們看一些例子

Gocon 2014 (26)

Sum 返回 1 到 100 的整數的和。這是一種相當不尋常的做法,但它說明了逃逸分析的工作原理。

因為切片 numbers 僅在 Sum 內引用,所以編譯器將安排到棧上來存儲的 100 個整數,而不是安排到堆上。

沒有必要回收 numbers,它會在 Sum 返回時自動釋放。

Gocon 2014 (27)

第二個例子也有點尬。在 CenterCursor 中,我們創建一個新的 Cursor 對象並在 c 中存儲指向它的指針。

然後我們將 c 傳遞給 Center() 函數,它將 Cursor 移動到屏幕的中心。

最後我們列印出那個 『Cursor` 的 X 和 Y 坐標。

即使 cnew 函數分配了空間,它也不會存儲在堆上,因為沒有引用 c 的變數逃逸 CenterCursor 函數。

Gocon 2014 (28)

默認情況下,Go 的優化始終處於啟用狀態。可以使用 -gcflags = -m 開關查看編譯器的逃逸分析和內聯決策。

因為逃逸分析是在編譯時執行的,而不是運行時,所以無論垃圾回收的效率如何,棧分配總是比堆分配快。

我將在本演講的其餘部分詳細討論棧。

Gocon 2014 (30)

Go 有 goroutine。 這是 Go 並發的基石。

我想退一步,探索 goroutine 的歷史。

最初,計算機一次運行一個進程。在 60 年代,多進程或 分時 Time Sharing 的想法變得流行起來。

在分時系統中,操作系統必須通過保護當前進程的現場,然後恢復另一個進程的現場,不斷地在這些進程之間切換 CPU 的注意力。

這稱為 進程切換。

Gocon 2014 (29)

進程切換有三個主要開銷。

首先,內核需要保護該進程的所有 CPU 寄存器的現場,然後恢復另一個進程的現場。

內核還需要將 CPU 的映射從虛擬內存刷新到物理內存,因為這些映射僅對當前進程有效。

最後是操作系統 上下文切換 Context Switch 的成本,以及 調度函數 Scheduler Function 選擇佔用 CPU 的下一個進程的開銷。

Gocon 2014 (31)

現代處理器中有數量驚人的寄存器。我很難在一張幻燈片上排開它們,這可以讓你知道保護和恢復它們需要多少時間。

由於進程切換可以在進程執行的任何時刻發生,因此操作系統需要存儲所有寄存器的內容,因為它不知道當前正在使用哪些寄存器。

Gocon 2014 (32)

這導致了線程的出生,這些線程在概念上與進程相同,但共享相同的內存空間。

由於線程共享地址空間,因此它們比進程更輕,因此創建速度更快,切換速度更快。

Gocon 2014 (33)

Goroutine 升華了線程的思想。

Goroutine 是 協作式調度 Cooperative Scheduled
的,而不是依靠內核來調度。

當對 Go 運行時調度器 Runtime Scheduler 進行顯式調用時,goroutine 之間的切換僅發生在明確定義的點上。

編譯器知道正在使用的寄存器並自動保存它們。

Gocon 2014 (34)

雖然 goroutine 是協作式調度的,但運行時會為你處理。

Goroutine 可能會給禪讓給其他協程時刻是:

  • 阻塞式通道發送和接收。
  • Go 聲明,雖然不能保證會立即調度新的 goroutine。
  • 文件和網路操作式的阻塞式系統調用。
  • 在被垃圾回收循環停止後。

Gocon 2014 (35)

這個例子說明了上一張幻燈片中描述的一些調度點。

箭頭所示的線程從左側的 ReadFile 函數開始。遇到 os.Open,它在等待文件操作完成時阻塞線程,因此調度器將線程切換到右側的 goroutine。

繼續執行直到從通道 c 中讀,並且此時 os.Open 調用已完成,因此調度器將線程切換回左側並繼續執行 file.Read 函數,然後又被文件 IO 阻塞。

調度器將線程切換回右側以進行另一個通道操作,該操作在左側運行期間已解鎖,但在通道發送時再次阻塞。

最後,當 Read 操作完成並且數據可用時,線程切換回左側。

Gocon 2014 (36)

這張幻燈片顯示了低級語言描述的 runtime.Syscall 函數,它是 os 包中所有函數的基礎。

只要你的代碼調用操作系統,就會通過此函數。

entersyscall 的調用通知運行時該線程即將阻塞。

這允許運行時啟動一個新線程,該線程將在當前線程被阻塞時為其他 goroutine 提供服務。

這導致每 Go 進程的操作系統線程相對較少,Go 運行時負責將可運行的 Goroutine 分配給空閑的操作系統線程。

Gocon 2014 (37)

在上一節中,我討論了 goroutine 如何減少管理許多(有時是數十萬個並發執行線程)的開銷。

Goroutine故事還有另一面,那就是棧管理,它引導我進入我的最後一個話題。

Gocon 2014 (39)

這是一個進程的內存布局圖。我們感興趣的關鍵是堆和棧的位置。

傳統上,在進程的地址空間內,堆位於內存的底部,位於程序(代碼)的上方並向上增長。

棧位於虛擬地址空間的頂部,並向下增長。

Gocon 2014 (40)

因為堆和棧相互覆蓋的結果會是災難性的,操作系統通常會安排在棧和堆之間放置一個不可寫內存區域,以確保如果它們發生碰撞,程序將中止。

這稱為保護頁,有效地限制了進程的棧大小,通常大約為幾兆位元組。

Gocon 2014 (41)

我們已經討論過線程共享相同的地址空間,因此對於每個線程,它必須有自己的棧。

由於很難預測特定線程的棧需求,因此為每個線程的棧和保護頁面保留了大量內存。

希望是這些區域永遠不被使用,而且防護頁永遠不會被擊中。

缺點是隨著程序中線程數的增加,可用地址空間的數量會減少。

Gocon 2014 (42)

我們已經看到 Go 運行時將大量的 goroutine 調度到少量線程上,但那些 goroutines 的棧需求呢?

Go 編譯器不使用保護頁,而是在每個函數調用時插入一個檢查,以檢查是否有足夠的棧來運行該函數。如果沒有,運行時可以分配更多的棧空間。

由於這種檢查,goroutines 初始棧可以做得更小,這反過來允許 Go 程序員將 goroutines 視為廉價資源。

Gocon 2014 (43)

這是一張顯示了 Go 1.2 如何管理棧的幻燈片。

G 調用 H 時,沒有足夠的空間讓 H 運行,所以運行時從堆中分配一個新的棧幀,然後在新的棧段上運行 H。當 H 返回時,棧區域返回到堆,然後返回到 G

Gocon 2014 (44)

這種管理棧的方法通常很好用,但對於某些類型的代碼,通常是遞歸代碼,它可能導致程序的內部循環跨越這些棧邊界之一。

例如,在程序的內部循環中,函數 G 可以在循環中多次調用 H

每次都會導致棧拆分。 這被稱為 熱分裂 Hot Split 問題。

Gocon 2014 (45)

為了解決熱分裂問題,Go 1.3 採用了一種新的棧管理方法。

如果 goroutine 的棧太小,則不會添加和刪除其他棧段,而是分配新的更大的棧。

舊棧的內容被複制到新棧,然後 goroutine 使用新的更大的棧繼續運行。

在第一次調用 H 之後,棧將足夠大,對可用棧空間的檢查將始終成功。

這解決了熱分裂問題。

Gocon 2014 (46)

值,內聯,逃逸分析,Goroutines 和分段/複製棧。

這些是我今天選擇談論的五個特性,但它們絕不是使 Go 成為快速的語言的唯一因素,就像人們引用他們學習 Go 的理由的三個原因一樣。

這五個特性一樣強大,它們不是孤立存在的。

例如,運行時將 goroutine 復用到線程上的方式在沒有可擴展棧的情況下幾乎沒有效率。

內聯通過將較小的函數組合成較大的函數來降低棧大小檢查的成本。

逃逸分析通過自動將從實例從堆移動到棧來減少垃圾回收器的壓力。

逃逸分析還提供了更好的 緩存局部性 Cache Locality

如果沒有可增長的棧,逃逸分析可能會對棧施加太大的壓力。

Gocon 2014 (47)

  • 感謝 Gocon 主辦方允許我今天發言
  • twitter / web / email details
  • 感謝 @offbymany,@billkennedy_go 和 Minux 在準備這個演講的過程中所提供的幫助。

相關文章:

  1. 聽我在 OSCON 上關於 Go 性能的演講
  2. 為什麼 Goroutine 的棧是無限大的?
  3. Go 的運行時環境變數的旋風之旅
  4. 沒有事件循環的性能

作者簡介:

David 是來自澳大利亞悉尼的程序員和作者。

自 2011 年 2 月起成為 Go 的 contributor,自 2012 年 4 月起成為 committer。

聯繫信息

via: https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast

作者:Dave Cheney 譯者:houbaron 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國