《Go 語言程式設計》讀書筆記(十一)底層程式設計

KevinYan發表於2020-01-25

Go語言的設計包含了諸多安全策略,限制了可能導致程式執行出現錯誤的用法。編譯時型別檢查可以發現大多數型別不匹配的操作,例如兩個字串做減法的錯誤。字串、mapslicechan等所有的內建型別,都有嚴格的型別轉換規則。

對於無法靜態檢測到的錯誤,例如陣列訪問越界或使用空指標,執行時動態檢測可以保證程式在遇到問題的時候立即終止並列印相關的錯誤資訊。自動記憶體管理(垃圾記憶體自動回收)可以消除大部分野指標和記憶體洩漏相關的問題。

Go語言的實現刻意隱藏了很多底層細節。我們無法知道一個結構體真實的記憶體佈局,也無法獲取一個執行時函式對應的機器碼,也無法知道當前的goroutine是執行在哪個作業系統執行緒之上。事實上,Go語言的排程器會自己決定是否需要將某個goroutine從一個作業系統執行緒轉移到另一個作業系統執行緒。一個指向變數的指標也並沒有展示變數真實的地址。因為垃圾回收器可能會根據需要移動變數的記憶體位置,當然變數對應的地址也會被自動更新。

總的來說,Go語言的這些特性使得Go程式相比較低階的C語言來說更容易預測和理解,程式也不容易崩潰。透過隱藏底層的實現細節,也使得Go語言編寫的程式具有高度的可移植性,因為語言的語義在很大程度上是獨立於任何編譯器實現、作業系統和CPU系統結構的(當然也不是完全絕對獨立:例如int等型別就依賴於CPU機器字的大小,某些表示式求值的具體順序,還有編譯器實現的一些額外的限制等)。

有時候我們可能會放棄使用部分語言特性而優先選擇具有更好效能的方法,例如需要與其他語言編寫的庫互操作,或者用純Go語言無法實現的某些函式。

在本章,我們將展示如何使用unsafe包來擺脫Go語言規則帶來的限制,講述如何建立C語言函式庫的繫結,以及如何進行系統呼叫。

本章提供的方法不應該輕易使用(屬於黑魔法,雖然可能功能很強大,但是也容易誤傷到自己)。如果沒有處理好細節,它們可能導致各種不可預測的並且隱晦的錯誤,甚至連有經驗的的C語言程式設計師也無法理解這些錯誤。使用unsafe包的同時也放棄了Go語言保證與未來版本的相容性的承諾,因為它必然會在有意無意中會使用很多實現的細節,而這些實現的細節在未來的Go語言中很可能會被改變。

要注意的是,unsafe包是一個採用特殊方式實現的包。雖然它可以和普通包一樣的匯入和使用,但它實際上是由編譯器實現的。它提供了一些訪問語言內部特性的方法,特別是記憶體佈局相關的細節。將這些特性封裝到一個獨立的包中,是為在極少數情況下需要使用的時候,同時引起人們的注意(因為看包的名字就知道使用unsafe包是不安全的)。此外,有一些環境因為安全的因素可能限制這個包的使用。

不過unsafe包被廣泛地用於比較低階的包, 例如runtimeossyscall還有net包等,因為它們需要和作業系統密切配合,但是對於普通的程式一般是不需要使用unsafe包的。

unsafe.Sizeof, Alignof 和 Offsetof

unsafe.Sizeof函式返回運算元在記憶體中的位元組大小,引數可以是任意型別的表示式,但是它並不會對錶達式進行求值。一個Sizeof函式呼叫是一個對應uintptr型別的常量表示式,因此返回的結果可以用作陣列型別的長度大小,或者用作計算其他的常量。

import "unsafe"
fmt.Println(unsafe.Sizeof(float64(0))) // "8"

Sizeof函式返回的大小隻包括資料結構中固定的部分,例如字串對應結構體中的指標和字串長度部分,但是並不包含指標指向的字串的內容。Go語言中非聚合型別通常有一個固定的大小,儘管在不同工具鏈下生成的實際大小可能會有所不同。考慮到可移植性,引用型別或包含引用的型別在32位平臺上是4個位元組,在64位平臺上是8個位元組。

計算機在載入和儲存資料時,如果記憶體地址合理地對齊的將會更有效率。例如2位元組大小的int16型別的變數地址應該是偶數,一個4位元組大小的rune型別變數的地址應該是4的倍數,一個8位元組大小的float64、uint64或64bit指標型別變數的地址應該是8位元組對齊的。但是對於再大的地址對齊倍數則是不需要的,即使是complex128等較大的資料型別最多也只是8位元組對齊。

由於地址對齊這個因素,一個聚合型別(結構體或陣列)的大小至少是所有欄位或元素大小的總和,或者更大因為可能存在記憶體空洞。記憶體空洞是編譯器自動新增的沒有被使用的記憶體空間,用於保證後面每個欄位或元素的地址相對於結構體或陣列的開始地址能夠合理地對齊(記憶體空洞可能會存在一些隨機資料,可能會對用unsafe包直接操作記憶體的處理產生影響)。

