1. 程式人生 > >深度解密Go語言之unsafe

深度解密Go語言之unsafe

目錄

  • 指標型別
  • 什麼是 unsafe
  • 為什麼有 unsafe
  • unsafe 實現原理
  • unsafe 如何使用
    • 獲取 slice 長度
    • 獲取 map 長度
    • map 原始碼中的應用
    • Offsetof 獲取成員偏移量
    • string 和 slice 的相互轉換
  • 總結
  • 參考資料

上一篇文章我們詳細分析了 map 的底層實現,如果你也跟著閱讀了原始碼,那一定對 unsafe.Pointer

不陌生,map 對 key 進行定位的時候,大量使用。

unsafe.Pointer 位於 unsafe 包,這篇文章,我們來深入研究 unsafe 包。先說明一下,本文沒有之前那麼長了,你可以比較輕鬆地讀完,這樣的時候不是太多。

上次釋出文章的時候,包括程式碼超過 5w 字,後臺編輯器的體驗非常差,一度讓我懷疑人生。我之前說過,像 map 那樣的長文,估計能讀完的不超過 1 %。像下面這幾位同學的評價,並不多見。

個人認為,學習本身並不是一件輕鬆愉快的事情,寓教於樂是個美好的願望。想要深刻地領悟,就得付出別人看不見的努力。學習從來都不會是一件輕鬆的事情,枯燥是正常的。耐住性子,深入研究某個問題,讀書、看文章、寫部落格都可以,浮躁時代做個專注的人!

指標型別

在正式介紹 unsafe 包之前,需要著重介紹 Go 語言中的指標型別。

我本科開始學程式設計的時候,第一門語言就是 C。之後又陸續學過 C++,Java,Python,這些語言都挺強大的,但是沒了 C 語言那麼“單純”。直到我開始接觸 Go 語言,又找到了那種感覺。Go 語言的作者之一 Ken Thompson 也是 C 語言的作者。所以,Go 可以看作 C 系語言,它的很多特性都和 C 類似,指標就是其中之一。

然而,Go 語言的指標相比 C 的指標有很多限制。這當然是為了安全考慮,要知道像 Java/Python 這些現代語言,生怕程式設計師出錯,哪有什麼指標(這裡指的是顯式的指標)?更別說像 C/C++ 還需要程式設計師自己清理“垃圾”。所以對於 Go 來說,有指標已經很不錯了,僅管它有很多限制。

為什麼需要指標型別呢?參考文獻 go101.org 裡舉了這樣一個例子:

package main

import "fmt"

func double(x int) {
    x += x
}

func main() {
    var a = 3
    double(a)
    fmt.Println(a) // 3
}

非常簡單,我想在 double 函式裡將 a 翻倍,但是例子中的函式卻做不到。為什麼?因為 Go 語言的函式傳參都是值傳遞。double 函式裡的 x 只是實參 a 的一個拷貝,在函式內部對 x 的操作不能反饋到實參 a。

如果這時,有一個指標就可以解決問題了!這也是我們常用的“伎倆”。

package main

import "fmt"

func double(x *int) {
    *x += *x
    x = nil
}

func main() {
    var a = 3
    double(&a)
    fmt.Println(a) // 6
    
    p := &a
    double(p)
    fmt.Println(a, p == nil) // 12 false
}

很常規的操作,不用多解釋。唯一可能有些疑惑的在這一句:

x = nil

這得稍微思考一下,才能得出這一行程式碼根本不影響的結論。因為是值傳遞,所以 x 也只是對 &a 的一個拷貝。

*x += *x

這一句把 x 指向的值(也就是 &a 指向的值,即變數 a)變為原來的 2 倍。但是對 x 本身(一個指標)的操作卻不會影響外層的 a,所以 x = nil 掀不起任何大風大浪。

下面的這張圖可以“自證清白”:

然而,相比於 C 語言中指標的靈活,Go 的指標多了一些限制。但這也算是 Go 的成功之處:既可以享受指標帶來的便利,又避免了指標的危險性。

限制一:Go 的指標不能進行數學運算

來看一個簡單的例子:

a := 5
p := &a

p++
p = &a + 3

上面的程式碼將不能通過編譯,會報編譯錯誤:invalid operation,也就是說不能對指標做數學運算。

