Go 編譯器 nil 指標檢查
簡介
我在思考編譯器是如何保護我們寫的程式碼。無效的記憶體訪問檢查是編譯器新增到程式碼中的一種安全檢查。我們可能會認為這種“額外的程式碼”會損耗程式的效能,甚至可能需要數十億的迭代操作。但是,這些檢查可以防止程式碼對正在執行的系統造成損害。編譯器本質上是指出和查詢錯誤,使我們編寫的程式碼在執行時更安全。
基於上述考慮,同時 Go 語言想要達成更快的編譯速度,如果硬體可以解決這些問題,那麼 Go 語言編譯器就會使用硬體來解決問題。其中一種情況是檢測無效的記憶體訪問。有時編譯器會在程式碼中新增 nil 指標檢查,而有時不會。在這篇部落格中,我們將探討一種情況,即編譯器在什麼情況下讓硬體來檢測無效的記憶體訪問,以及在什麼情況下會新增 nil 指標檢查。
硬體只作檢查
當編譯器依賴於硬體來檢查並指出無效的記憶體訪問時,編譯器可以生成更少的程式碼,以提高程式效能。如果我們的程式碼嘗試讀取或寫入地址 0x0,則硬體將丟擲一個異常,該異常將被 Go 執行時捕獲並以panic
的形式反饋給我們的程式。如果panic
沒有恢復,則產生堆疊跟蹤資訊。
如下是一個嘗試對 0x0 記憶體地址進行寫入資料示例,以及產生的panic
和堆疊跟蹤資訊:
01 package main 02 03 func main() { 04var p *int// 宣告一個 nil 值指標 05*p = 10// 將 10 寫入地址 0x0 06 } panic: runtime error: invalid memory address or nil pointer dereference [signal 0xb code=0x1 addr=0x0 pc=0x2007] goroutine 16 [running]: runtime.panic(0x28600, 0x51744) /go/src/pkg/runtime/panic.c:279 +0xf5 main.main() /Go/Projects/src/github.com/goinaction/code/temp/main.go:5 +0x7 goroutine 17 [runnable]: runtime.MHeap_Scavenger() /go/src/pkg/runtime/mheap.c:507 runtime.goexit() /go/src/pkg/runtime/proc.c:1445
讓我們看看由 6g 編譯器在 darwin/amd64 機器上的生成的彙編程式碼(譯者注:6g 是 Golang 在 amd64 架構機器上的編譯器,由於 Go 語言的版本關係,1.5 之後的版本中 6g/8g 已被取代,應該統一使用go tool compile -S main.go
):
go tool 6g -S main.go 04var p *int 0x0004 00004 (main.go:4)MOVQ $0,AX 05*p = 10 0x0007 00007 (main.go:5)NOP , 0x0007 00007 (main.go:5)MOVQ $10,(AX)
在上面的彙編程式碼片段中,我們看到 0 的值被分配給 AX 暫存器,然後程式碼嘗試將值 10 寫入 AX 暫存器指向的儲存器。這會產生 panic 以及相應的堆疊跟蹤資訊。上述彙編程式碼中沒有顯示 nil 指標檢查,因為編譯器已將其交給硬體進行檢測和報告。
編譯器檢查
讓我們看一下編譯器生成 nil 指標檢查的示例:
01 package main 02 03 type S struct { 04b [4096]byte 05i int 06 } 07 08 func main() { 09s := new(S) 10s.i++ 11 }
在第 3 行到第 6 行,我們定義了 S 的結構型別,這個型別有兩個欄位。第一個欄位是 4096 位元組的陣列,第二個欄位是一個整數。然後在第 9 行的 main 函式中,我們建立一個 S 型別的值,並將該值的地址分配給名為 s 的指標變數。最後在第 10 行,我們將 s 例項的 i 欄位的值遞增 1。
讓我們看看由 6g 編譯器在 darwin/amd64 機器上生成的第 9 行、第 10 行的彙編程式碼(譯者注:同上譯註):
go tool 6g -S main.go 09s := new(S) 0x0036 00054 (main.go:9)LEAQ "".autotmp_0001+0(SP),DI 0x003a 00058 (main.go:9)MOVL $0,AX 0x003c 00060 (main.go:9)MOVQ $513,CX 0x0043 00067 (main.go:9)REP , 0x0044 00068 (main.go:9)STOSQ , 0x0046 00070 (main.go:9)LEAQ "".autotmp_0001+0(SP),BX 10s.i++ 0x004a 00074 (main.go:10)CMPQ BX,$0 0x004e 00078 (main.go:10)JEQ $1,105 0x0050 00080 (main.go:10)MOVQ 4096(BX),BP 0x0057 00087 (main.go:10)NOP , 0x0057 00087 (main.go:10)INCQ ,BP 0x005a 00090 (main.go:10)MOVQ BP,4096(BX) 0x0061 00097 (main.go:10)NOP ,
在我們的示例中,第 10 行程式碼需要下面的指標運算才能使其工作 :
10s.i++ 0x004a 00074 (main.go:10)CMPQ BX,$0 0x004e 00078 (main.go:10)JEQ $1,105 0x0050 00080 (main.go:10)MOVQ 4096(BX),BP 0x0057 00087 (main.go:10)NOP , 0x0057 00087 (main.go:10)INCQ ,BP 0x005a 00090 (main.go:10)MOVQ BP,4096(BX)
i 欄位記憶體地址位於 S 型別的值內的 4096 位元組偏移量。上面彙編程式碼中,BP 暫存器被分配到 BX (s 值 ) 的記憶體位置的值加上 4096 的偏移量(譯者注:基地址加上偏移量)。之後 BP 的值增加 1,新值分配在 BX + 4096 的記憶體地址中。(譯者注:0x0050 0008 至 0x005a 00090 程式碼片段)
在這個例子中,Go 編譯器在自增程式碼之前新增一個 nil 指標檢查:
10s.i++ 0x004a 00074 (main.go:10)CMPQ BX,$0 0x004e 00078 (main.go:10)JEQ $1,105 0x0050 00080 (main.go:10)MOVQ 4096(BX),BP 0x0057 00087 (main.go:10)NOP , 0x0057 00087 (main.go:10)INCQ ,BP 0x005a 00090 (main.go:10)MOVQ BP,4096(BX)
上述高亮程式碼(譯者注:0x004a 00074 至 0x004e 00078 程式碼片段,詳見原文)程式碼顯示了編譯器新增的 nil 指標檢查。將 BX 的值與 0 的值進行比較,如果它們相等,程式碼不會執行下面的自增程式碼片段。
問題是,為什麼 Go 在這個例子中添加了一個 nil 指標檢查,而第一個例子中卻沒有新增?
新增 nil 檢查的情形
讓我們對先前的例子做一點小修改:
01 package main 02 03 type S struct { 04i int// 交換欄位的順序 05b [4096]byte 06 } 07 08 func main() { 09s := new(S) 10s.i++ 11 }
我所做的只是交換結構中的欄位順序。這次 int 型別的 i 欄位在 4096 個元素的位元組陣列型別的 b 欄位之前。現在讓我們生成彙編程式碼,看看 nil 指標檢查是否仍然存在:
09s := new(S) 0x0036 00054 (main.go:9)LEAQ "".autotmp_0001+0(SP),DI 0x003a 00058 (main.go:9)MOVL $0,AX 0x003c 00060 (main.go:9)MOVQ $513,CX 0x0043 00067 (main.go:9)REP , 0x0044 00068 (main.go:9)STOSQ , 0x0046 00070 (main.go:9)LEAQ "".autotmp_0001+0(SP),BX 10s.i++ 0x004a 00074 (main.go:10)NOP , 0x004a 00074 (main.go:10)MOVQ (BX),BP 0x004d 00077 (main.go:10)NOP , 0x004d 00077 (main.go:10)INCQ ,BP 0x0050 00080 (main.go:10)MOVQ BP,(BX) 0x0053 00083 (main.go:10)NOP ,
如果將第 10 行的未修改的示例彙編程式碼與剛才修改了的(交換欄位後的)示例進行比較,你會發現有很大的區別:
First Example|Second Example CMPQ BX,$0|NOP JEQ $1,105|MOVQ (BX),BP MOVQ 4096(BX),BP|NOP , NOP ,|INCQ ,BP INCQ ,BP|MOVQ BP,(BX) MOVQ BP,4096(BX)|NOP ,
當我們將整數字段移動到結構中的第一位,nil 指標檢查就消失了。現在 Go 再次讓硬體來完成 nil 指標檢查,並且報告無效的記憶體訪問。
原因是 Go 可以信任硬體來識別和報告來自可能存在於 0x0 和 0xFFF 地址間的無效記憶體訪問。編譯器不會在這些情況下新增檢查。但是當代碼如果處理超出 4k 範圍的地址,nil 指標檢查就會被加入到彙編程式碼中。
在之前的例子中,當位元組陣列首先出現在結構體中時,整數字段的記憶體會分配超過 4k 邊界的偏移量。這會導致編譯器為整數字段新增 nil 指標檢查。當整數字段是第一個時,編譯器將其留給硬體進行檢測,因為(記憶體)地址在 4k 範圍內。
總結
我不相信編寫專注於效能的程式碼。通常我們編寫慣用、簡潔的程式碼,然後對程式進行基準測試,並根據需要進行效能調整。正如我們所看到的,結構體的設計會對程式的效能有一定的影響。訪問結構體中的欄位需要進行指標運算,對於大於 4K 的結構體,你組織欄位的方式可能會有所不同。
需要注意的是,我們看到的是編譯器實現細節,而不是 Go 規範的一部分。這可能會隨著 Go 的新版本釋出而發生改變,或者 5g、6g 或 8g 編譯器之間存在差異。在對實現細節進行效能調優時要小心,每一個新版本都需要重新檢查這些細節。有趣的是,當編譯器可以信任硬體時,它將如何使我們的程式碼更安全。
擴充套件材料
如果你想在標準庫中閱讀一些關於重新排序結構以消除欄位的 nil 指標檢查的示例,請看 Nigel Tao 的程式碼更改:https://codereview.appspot.com/6625058
此外還有 Russ Cox 於 2013 年 7 月撰寫的一份文件 ,"Go 1.2 欄位選擇器和 nil 檢查 ":http://golang.org/s/go12nil