型別 大小
bool 1個位元組
intN, uintN, floatN, complexN N/8個位元組(例如float64是8個位元組)
int, uint, uintptr 1個機器字
*T 1個機器字
string 2個機器字(data,len)
[]T 3個機器字(data,len,cap)
map 1個機器字
func 1個機器字
chan 1個機器字
interface 2個機器字(type,value)

unsafe.Alignof函式返回對應引數的型別需要對齊的倍數. 和 Sizeof 類似, Alignof 也是返回一個常量表示式, 對應一個常量. 通常情況下布林和數字型別需要對齊到它們本身的大小(最多8個位元組), 其它的型別對齊到機器字大小.

unsafe.Offsetof 函式的引數必須是一個欄位 x.f, 然後返回 f 欄位相對於 x 起始地址的偏移量, 包括可能的空洞.

圖 13.1 顯示了一個結構體變數 x 以及其在32位和64位機器上的典型的記憶體. 灰色區域是空洞.

var x struct {
    a bool
    b int16
    c []int
}

下面顯示了對x和它的三個欄位呼叫unsafe包相關函式的計算結果:

img

32位系統:

Sizeof(x)   = 16  Alignof(x)   = 4
Sizeof(x.a) = 1   Alignof(x.a) = 1 Offsetof(x.a) = 0
Sizeof(x.b) = 2   Alignof(x.b) = 2 Offsetof(x.b) = 2
Sizeof(x.c) = 12  Alignof(x.c) = 4 Offsetof(x.c) = 4

64位系統:

Sizeof(x)   = 32  Alignof(x)   = 8
Sizeof(x.a) = 1   Alignof(x.a) = 1 Offsetof(x.a) = 0
Sizeof(x.b) = 2   Alignof(x.b) = 2 Offsetof(x.b) = 2
Sizeof(x.c) = 24  Alignof(x.c) = 8 Offsetof(x.c) = 8

雖然這幾個函式在不安全的unsafe包,但是這幾個函式呼叫並不是真的不安全,特別在需要最佳化記憶體空間時它們返回的結果對於理解原生的記憶體佈局很有幫助。

unsafe.Pointer

大多數指標型別會寫成*T,表示是“一個指向T型別變數的指標”。unsafe.Pointer是特別定義的一種指標型別(譯註:類似C語言中的void*型別的指標),它可以包含任意型別變數的地址。當然,我們不可以直接透過*p來獲取unsafe.Pointer指標指向的真實變數的值,因為我們並不知道變數的具體型別。和普通指標一樣,unsafe.Pointer指標也是可以比較的,並且支援和nil常量比較判斷是否為空指標。

一個普通的*T型別指標可以被轉化為unsafe.Pointer型別指標,並且一個unsafe.Pointer型別指標也可以被轉回普通的指標,被轉回普通的指標型別並不需要和原始的*T型別相同。透過將*float64型別指標轉化為*uint64型別指標,我們可以檢視一個浮點數變數的位模式。

package math

func Float64bits(f float64) uint64 { return *(*uint64)(unsafe.Pointer(&f)) }

fmt.Printf("%#016x\n", Float64bits(1.0)) // "0x3ff0000000000000"

透過轉為新型別指標,我們可以更新浮點數的位模式。透過位模式操作浮點數是可以的,但是更重要的意義是指標轉換語法讓我們可以在不破壞型別系統的前提下向記憶體寫入任意的值。

幾點忠告

我們在前一章結尾的時候,我們警告要謹慎使用reflect包。那些警告同樣適用於本章的unsafe包。

高階語言使得程式設計師不用在關心真正執行程式的指令細節,同時也不再需要關注許多如記憶體佈局之類的實現細節。因為高階語言這個絕緣的抽象層,我們可以編寫安全健壯的,並且可以執行在不同作業系統上的具有高度可移植性的程式。

但是unsafe包,它讓程式設計師可以透過這個絕緣的抽象層直接使用一些必要的功能,雖然可能是為了獲得更好的效能。但是代價就是犧牲了可移植性和程式安全,因此使用unsafe包是一個危險的行為。我們對何時以及如何使用unsafe包的建議和我們在11.5節提到的Knuth對過早最佳化的建議類似。大多數Go程式設計師可能永遠不會需要直接使用unsafe包。當然,也永遠都會有一些需要使用unsafe包實現會更簡單的場景。如果確實認為使用unsafe包是最理想的方式,那麼應該儘可能將它限制在較小的範圍,那樣其它程式碼就忽略unsafe的影響。

現在,趕緊將最後兩章拋入腦後吧。編寫一些實實在在的應用是真理。請遠離reflect的unsafe包,除非你確實需要它們。

最後,用Go快樂地程式設計。我們希望你能像我們一樣喜歡Go語言。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章