限制二:不同型別的指標不能相互轉換

例如下面這個簡短的例子:

func main() {
    a := int(100)
    var f *float64
    
    f = &a
}

也會報編譯錯誤:

cannot use &a (type *int) as type *float64 in assignment

關於兩個指標能否相互轉換,參考資料中 go 101 相關文章裡寫得非常細,這裡我不想展開。個人認為記住這些沒有什麼意義,有完美主義的同學可以去閱讀原文。當然我也有完美主義,但我有時會剋制,嘿嘿。

限制三:不同型別的指標不能使用 == 或 != 比較

只有在兩個指標型別相同或者可以相互轉換的情況下,才可以對兩者進行比較。另外,指標可以通過 ==!= 直接和 nil 作比較。

限制四:不同型別的指標變數不能相互賦值

這一點同限制三。

什麼是 unsafe

前面所說的指標是型別安全的,但它有很多限制。Go 還有非型別安全的指標,這就是 unsafe 包提供的 unsafe.Pointer。在某些情況下,它會使程式碼更高效,當然,也更危險。

unsafe 包用於 Go 編譯器,在編譯階段使用。從名字就可以看出來,它是不安全的,官方並不建議使用。我在用 unsafe 包的時候會有一種不舒服的感覺,可能這也是語言設計者的意圖吧。

但是高階的 Gopher,怎麼能不會使用 unsafe 包呢?它可以繞過 Go 語言的型別系統,直接操作記憶體。例如,一般我們不能操作一個結構體的未匯出成員,但是通過 unsafe 包就能做到。unsafe 包讓我可以直接讀寫記憶體,還管你什麼匯出還是未匯出。

為什麼有 unsafe

Go 語言型別系統是為了安全和效率設計的,有時,安全會導致效率低下。有了 unsafe 包,高階的程式設計師就可以利用它繞過型別系統的低效。因此,它就有了存在的意義,閱讀 Go 原始碼,會發現有大量使用 unsafe 包的例子。

unsafe 實現原理

我們來看原始碼:

type ArbitraryType int

type Pointer *ArbitraryType

從命名來看,Arbitrary 是任意的意思,也就是說 Pointer 可以指向任意型別,實際上它類似於 C 語言裡的 void*

unsafe 包還有其他三個函式:

func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

Sizeof 返回型別 x 所佔據的位元組數,但不包含 x 所指向的內容的大小。例如,對於一個指標,函式返回的大小為 8 位元組(64位機上),一個 slice 的大小則為 slice header 的大小。

Offsetof 返回結構體成員在記憶體中的位置離結構體起始處的位元組數,所傳引數必須是結構體的成員。

Alignof 返回 m,m 是指當型別進行記憶體對齊時,它分配到的記憶體地址能整除 m。

注意到以上三個函式返回的結果都是 uintptr 型別,這和 unsafe.Pointer 可以相互轉換。三個函式都是在編譯期間執行,它們的結果可以直接賦給 const 型變數。另外,因為三個函式執行的結果和作業系統、編譯器相關,所以是不可移植的。

綜上所述,unsafe 包提供了 2 點重要的能力:

  1. 任何型別的指標和 unsafe.Pointer 可以相互轉換。
  2. uintptr 型別和 unsafe.Pointer 可以相互轉換。

pointer 不能直接進行數學運算,但可以把它轉換成 uintptr,對 uintptr 型別進行數學運算,再轉換成 pointer 型別。

// uintptr 是一個整數型別,它足夠大,可以儲存
type uintptr uintptr

還有一點要注意的是,uintptr 並沒有指標的語義,意思就是 uintptr 所指向的物件會被 gc 無情地回收。而 unsafe.Pointer 有指標語義,可以保護它所指向的物件在“有用”的時候不會被垃圾回收。

unsafe 包中的幾個函式都是在編譯期間執行完畢,畢竟,編譯器對記憶體分配這些操作“瞭然於胸”。在 /usr/local/go/src/cmd/compile/internal/gc/unsafe.go 路徑下,可以看到編譯期間 Go 對 unsafe 包中函式的處理。

更深層的原理需要去研究編譯器的原始碼,這裡就不去深究了。我們重點關注它的用法,接著往下看。

