Go 是如何確保內(nèi)存安全的?

??這篇文章基于 Go 1.13 編寫。
Go 的一系列內(nèi)存管理手段(內(nèi)存分配,垃圾回收,內(nèi)存訪問檢查)使許多開發(fā)者的開發(fā)工作變得很輕松。編譯器通過在代碼中引入“邊界檢查” 來確保安全地訪問內(nèi)存。
生成的指令
Go 引入了一些控制點位,來確保我們的程序訪問的內(nèi)存片段安全且有效的。讓我們從一個簡單的例子開始:
package?main
func?main()?{
????list?:=?[]int{1,?2,?3}
????printList(list)
}
func?printList(list?[]int)?{
????println(list[2])
????println(list[3])
}
這段代碼跑起來之后會 panic:
3
panic:?runtime?error:?index?out?of?range?[3]?with?length?3
Go 通過添加邊界檢查來防止不正確的內(nèi)存訪問
如果你想知道沒有這些檢查會怎么樣,你可以使用 -gcflags="-B" 的選項,輸出如下
3
824633993168
因為這塊內(nèi)存是無效的,它會讀取不屬于這個 slice 的下一個 bytes。
利用命令 go tool compile -S main.go 來生成對應的匯編[1]代碼,就可以看到這些檢查點:
0x0021?00033?(main.go:10)??MOVQ???"".list+48(SP),?CX
0x0026?00038?(main.go:10)??CMPQ???CX,?$2
0x002a?00042?(main.go:10)??JLS????161
[...]?here?Go?prints?the?third?element
0x0057?00087?(main.go:11)??MOVQ???"".list+48(SP),?CX
0x005c?00092?(main.go:11)??CMPQ???CX,?$3
0x0060?00096?(main.go:11)??JLS????151
[...]
0x0096?00150?(main.go:12)??RET
0x0097?00151?(main.go:11)??MOVL???$3,?AX
0x009c?00156?(main.go:11)??CALL???runtime.panicIndex(SB)
0x00a1?00161?(main.go:10)??MOVL???$2,?AX
0x00a6?00166?(main.go:10)??CALL???runtime.panicIndex(SB)
Go 先使用 MOVQ 指令將 list 變量的長度放入寄存器 CX 中
0x0021?00033?(main.go:10)??MOVQ???"".list+48(SP),?CX
友情提醒,slice 類型的變量由三部分組成,指向底層數(shù)組的指針、長度,容量(capacity)。list 變量在棧中的位置如下圖:

通過將棧指針移動 48 個字節(jié)就可以訪問長度
下一條指令將 slice 的長度與程序即將訪問的偏移量進行比較

CMPQ 指令會將兩個值相減,并在下一條指令中與 0 進行比較。如果 slice 的長度(寄存器 CX)減去要訪問的偏移量(在這個例子當中是 2)小于或等于 0(JLS 是 Jump on lower or the same 的縮寫),程序就會跳到 161 處繼續(xù)執(zhí)行。

兩種邊界檢查使用的都是相同的指令。除了看生成的匯編代碼,Go 提供了一個編譯期的通行證去打印出邊界檢查的點,你可以在 build 和 run 的時候使用標志 -gcflags="-d=ssa/check_bce/debug=1" 去開啟。輸出如下:
./main.go:10:14:?Found?IsInBounds
./main.go:11:14:?Found?IsInBounds
我們可以看到輸出里生成了兩個檢查點。不過 Go 編譯器足夠聰明,在不需要的情況下,它不會生成邊界檢查的指令。
規(guī)則
在每次訪問內(nèi)存的時候都生成檢查指令是非常低效的,讓我們稍微修改一下前面的例子。
package?main
func?main()?{
????list?:=?[]int{1,?2,?3}
????printList(list)
}
func?printList(list?[]int)?{
????println(list[3])
????println(list[2])
}
兩個 println 指令對調(diào)了,用 check_bce 標志再去跑一遍程序,這次只有一處邊界檢查:
./main.go:11:14:?Found?IsInBounds
程序先檢查了偏移量 3 。如果是有效的,那么 2 很明顯也是有效的,沒必要再去檢查了??梢酝ㄟ^命令 GOSSAFUNC=printList Go run main.go 來生成 SSA 代碼看編譯過程。這張圖就是生成的帶邊界檢查的 SSA 代碼:

里面的 prove pass 將邊界檢查標記為移除,這樣后面的 pass 將會收集這些 dead code:

用這條命令 GOSSAFUNC=printList Go run -gcflags="-d=ssa/prove/debug=3" main.go 可以把 pass 背后的邏輯打印出來,它也會生成 SSA 文件來幫助你 debug,接下來看命令的輸出:

這個 pass 實際上會采取不同的策略,并建立了 fact 表。這些 fact 決定了矛盾點在哪里。在我們這個例子里,我們可以通過 SSA 的 pass 來解讀這些規(guī)則:

第一個階段從代表指令 println(list[3]) 的分析塊 b1 開始,這個指令有兩種可能:
偏移量 [3]在邊界中,跳到第二個指令 b2。在這個例子中,Go 指定 v7 的限制(slice 的長度)是[4, max(int)]。偏移量 [3不在邊界中, 程序跳轉(zhuǎn)到 b3 指令并 panic。
接下來,Go 開始處理 b2 塊(第二個指令)。這里也有兩種可能
偏移量 [2]在邊界中,這意味著 slice 的長度v7比v23(偏移量[2]) 要大。在先前的 b1 塊中 Go 已經(jīng)判斷了v7 > 4, 所以這個已經(jīng)被確認了。偏移量 [2] 不在邊界中,這意味著它比 slice 的長度 v7更大,但v7的限制是[4, max(int)],所以 Go 會將這個分之標記為矛盾,意味著這種情況永遠不會發(fā)生,這條指令的邊界檢查可以被移除。
這個 pass 在隨著時間不斷地改善,現(xiàn)在可以參考更多的 case[2]。消除邊界檢查可以略微提升 Go 程序的運行速度,但除非你的程序是微妙級敏感的,不然沒有必要去優(yōu)化它。
via: https://medium.com/a-journey-with-go/go-memory-safety-with-bounds-check-1397bef748b5
作者: Vincent Blanchon[3]譯者:yxlimo[4]校對:Alex.Jiang[5]本文由 GCTT[6] 原創(chuàng)編譯,Go 中文網(wǎng)[7] 榮譽推出
參考資料
匯編: https://golang.org/doc/asm
[2]更多的 case: https://github.com/golang/go/blob/master/test/prove.go
[3]Vincent Blanchon: https://medium.com/@blanchon.vincent
[4]yxlimo: https://github.com/yxlimo
[5]Alex.Jiang: https://github.com/JYSDeveloper
[6]GCTT: https://github.com/studygolang/GCTT
[7]Go 中文網(wǎng): https://studygolang.com/
推薦閱讀
