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

??這篇文章基于 Go 1.13 編寫。
Go 的一系列內(nèi)存管理手段(內(nèi)存分配,垃圾回收,內(nèi)存訪問檢查)使許多開發(fā)者的開發(fā)工作變得很輕松。編譯器通過在代碼中引入“邊界檢查” 來確保安全地訪問內(nèi)存。
生成的指令
Go 引入了一些控制點(diǎn)位,來確保我們的程序訪問的內(nèi)存片段安全且有效的。讓我們從一個(gè)簡(jiǎn)單的例子開始:
package?main
func?main()?{
????list?:=?[]int{1,?2,?3}
????printList(list)
}
func?printList(list?[]int)?{
????println(list[2])
????println(list[3])
}
這段代碼跑起來之后會(huì) panic:
3
panic:?runtime?error:?index?out?of?range?[3]?with?length?3
Go 通過添加邊界檢查來防止不正確的內(nèi)存訪問
如果你想知道沒有這些檢查會(huì)怎么樣,你可以使用 -gcflags="-B" 的選項(xiàng),輸出如下
3
824633993168
因?yàn)檫@塊內(nèi)存是無(wú)效的,它會(huì)讀取不屬于這個(gè) slice 的下一個(gè) bytes。
利用命令 go tool compile -S main.go 來生成對(duì)應(yīng)的匯編[1]代碼,就可以看到這些檢查點(diǎn):
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 變量的長(zhǎng)度放入寄存器 CX 中
0x0021?00033?(main.go:10)??MOVQ???"".list+48(SP),?CX
友情提醒,slice 類型的變量由三部分組成,指向底層數(shù)組的指針、長(zhǎng)度,容量(capacity)。list 變量在棧中的位置如下圖:

通過將棧指針移動(dòng) 48 個(gè)字節(jié)就可以訪問長(zhǎng)度
下一條指令將 slice 的長(zhǎng)度與程序即將訪問的偏移量進(jìn)行比較

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

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

里面的 prove pass 將邊界檢查標(biāo)記為移除,這樣后面的 pass 將會(huì)收集這些 dead code:

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

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

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