unsafe 如何使用

獲取 slice 長度

通過前面關於 slice 的文章,我們知道了 slice header 的結構體定義:

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 元素指標
    len   int // 長度 
    cap   int // 容量
}

呼叫 make 函式新建一個 slice,底層呼叫的是 makeslice 函式,返回的是 slice 結構體:

func makeslice(et *_type, len, cap int) slice

因此我們可以通過 unsafe.Pointer 和 uintptr 進行轉換,得到 slice 的欄位值。

func main() {
    s := make([]int, 9, 20)
    var Len = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8)))
    fmt.Println(Len, len(s)) // 9 9

    var Cap = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(16)))
    fmt.Println(Cap, cap(s)) // 20 20
}

Len,cap 的轉換流程如下:

Len: &s => pointer => uintptr => poiter => *int => int
Cap: &s => pointer => uintptr => poiter => *int => int

獲取 map 長度

再來看一下上篇文章我們講到的 map:

type hmap struct {
    count     int
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32

    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    nevacuate  uintptr

    extra *mapextra
}

和 slice 不同的是,makemap 函式返回的是 hmap 的指標,注意是指標:

func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap

我們依然能通過 unsafe.Pointer 和 uintptr 進行轉換,得到 hamp 欄位的值,只不過,現在 count 變成二級指標了:

func main() {
    mp := make(map[string]int)
    mp["qcrao"] = 100
    mp["stefno"] = 18

    count := **(**int)(unsafe.Pointer(&mp))
    fmt.Println(count, len(mp)) // 2 2
}

count 的轉換過程:

&mp => pointer => **int => int

map 原始碼中的應用

在 map 原始碼中,mapaccess1、mapassign、mapdelete 函式中,需要定位 key 的位置,會先對 key 做雜湊運算。

例如:

b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))

h.buckets 是一個 unsafe.Pointer,將它轉換成 uintptr,然後加上 (hash&m)*uintptr(t.bucketsize),二者相加的結果再次轉換成 unsafe.Pointer,最後,轉換成 bmap 指標,得到 key 所落入的 bucket 位置。如果不熟悉這個公式,可以看看上一篇文章,淺顯易懂。

上面舉的例子相對簡單,來看一個關於賦值的更難一點的例子:

// store new key/value at insert position
if t.indirectkey {
    kmem := newobject(t.key)
    *(*unsafe.Pointer)(insertk) = kmem
    insertk = kmem
}
if t.indirectvalue {
    vmem := newobject(t.elem)
    *(*unsafe.Pointer)(val) = vmem
}

typedmemmove(t.key, insertk, key)

這段程式碼是在找到了 key 要插入的位置後,進行“賦值”操作。insertk 和 val 分別表示 key 和 value 所要“放置”的地址。如果 t.indirectkey 為真,說明 bucket 中儲存的是 key 的指標,因此需要將 insertk 看成指標的指標,這樣才能將 bucket 中的相應位置的值設定成指向真實 key 的地址值,也就是說 key 存放的是指標。

下面這張圖展示了設定 key 的全部操作:

obj 是真實的 key 存放的地方。第 4 號圖,obj 表示執行完 typedmemmove 函式後,被成功賦值。

Offsetof 獲取成員偏移量

對於一個結構體,通過 offset 函式可以獲取結構體成員的偏移量,進而獲取成員的地址,讀寫該地址的記憶體,就可以達到改變成員值的目的。

這裡有一個記憶體分配相關的事實:結構體會被分配一塊連續的記憶體,結構體的地址也代表了第一個成員的地址。

我們來看一個例子:

package main

import (
    "fmt"
    "unsafe"
)

type Programmer struct {
    name string
    language string
}

func main() {
    p := Programmer{"stefno", "go"}
    fmt.Println(p)
    
    name := (*string)(unsafe.Pointer(&p))
    *name = "qcrao"

    lang := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + unsafe.Offsetof(p.language)))
    *lang = "Golang"

    fmt.Println(p)
}

執行程式碼,輸出:

{stefno go}
{qcrao Golang}

name 是結構體的第一個成員,因此可以直接將 &p 解析成 *string。這一點,在前面獲取 map 的 count 成員時,用的是同樣的原理。

對於結構體的私有成員,現在有辦法可以通過 unsafe.Pointer 改變它的值了。

我把 Programmer 結構體升級,多加一個欄位:

type Programmer struct {
    name string
    age int
    language string
}

並且放在其他包,這樣在 main 函式中,它的三個欄位都是私有成員變數,不能直接修改。但我通過 unsafe.Sizeof() 函式可以獲取成員大小,進而計算出成員的地址,直接修改記憶體。

func main() {
    p := Programmer{"stefno", 18, "go"}
    fmt.Println(p)

    lang := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + unsafe.Sizeof(int(0)) + unsafe.Sizeof(string(""))))
    *lang = "Golang"

    fmt.Println(p)
}

輸出:

{stefno 18 go}
{stefno 18 Golang}

string 和 slice 的相互轉換

這是一個非常精典的例子。實現字串和 bytes 切片之間的轉換,要求是 zero-copy。想一下,一般的做法,都需要遍歷字串或 bytes 切片,再挨個賦值。

完成這個任務,我們需要了解 slice 和 string 的底層資料結構:

type StringHeader struct {
    Data uintptr
    Len  int
}

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

上面是反射包下的結構體,路徑:src/reflect/value.go。只需要共享底層 []byte 陣列就可以實現 zero-copy

func string2bytes(s string) []byte {
    stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))

    bh := reflect.SliceHeader{
        Data: stringHeader.Data,
        Len:  stringHeader.Len,
        Cap:  stringHeader.Len,
    }

    return *(*[]byte)(unsafe.Pointer(&bh))
}

func bytes2string(b []byte) string{
    sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&b))

    sh := reflect.StringHeader{
        Data: sliceHeader.Data,
        Len:  sliceHeader.Len,
    }

    return *(*string)(unsafe.Pointer(&sh))
}

程式碼比較簡單,不作詳細解釋。通過構造 slice header 和 string header,來完成 string 和 byte slice 之間的轉換。

總結

unsafe 包繞過了 Go 的型別系統,達到直接操作記憶體的目的,使用它有一定的風險性。但是在某些場景下,使用 unsafe 包提供的函式會提升程式碼的效率,Go 原始碼中也是大量使用 unsafe 包。

unsafe 包定義了 Pointer 和三個函式:

type ArbitraryType int
type Pointer *ArbitraryType

func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

通過三個函式可以獲取變數的大小、偏移、對齊等資訊。

uintptr 可以和 unsafe.Pointer 進行相互轉換,uintptr 可以進行數學運算。這樣,通過 uintptr 和 unsafe.Pointer 的結合就解決了 Go 指標不能進行數學運算的限制。

通過 unsafe 相關函式,可以獲取結構體私有成員的地址,進而對其做進一步的讀寫操作,突破 Go 的型別安全限制。關於 unsafe 包,我們更多關注它的用法。

順便說一句,unsafe 包用多了之後,也不覺得它的名字有多麼地不“美觀”了。相反,因為使用了官方並不提倡的東西,反而覺得有點酷炫。這就是叛逆的感覺吧。

最後,點選閱讀原文,你將參與見證一個千星專案的成長,你值得擁有!

參考資料

【飛雪無情的部落格】https://www.flysnow.org/2017/07/06/go-in-action-unsafe-pointer.html

【譯文 unsafe包詳解】https://gocn.vip/question/371

【官方文件】https://golang.org/pkg/unsafe/

【例子】http://www.opscoder.info/golang_unsafe.html

【煎魚大佬的部落格】https://segmentfault.com/a/1190000017389782

【go語言聖經】https://www.kancloud.cn/wizardforcel/gopl-zh/106477

【pointer and system calls】https://blog.gopheracademy.com/advent-2017/unsafe-pointer-and-system-calls/

【pointer and uintptr】https://my.oschina.net/xinxingegeya/blog/729673

【unsafe.pointer】https://go101.org/article/unsafe.html

【go 指標型別】https://go101.org/article/pointer.html

【碼洞 快學Go語言 unsafe】https://juejin.im/post/5c189dce5188256b2e71e79b

【官方文件】https://golang.org/pkg/unsafe/

【jasper 的小窩】http://www.opscoder.info/golang_unsafe.