帶你了解五種加速Go的特性和如何實現它們
Anthony Starks 使用他出色的 Deck 演示工具重構了我原來的基于 Google Slides 的幻燈片。你可以在他的博客上查看他重構后的幻燈片,
mindchunk.blogspot.com.au/2014/06/remixing-with-deck。
我最近被邀請在 Gocon 發表演講,這是一個每半年在日本東京舉行的 Go 的精彩大會。Gocon 2014 是一個完全由社區驅動的為期一天的活動,由培訓和一整個下午的圍繞著生產環境中的 Go 這個主題的演講組成。(LCTT 譯注:本文發表于 2014 年)
以下是我的講義。原文的結構能讓我緩慢而清晰的演講,因此我已經編輯了它使其更可讀。 我要感謝 Bill Kennedy 和 Minux Ma,特別是 Josh Bleecher Snyder,感謝他們在我準備這次演講中的幫助。 大家下午好。 我叫 David. 我很高興今天能來到 Gocon。我想參加這個會議已經兩年了,我很感謝主辦方能提供給我向你們演講的機會。 Gocon 2014 我想以一個問題開始我的演講。 為什么選擇 Go? 當大家討論學習或在生產環境中使用 Go 的原因時,答案不一而足,但因為以下三個原因的最多。 Gocon 2014 這就是 TOP3 的原因。 ***,并發。 Go 的 并發原語 對于來自 Nodejs,Ruby 或 Python 等單線程腳本語言的程序員,或者來自 C++ 或 Java 等重量級線程模型的語言都很有吸引力。 易于部署。 我們今天從經驗豐富的 Gophers 那里聽說過,他們非常欣賞部署 Go 應用的簡單性。 Gocon 2014 然后是性能。 我相信人們選擇 Go 的一個重要原因是它 快。 Gocon 2014 (4) 在今天的演講中,我想討論五個有助于提高 Go 性能的特性。 我還將與大家分享 Go 如何實現這些特性的細節。 Gocon 2014 (5) 我要談的***個特性是 Go 對于值的高效處理和存儲。 Gocon 2014 (6) 這是 Go 中一個值的例子。編譯時, 讓我們將 Go 與其他一些語言進行比較 Gocon 2014 (7) 由于 Python 表示變量的方式的開銷,使用 Python 存儲相同的值會消耗六倍的內存。 Python 使用額外的內存來跟蹤類型信息,進行 引用計數 等。 讓我們看另一個例子: Gocon 2014 (8) 與 Go 類似,Java 消耗 4 個字節的內存來存儲 但是,要在像 Gocon 2014 (9) 因此,Java 中的整數通常消耗 16 到 24 個字節的內存。 為什么這很重要? 內存便宜且充足,為什么這個開銷很重要? Gocon 2014 (10) 這是一張顯示 CPU 時鐘速度與內存總線速度的圖表。 請注意 CPU 時鐘速度和內存總線速度之間的差距如何繼續擴大。 兩者之間的差異實際上是 CPU 花費多少時間等待內存。 Gocon 2014 (11) 自 1960 年代后期以來,CPU 設計師已經意識到了這個問題。 他們的解決方案是一個緩存,一個更小、更快的內存區域,介入 CPU 和主存之間。 Gocon 2014 (12) 這是一個 我們可以使用這種類型來構造一個容納 1000 個 在數組內部, 這很重要,因為現在所有 1000 個 Gocon 2014 (13) Go 允許您創建緊湊的數據結構,避免不必要的填充字節。 緊湊的數據結構能更好地利用緩存。 更好的緩存利用率可帶來更好的性能。 Gocon 2014 (14) 函數調用不是無開銷的。 Gocon 2014 (15) 調用函數時會發生三件事。 創建一個新的 棧幀,并記錄調用者的詳細信息。 在函數調用期間可能被覆蓋的任何寄存器都將保存到棧中。 處理器計算函數的地址并執行到該新地址的分支。 Gocon 2014 (16) 由于函數調用是非常常見的操作,因此 CPU 設計師一直在努力優化此過程,但他們無法消除開銷。 函調固有開銷,或重于泰山,或輕于鴻毛,這取決于函數做了什么。 減少函數調用開銷的解決方案是 內聯。 Gocon 2014 (17) Go 編譯器通過將函數體視為調用者的一部分來內聯函數。 內聯也有成本,它增加了二進制文件大小。 只有當調用開銷與函數所做工作關聯度的很大時內聯才有意義,因此只有簡單的函數才能用于內聯。 復雜的函數通常不受調用它們的開銷所支配,因此不會內聯。 Gocon 2014 (18) 這個例子顯示函數 為了減少調用 Gocon 2014 (19) 內聯后不再調用 內聯并不是 Go 獨有的。幾乎每種編譯或及時編譯的語言都執行此優化。但是 Go 的內聯是如何實現的? Go 實現非常簡單。編譯包時,會標記任何適合內聯的小函數,然后照常編譯。 然后函數的源代碼和編譯后版本都會被存儲。 Gocon 2014 (20) 此幻燈片顯示了 當編譯器編譯 就會替換原函數中的代碼,而不是插入對 擁有該函數的源代碼可以實現其他優化。 Gocon 2014 (21) 在這個例子中,盡管函數 當 Gocon 2014 (22) 編譯器現在知道 這不僅節省了調用 Go 編譯器可以跨文件甚至跨包自動內聯函數。還包括從標準庫調用的可內聯函數的代碼。 Gocon 2014 (23) 強制垃圾回收 使 Go 成為一種更簡單,更安全的語言。 這并不意味著垃圾回收會使 Go 變慢,或者垃圾回收是程序速度的瓶頸。 這意味著在堆上分配的內存是有代價的。每次 GC 運行時都會花費 CPU 時間,直到釋放內存為止。 Gocon 2014 (24) 然而,有另一個地方分配內存,那就是棧。 與 C 不同,它強制您選擇是否將值通過 Gocon 2014 (25) 逃逸分析決定了對一個值的任何引用是否會從被聲明的函數中逃逸。 如果沒有引用逃逸,則該值可以安全地存儲在棧中。 存儲在棧中的值不需要分配或釋放。 讓我們看一些例子 Gocon 2014 (26) 因為切片 沒有必要回收 Gocon 2014 (27) 第二個例子也有點尬。在 然后我們將 ***我們打印出那個 ‘Cursor` 的 X 和 Y 坐標。 即使 Gocon 2014 (28) 默認情況下,Go 的優化始終處于啟用狀態。可以使用 因為逃逸分析是在編譯時執行的,而不是運行時,所以無論垃圾回收的效率如何,棧分配總是比堆分配快。 我將在本演講的其余部分詳細討論棧。 Gocon 2014 (29) Go 有 goroutine。 這是 Go 并發的基石。 我想退一步,探索 goroutine 的歷史。 最初,計算機一次運行一個進程。在 60 年代,多進程或 分時 的想法變得流行起來。 在分時系統中,操作系統必須通過保護當前進程的現場,然后恢復另一個進程的現場,不斷地在這些進程之間切換 CPU 的注意力。 這稱為 進程切換。 Gocon 2014 (30) 進程切換有三個主要開銷。 首先,內核需要保護該進程的所有 CPU 寄存器的現場,然后恢復另一個進程的現場。 內核還需要將 CPU 的映射從虛擬內存刷新到物理內存,因為這些映射僅對當前進程有效。 ***是操作系統 上下文切換 的成本,以及 調度函數 選擇占用 CPU 的下一個進程的開銷。 Gocon 2014 (31) 現代處理器中有數量驚人的寄存器。我很難在一張幻燈片上排開它們,這可以讓你知道保護和恢復它們需要多少時間。 由于進程切換可以在進程執行的任何時刻發生,因此操作系統需要存儲所有寄存器的內容,因為它不知道當前正在使用哪些寄存器。 Gocon 2014 (32) 這導致了線程的出生,這些線程在概念上與進程相同,但共享相同的內存空間。 由于線程共享地址空間,因此它們比進程更輕,因此創建速度更快,切換速度更快。 Gocon 2014 (33) Goroutine 升華了線程的思想。 Goroutine 是 協作式調度的,而不是依靠內核來調度。 當對 Go 運行時調度器 進行顯式調用時,goroutine 之間的切換僅發生在明確定義的點上。 編譯器知道正在使用的寄存器并自動保存它們。 Gocon 2014 (34) 雖然 goroutine 是協作式調度的,但運行時會為你處理。 Goroutine 可能會給禪讓給其他協程時刻是: Gocon 2014 (35) 這個例子說明了上一張幻燈片中描述的一些調度點。 箭頭所示的線程從左側的 繼續執行直到從通道 調度器將線程切換回右側以進行另一個通道操作,該操作在左側運行期間已解鎖,但在通道發送時再次阻塞。 ***,當 Gocon 2014 (36) 這張幻燈片顯示了低級語言描述的 只要你的代碼調用操作系統,就會通過此函數。 對 這允許運行時啟動一個新線程,該線程將在當前線程被阻塞時為其他 goroutine 提供服務。 這導致每 Go 進程的操作系統線程相對較少,Go 運行時負責將可運行的 Goroutine 分配給空閑的操作系統線程。 Gocon 2014 (37) 在上一節中,我討論了 goroutine 如何減少管理許多(有時是數十萬個并發執行線程)的開銷。 Goroutine故事還有另一面,那就是棧管理,它引導我進入我的***一個話題。 Gocon 2014 (38) 這是一個進程的內存布局圖。我們感興趣的關鍵是堆和棧的位置。 傳統上,在進程的地址空間內,堆位于內存的底部,位于程序(代碼)的上方并向上增長。 棧位于虛擬地址空間的頂部,并向下增長。 Gocon 2014 (39) 因為堆和棧相互覆蓋的結果會是災難性的,操作系統通常會安排在棧和堆之間放置一個不可寫內存區域,以確保如果它們發生碰撞,程序將中止。 這稱為保護頁,有效地限制了進程的棧大小,通常大約為幾兆字節。 Gocon 2014 (40) 我們已經討論過線程共享相同的地址空間,因此對于每個線程,它必須有自己的棧。 由于很難預測特定線程的棧需求,因此為每個線程的棧和保護頁面保留了大量內存。 希望是這些區域永遠不被使用,而且防護頁永遠不會被擊中。 缺點是隨著程序中線程數的增加,可用地址空間的數量會減少。 Gocon 2014 (41) 我們已經看到 Go 運行時將大量的 goroutine 調度到少量線程上,但那些 goroutines 的棧需求呢? Go 編譯器不使用保護頁,而是在每個函數調用時插入一個檢查,以檢查是否有足夠的棧來運行該函數。如果沒有,運行時可以分配更多的棧空間。 由于這種檢查,goroutines 初始棧可以做得更小,這反過來允許 Go 程序員將 goroutines 視為廉價資源。 Gocon 2014 (42) 這是一張顯示了 Go 1.2 如何管理棧的幻燈片。 當 Gocon 2014 (43) 這種管理棧的方法通常很好用,但對于某些類型的代碼,通常是遞歸代碼,它可能導致程序的內部循環跨越這些棧邊界之一。 例如,在程序的內部循環中,函數 每次都會導致棧拆分。 這被稱為 熱分裂 問題。 Gocon 2014 (44) 為了解決熱分裂問題,Go 1.3 采用了一種新的棧管理方法。 如果 goroutine 的棧太小,則不會添加和刪除其他棧段,而是分配新的更大的棧。 舊棧的內容被復制到新棧,然后 goroutine 使用新的更大的棧繼續運行。 在***次調用 這解決了熱分裂問題。 Gocon 2014 (45) 值,內聯,逃逸分析,Goroutines 和分段/復制棧。 這些是我今天選擇談論的五個特性,但它們絕不是使 Go 成為快速的語言的唯一因素,就像人們引用他們學習 Go 的理由的三個原因一樣。 這五個特性一樣強大,它們不是孤立存在的。 例如,運行時將 goroutine 復用到線程上的方式在沒有可擴展棧的情況下幾乎沒有效率。 內聯通過將較小的函數組合成較大的函數來降低棧大小檢查的成本。 逃逸分析通過自動將從實例從堆移動到棧來減少垃圾回收器的壓力。 逃逸分析還提供了更好的 緩存局部性。 如果沒有可增長的棧,逃逸分析可能會對棧施加太大的壓力。 Gocon 2014 (46) gocon
正好消耗四個字節的內存。int
型。List
或 Map
這樣的集合中使用此值,編譯器必須將其轉換為 Integer
對象。Location
類型,它保存物體在三維空間中的位置。它是用 Go 編寫的,因此每個 Location
只消耗 24 個字節的存儲空間。Location
的數組類型,它只消耗 24000 字節的內存。Location
結構體是順序存儲的,而不是隨機存儲的 1000 個 Location
結構體的指針。Location
結構體都按順序放在緩存中,緊密排列在一起。Double
調用 util.Max
。util.Max
的開銷,編譯器可以將 util.Max
內聯到 Double
中,就象這樣util.Max
,但是 Double
的行為沒有改變。util.a
的內容。源代碼已經過一些轉換,以便編譯器更容易快速處理。Double
時,它看到 util.Max
可內聯的,并且 util.Max
的源代碼是可用的。util.Max
的編譯版本的調用。Test
總是返回 false
,但 Expensive
在不執行它的情況下無法知道結果。Test
被內聯時,我們得到這樣的東西。Expensive
的代碼無法訪問。Test
的成本,還節省了編譯或運行任何現在無法訪問的 Expensive
代碼。malloc
將其存儲在堆上,還是通過在函數范圍內聲明將其儲存在棧上;Go 實現了一個名為 逃逸分析 的優化。Sum
返回 1 到 100 的整數的和。這是一種相當不尋常的做法,但它說明了逃逸分析的工作原理。numbers
僅在 Sum
內引用,所以編譯器將安排到棧上來存儲的 100 個整數,而不是安排到堆上。numbers
,它會在 Sum
返回時自動釋放。CenterCursor
中,我們創建一個新的 Cursor
對象并在 c
中存儲指向它的指針。c
傳遞給 Center()
函數,它將 Cursor
移動到屏幕的中心。c
被 new
函數分配了空間,它也不會存儲在堆上,因為沒有引用 c
的變量逃逸 CenterCursor
函數。-gcflags = -m
開關查看編譯器的逃逸分析和內聯決策。
ReadFile
函數開始。遇到 os.Open
,它在等待文件操作完成時阻塞線程,因此調度器將線程切換到右側的 goroutine。c
中讀,并且此時 os.Open
調用已完成,因此調度器將線程切換回左側并繼續執行 file.Read
函數,然后又被文件 IO 阻塞。Read
操作完成并且數據可用時,線程切換回左側。runtime.Syscall
函數,它是 os
包中所有函數的基礎。entersyscall
的調用通知運行時該線程即將阻塞。G
調用 H
時,沒有足夠的空間讓 H
運行,所以運行時從堆中分配一個新的棧幀,然后在新的棧段上運行 H
。當 H
返回時,棧區域返回到堆,然后返回到 G
。G
可以在循環中多次調用 H
,H
之后,棧將足夠大,對可用棧空間的檢查將始終成功。