五種加速 Go 的特性
我想以一個問題開始我的演講。
為什麼選擇 Go?
當大家討論學習或在生產環境中使用 Go 的原因時,答案不一而足,但因為以下三個原因的最多。
這就是 TOP3 的原因。
第一,並發。
Go 的 並發原語 對於來自 Nodejs,Ruby 或 Python 等單線程腳本語言的程序員,或者來自 C++ 或 Java 等重量級線程模型的語言都很有吸引力。
易於部署。
我們今天從經驗豐富的 Gophers 那裡聽說過,他們非常欣賞部署 Go 應用的簡單性。
然後是性能。
我相信人們選擇 Go 的一個重要原因是它 快。
在今天的演講中,我想討論五個有助於提高 Go 性能的特性。
我還將與大家分享 Go 如何實現這些特性的細節。
我要談的第一個特性是 Go 對於值的高效處理和存儲。
這是 Go 中一個值的例子。編譯時,gocon
正好消耗四個位元組的內存。
讓我們將 Go 與其他一些語言進行比較
由於 Python 表示變數的方式的開銷,使用 Python 存儲相同的值會消耗六倍的內存。
Python 使用額外的內存來跟蹤類型信息,進行 引用計數 等。
讓我們看另一個例子:
與 Go 類似,Java 消耗 4 個位元組的內存來存儲 int
型。
但是,要在像 List
或 Map
這樣的集合中使用此值,編譯器必須將其轉換為 Integer
對象。
因此,Java 中的整數通常消耗 16 到 24 個位元組的內存。
為什麼這很重要? 內存便宜且充足,為什麼這個開銷很重要?
這是一張顯示 CPU 時鐘速度與內存匯流排速度的圖表。
請注意 CPU 時鐘速度和內存匯流排速度之間的差距如何繼續擴大。
兩者之間的差異實際上是 CPU 花費多少時間等待內存。
自 1960 年代後期以來,CPU 設計師已經意識到了這個問題。
他們的解決方案是一個緩存,一個更小、更快的內存區域,介入 CPU 和主存之間。
這是一個 Location
類型,它保存物體在三維空間中的位置。它是用 Go 編寫的,因此每個 Location
只消耗 24 個位元組的存儲空間。
我們可以使用這種類型來構造一個容納 1000 個 Location
的數組類型,它只消耗 24000 位元組的內存。
在數組內部,Location
結構體是順序存儲的,而不是隨機存儲的 1000 個 Location
結構體的指針。
這很重要,因為現在所有 1000 個 Location
結構體都按順序放在緩存中,緊密排列在一起。
Go 允許您創建緊湊的數據結構,避免不必要的填充位元組。
緊湊的數據結構能更好地利用緩存。
更好的緩存利用率可帶來更好的性能。
函數調用不是無開銷的。
調用函數時會發生三件事。
創建一個新的 棧幀 ,並記錄調用者的詳細信息。
在函數調用期間可能被覆蓋的任何寄存器都將保存到棧中。
處理器計算函數的地址並執行到該新地址的分支。
由於函數調用是非常常見的操作,因此 CPU 設計師一直在努力優化此過程,但他們無法消除開銷。
函調固有開銷,或重於泰山,或輕於鴻毛,這取決於函數做了什麼。
減少函數調用開銷的解決方案是 內聯 。
Go 編譯器通過將函數體視為調用者的一部分來內聯函數。
內聯也有成本,它增加了二進位文件大小。
只有當調用開銷與函數所做工作關聯度的很大時內聯才有意義,因此只有簡單的函數才能用於內聯。
複雜的函數通常不受調用它們的開銷所支配,因此不會內聯。
這個例子顯示函數 Double
調用 util.Max
。
為了減少調用 util.Max
的開銷,編譯器可以將 util.Max
內聯到 Double
中,就象這樣
內聯後不再調用 util.Max
,但是 Double
的行為沒有改變。
內聯並不是 Go 獨有的。幾乎每種編譯或及時編譯的語言都執行此優化。但是 Go 的內聯是如何實現的?
Go 實現非常簡單。編譯包時,會標記任何適合內聯的小函數,然後照常編譯。
然後函數的源代碼和編譯後版本都會被存儲。
此幻燈片顯示了 util.a
的內容。源代碼已經過一些轉換,以便編譯器更容易快速處理。
當編譯器編譯 Double
時,它看到 util.Max
可內聯的,並且 util.Max
的源代碼是可用的。
就會替換原函數中的代碼,而不是插入對 util.Max
的編譯版本的調用。
擁有該函數的源代碼可以實現其他優化。
在這個例子中,儘管函數 Test
總是返回 false
,但 Expensive
在不執行它的情況下無法知道結果。
當 Test
被內聯時,我們得到這樣的東西。
編譯器現在知道 Expensive
的代碼無法訪問。
這不僅節省了調用 Test
的成本,還節省了編譯或運行任何現在無法訪問的 Expensive
代碼。
Go 編譯器可以跨文件甚至跨包自動內聯函數。還包括從標準庫調用的可內聯函數的代碼。
強制垃圾回收 使 Go 成為一種更簡單,更安全的語言。
這並不意味著垃圾回收會使 Go 變慢,或者垃圾回收是程序速度的瓶頸。
這意味著在堆上分配的內存是有代價的。每次 GC 運行時都會花費 CPU 時間,直到釋放內存為止。
然而,有另一個地方分配內存,那就是棧。
與 C 不同,它強制您選擇是否將值通過 malloc
將其存儲在堆上,還是通過在函數範圍內聲明將其儲存在棧上;Go 實現了一個名為 逃逸分析 的優化。
逃逸分析決定了對一個值的任何引用是否會從被聲明的函數中逃逸。
如果沒有引用逃逸,則該值可以安全地存儲在棧中。
存儲在棧中的值不需要分配或釋放。
讓我們看一些例子
Sum
返回 1 到 100 的整數的和。這是一種相當不尋常的做法,但它說明了逃逸分析的工作原理。
因為切片 numbers
僅在 Sum
內引用,所以編譯器將安排到棧上來存儲的 100 個整數,而不是安排到堆上。
沒有必要回收 numbers
,它會在 Sum
返回時自動釋放。
第二個例子也有點尬。在 CenterCursor
中,我們創建一個新的 Cursor
對象並在 c
中存儲指向它的指針。
然後我們將 c
傳遞給 Center()
函數,它將 Cursor
移動到屏幕的中心。
最後我們列印出那個 『Cursor` 的 X 和 Y 坐標。
即使 c
被 new
函數分配了空間,它也不會存儲在堆上,因為沒有引用 c
的變數逃逸 CenterCursor
函數。
默認情況下,Go 的優化始終處於啟用狀態。可以使用 -gcflags = -m
開關查看編譯器的逃逸分析和內聯決策。
因為逃逸分析是在編譯時執行的,而不是運行時,所以無論垃圾回收的效率如何,棧分配總是比堆分配快。
我將在本演講的其餘部分詳細討論棧。
Go 有 goroutine。 這是 Go 並發的基石。
我想退一步,探索 goroutine 的歷史。
最初,計算機一次運行一個進程。在 60 年代,多進程或 分時 的想法變得流行起來。
在分時系統中,操作系統必須通過保護當前進程的現場,然後恢復另一個進程的現場,不斷地在這些進程之間切換 CPU 的注意力。
這稱為 進程切換。
進程切換有三個主要開銷。
首先,內核需要保護該進程的所有 CPU 寄存器的現場,然後恢復另一個進程的現場。
內核還需要將 CPU 的映射從虛擬內存刷新到物理內存,因為這些映射僅對當前進程有效。
最後是操作系統 上下文切換 的成本,以及 調度函數 選擇佔用 CPU 的下一個進程的開銷。
現代處理器中有數量驚人的寄存器。我很難在一張幻燈片上排開它們,這可以讓你知道保護和恢復它們需要多少時間。
由於進程切換可以在進程執行的任何時刻發生,因此操作系統需要存儲所有寄存器的內容,因為它不知道當前正在使用哪些寄存器。
這導致了線程的出生,這些線程在概念上與進程相同,但共享相同的內存空間。
由於線程共享地址空間,因此它們比進程更輕,因此創建速度更快,切換速度更快。
Goroutine 升華了線程的思想。
Goroutine 是 協作式調度 的,而不是依靠內核來調度。
當對 Go 運行時調度器 進行顯式調用時,goroutine 之間的切換僅發生在明確定義的點上。
編譯器知道正在使用的寄存器並自動保存它們。
雖然 goroutine 是協作式調度的,但運行時會為你處理。
Goroutine 可能會給禪讓給其他協程時刻是:
- 阻塞式通道發送和接收。
- Go 聲明,雖然不能保證會立即調度新的 goroutine。
- 文件和網路操作式的阻塞式系統調用。
- 在被垃圾回收循環停止後。
這個例子說明了上一張幻燈片中描述的一些調度點。
箭頭所示的線程從左側的 ReadFile
函數開始。遇到 os.Open
,它在等待文件操作完成時阻塞線程,因此調度器將線程切換到右側的 goroutine。
繼續執行直到從通道 c
中讀,並且此時 os.Open
調用已完成,因此調度器將線程切換回左側並繼續執行 file.Read
函數,然後又被文件 IO 阻塞。
調度器將線程切換回右側以進行另一個通道操作,該操作在左側運行期間已解鎖,但在通道發送時再次阻塞。
最後,當 Read
操作完成並且數據可用時,線程切換回左側。
這張幻燈片顯示了低級語言描述的 runtime.Syscall
函數,它是 os
包中所有函數的基礎。
只要你的代碼調用操作系統,就會通過此函數。
對 entersyscall
的調用通知運行時該線程即將阻塞。
這允許運行時啟動一個新線程,該線程將在當前線程被阻塞時為其他 goroutine 提供服務。
這導致每 Go 進程的操作系統線程相對較少,Go 運行時負責將可運行的 Goroutine 分配給空閑的操作系統線程。
在上一節中,我討論了 goroutine 如何減少管理許多(有時是數十萬個並發執行線程)的開銷。
Goroutine故事還有另一面,那就是棧管理,它引導我進入我的最後一個話題。
這是一個進程的內存布局圖。我們感興趣的關鍵是堆和棧的位置。
傳統上,在進程的地址空間內,堆位於內存的底部,位於程序(代碼)的上方並向上增長。
棧位於虛擬地址空間的頂部,並向下增長。
因為堆和棧相互覆蓋的結果會是災難性的,操作系統通常會安排在棧和堆之間放置一個不可寫內存區域,以確保如果它們發生碰撞,程序將中止。
這稱為保護頁,有效地限制了進程的棧大小,通常大約為幾兆位元組。
我們已經討論過線程共享相同的地址空間,因此對於每個線程,它必須有自己的棧。
由於很難預測特定線程的棧需求,因此為每個線程的棧和保護頁面保留了大量內存。
希望是這些區域永遠不被使用,而且防護頁永遠不會被擊中。
缺點是隨著程序中線程數的增加,可用地址空間的數量會減少。
我們已經看到 Go 運行時將大量的 goroutine 調度到少量線程上,但那些 goroutines 的棧需求呢?
Go 編譯器不使用保護頁,而是在每個函數調用時插入一個檢查,以檢查是否有足夠的棧來運行該函數。如果沒有,運行時可以分配更多的棧空間。
由於這種檢查,goroutines 初始棧可以做得更小,這反過來允許 Go 程序員將 goroutines 視為廉價資源。
這是一張顯示了 Go 1.2 如何管理棧的幻燈片。
當 G
調用 H
時,沒有足夠的空間讓 H
運行,所以運行時從堆中分配一個新的棧幀,然後在新的棧段上運行 H
。當 H
返回時,棧區域返回到堆,然後返回到 G
。
這種管理棧的方法通常很好用,但對於某些類型的代碼,通常是遞歸代碼,它可能導致程序的內部循環跨越這些棧邊界之一。
例如,在程序的內部循環中,函數 G
可以在循環中多次調用 H
,
每次都會導致棧拆分。 這被稱為 熱分裂 問題。
為了解決熱分裂問題,Go 1.3 採用了一種新的棧管理方法。
如果 goroutine 的棧太小,則不會添加和刪除其他棧段,而是分配新的更大的棧。
舊棧的內容被複制到新棧,然後 goroutine 使用新的更大的棧繼續運行。
在第一次調用 H
之後,棧將足夠大,對可用棧空間的檢查將始終成功。
這解決了熱分裂問題。
值,內聯,逃逸分析,Goroutines 和分段/複製棧。
這些是我今天選擇談論的五個特性,但它們絕不是使 Go 成為快速的語言的唯一因素,就像人們引用他們學習 Go 的理由的三個原因一樣。
這五個特性一樣強大,它們不是孤立存在的。
例如,運行時將 goroutine 復用到線程上的方式在沒有可擴展棧的情況下幾乎沒有效率。
內聯通過將較小的函數組合成較大的函數來降低棧大小檢查的成本。
逃逸分析通過自動將從實例從堆移動到棧來減少垃圾回收器的壓力。
逃逸分析還提供了更好的 緩存局部性 。
如果沒有可增長的棧,逃逸分析可能會對棧施加太大的壓力。
- 感謝 Gocon 主辦方允許我今天發言
- twitter / web / email details
- 感謝 @offbymany,@billkennedy_go 和 Minux 在準備這個演講的過程中所提供的幫助。
相關文章:
作者簡介:
David 是來自澳大利亞悉尼的程序員和作者。
自 2011 年 2 月起成為 Go 的 contributor,自 2012 年 4 月起成為 committer。
聯繫信息
- dave@cheney.net
- twitter: @davecheney
via: https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast
作者:Dave Cheney 譯者:houbaron 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive