深度解密Go語言之unsafe

目錄

上一篇文章我們?cè)敿?xì)分析了 map 的底層實(shí)現(xiàn),如果你也跟著閱讀了源碼,那一定對(duì) unsafe.Pointer 不陌生,map 對(duì) key 進(jìn)行定位的時(shí)候,大量使用。

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

上次發(fā)布文章的時(shí)候,包括代碼超過 5w 字,后臺(tái)編輯器的體驗(yàn)非常差,一度讓我懷疑人生。我之前說過,像 map 那樣的長文,估計(jì)能讀完的不超過 1 %。像下面這幾位同學(xué)的評(píng)價(jià),并不多見。

wechat

個(gè)人認(rèn)為,學(xué)習(xí)本身并不是一件輕松愉快的事情,寓教于樂是個(gè)美好的愿望。想要深刻地領(lǐng)悟,就得付出別人看不見的努力。學(xué)習(xí)從來都不會(huì)是一件輕松的事情,枯燥是正常的。耐住性子,深入研究某個(gè)問題,讀書、看文章、寫博客都可以,浮躁時(shí)代做個(gè)專注的人!

指針類型

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

我本科開始學(xué)編程的時(shí)候,第一門語言就是 C。之后又陸續(xù)學(xué)過 C++,Java,Python,這些語言都挺強(qiáng)大的,但是沒了 C 語言那么“單純”。直到我開始接觸 Go 語言,又找到了那種感覺。Go 語言的作者之一 Ken Thompson 也是 C 語言的作者。所以,Go 可以看作 C 系語言,它的很多特性都和 C 類似,指針就是其中之一。

然而,Go 語言的指針相比 C 的指針有很多限制。這當(dāng)然是為了安全考慮,要知道像 Java/Python 這些現(xiàn)代語言,生怕程序員出錯(cuò),哪有什么指針(這里指的是顯式的指針)?更別說像 C/C++ 還需要程序員自己清理“垃圾”。所以對(duì)于 Go 來說,有指針已經(jīng)很不錯(cuò)了,僅管它有很多限制。

為什么需要指針類型呢?參考文獻(xiàn) go101.org 里舉了這樣一個(gè)例子:

package main

import "fmt"

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

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

非常簡單,我想在 double 函數(shù)里將 a 翻倍,但是例子中的函數(shù)卻做不到。為什么?因?yàn)?Go 語言的函數(shù)傳參都是值傳遞。double 函數(shù)里的 x 只是實(shí)參 a 的一個(gè)拷貝,在函數(shù)內(nèi)部對(duì) x 的操作不能反饋到實(shí)參 a。

如果這時(shí),有一個(gè)指針就可以解決問題了!這也是我們常用的“伎倆”。

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
}

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

x = nil

這得稍微思考一下,才能得出這一行代碼根本不影響的結(jié)論。因?yàn)槭侵祩鬟f,所以 x 也只是對(duì) &a 的一個(gè)拷貝。

*x += *x

這一句把 x 指向的值(也就是 &a 指向的值,即變量 a)變?yōu)樵瓉淼?2 倍。但是對(duì) x 本身(一個(gè)指針)的操作卻不會(huì)影響外層的 a,所以 x = nil 掀不起任何大風(fēng)大浪。

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

pointer copy

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

限制一:Go 的指針不能進(jìn)行數(shù)學(xué)運(yùn)算

來看一個(gè)簡單的例子:

a := 5
p := &a

p++
p = &a + 3

上面的代碼將不能通過編譯,會(huì)報(bào)編譯錯(cuò)誤:invalid operation,也就是說不能對(duì)指針做數(shù)學(xué)運(yùn)算。

限制二:不同類型的指針不能相互轉(zhuǎn)換

例如下面這個(gè)簡短的例子:

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

    f = &a
}

也會(huì)報(bào)編譯錯(cuò)誤:

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

關(guān)于兩個(gè)指針能否相互轉(zhuǎn)換,參考資料中 go 101 相關(guān)文章里寫得非常細(xì),這里我不想展開。個(gè)人認(rèn)為記住這些沒有什么意義,有完美主義的同學(xué)可以去閱讀原文。當(dāng)然我也有完美主義,但我有時(shí)會(huì)克制,嘿嘿。

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

只有在兩個(gè)指針類型相同或者可以相互轉(zhuǎn)換的情況下,才可以對(duì)兩者進(jìn)行比較。另外,指針可以通過 ==!= 直接和 nil 作比較。

限制四:不同類型的指針變量不能相互賦值

這一點(diǎn)同限制三。

什么是 unsafe

前面所說的指針是類型安全的,但它有很多限制。Go 還有非類型安全的指針,這就是 unsafe 包提供的 unsafe.Pointer。在某些情況下,它會(huì)使代碼更高效,當(dāng)然,也更危險(xiǎn)。

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

但是高階的 Gopher,怎么能不會(huì)使用 unsafe 包呢?它可以繞過 Go 語言的類型系統(tǒng),直接操作內(nèi)存。例如,一般我們不能操作一個(gè)結(jié)構(gòu)體的未導(dǎo)出成員,但是通過 unsafe 包就能做到。unsafe 包讓我可以直接讀寫內(nèi)存,還管你什么導(dǎo)出還是未導(dǎo)出。

為什么有 unsafe

Go 語言類型系統(tǒng)是為了安全和效率設(shè)計(jì)的,有時(shí),安全會(huì)導(dǎo)致效率低下。有了 unsafe 包,高階的程序員就可以利用它繞過類型系統(tǒng)的低效。因此,它就有了存在的意義,閱讀 Go 源碼,會(huì)發(fā)現(xiàn)有大量使用 unsafe 包的例子。

unsafe 實(shí)現(xiàn)原理

我們來看源碼:

type ArbitraryType int

type Pointer *ArbitraryType

從命名來看,Arbitrary 是任意的意思,也就是說 Pointer 可以指向任意類型,實(shí)際上它類似于 C 語言里的 void*

unsafe 包還有其他三個(gè)函數(shù):

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

Sizeof 返回類型 x 所占據(jù)的字節(jié)數(shù),但不包含 x 所指向的內(nèi)容的大小。例如,對(duì)于一個(gè)指針,函數(shù)返回的大小為 8 字節(jié)(64位機(jī)上),一個(gè) slice 的大小則為 slice header 的大小。

Offsetof 返回結(jié)構(gòu)體成員在內(nèi)存中的位置離結(jié)構(gòu)體起始處的字節(jié)數(shù),所傳參數(shù)必須是結(jié)構(gòu)體的成員。

Alignof 返回 m,m 是指當(dāng)類型進(jìn)行內(nèi)存對(duì)齊時(shí),它分配到的內(nèi)存地址能整除 m。

注意到以上三個(gè)函數(shù)返回的結(jié)果都是 uintptr 類型,這和 unsafe.Pointer 可以相互轉(zhuǎn)換。三個(gè)函數(shù)都是在編譯期間執(zhí)行,它們的結(jié)果可以直接賦給 const 型變量。另外,因?yàn)槿齻€(gè)函數(shù)執(zhí)行的結(jié)果和操作系統(tǒng)、編譯器相關(guān),所以是不可移植的。

綜上所述,unsafe 包提供了 2 點(diǎn)重要的能力:

  1. 任何類型的指針和 unsafe.Pointer 可以相互轉(zhuǎn)換。
  2. uintptr 類型和 unsafe.Pointer 可以相互轉(zhuǎn)換。
type pointer uintptr

pointer 不能直接進(jìn)行數(shù)學(xué)運(yùn)算,但可以把它轉(zhuǎn)換成 uintptr,對(duì) uintptr 類型進(jìn)行數(shù)學(xué)運(yùn)算,再轉(zhuǎn)換成 pointer 類型。

// uintptr 是一個(gè)整數(shù)類型,它足夠大,可以存儲(chǔ)
type uintptr uintptr

還有一點(diǎn)要注意的是,uintptr 并沒有指針的語義,意思就是 uintptr 所指向的對(duì)象會(huì)被 gc 無情地回收。而 unsafe.Pointer 有指針語義,可以保護(hù)它所指向的對(duì)象在“有用”的時(shí)候不會(huì)被垃圾回收。

unsafe 包中的幾個(gè)函數(shù)都是在編譯期間執(zhí)行完畢,畢竟,編譯器對(duì)內(nèi)存分配這些操作“了然于胸”。在 /usr/local/go/src/cmd/compile/internal/gc/unsafe.go 路徑下,可以看到編譯期間 Go 對(duì) unsafe 包中函數(shù)的處理。

更深層的原理需要去研究編譯器的源碼,這里就不去深究了。我們重點(diǎn)關(guān)注它的用法,接著往下看。

unsafe 如何使用

獲取 slice 長度

通過前面關(guān)于 slice 的文章,我們知道了 slice header 的結(jié)構(gòu)體定義:

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

調(diào)用 make 函數(shù)新建一個(gè) slice,底層調(diào)用的是 makeslice 函數(shù),返回的是 slice 結(jié)構(gòu)體:

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

因此我們可以通過 unsafe.Pointer 和 uintptr 進(jìn)行轉(zhuǎn)換,得到 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 的轉(zhuǎn)換流程如下:

Len: &s => pointer => uintptr => pointer => *int => int
Cap: &s => pointer => uintptr => pointer => *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 函數(shù)返回的是 hmap 的指針,注意是指針:

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

我們依然能通過 unsafe.Pointer 和 uintptr 進(jìn)行轉(zhuǎn)換,得到 hamp 字段的值,只不過,現(xiàn)在 count 變成二級(jí)指針了:

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 的轉(zhuǎn)換過程:

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

map 源碼中的應(yīng)用

在 map 源碼中,mapaccess1、mapassign、mapdelete 函數(shù)中,需要定位 key 的位置,會(huì)先對(duì) key 做哈希運(yùn)算。

例如:

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

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

上面舉的例子相對(duì)簡單,來看一個(gè)關(guān)于賦值的更難一點(diǎn)的例子:

// 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 要插入的位置后,進(jìn)行“賦值”操作。insertk 和 val 分別表示 key 和 value 所要“放置”的地址。如果 t.indirectkey 為真,說明 bucket 中存儲(chǔ)的是 key 的指針,因此需要將 insertk 看成指針的指針,這樣才能將 bucket 中的相應(yīng)位置的值設(shè)置成指向真實(shí) key 的地址值,也就是說 key 存放的是指針。

下面這張圖展示了設(shè)置 key 的全部操作:

map assign

obj 是真實(shí)的 key 存放的地方。第 4 號(hào)圖,obj 表示執(zhí)行完 typedmemmove 函數(shù)后,被成功賦值。

Offsetof 獲取成員偏移量

對(duì)于一個(gè)結(jié)構(gòu)體,通過 offset 函數(shù)可以獲取結(jié)構(gòu)體成員的偏移量,進(jìn)而獲取成員的地址,讀寫該地址的內(nèi)存,就可以達(dá)到改變成員值的目的。

這里有一個(gè)內(nèi)存分配相關(guān)的事實(shí):結(jié)構(gòu)體會(huì)被分配一塊連續(xù)的內(nèi)存,結(jié)構(gòu)體的地址也代表了第一個(gè)成員的地址。

我們來看一個(gè)例子:

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)
}

運(yùn)行代碼,輸出:

{stefno go}
{qcrao Golang}

name 是結(jié)構(gòu)體的第一個(gè)成員,因此可以直接將 &p 解析成 *string。這一點(diǎn),在前面獲取 map 的 count 成員時(shí),用的是同樣的原理。

對(duì)于結(jié)構(gòu)體的私有成員,現(xiàn)在有辦法可以通過 unsafe.Pointer 改變它的值了。

我把 Programmer 結(jié)構(gòu)體升級(jí),多加一個(gè)字段:

type Programmer struct {
    name string
    age int
    language string
}

并且放在其他包,這樣在 main 函數(shù)中,它的三個(gè)字段都是私有成員變量,不能直接修改。但我通過 unsafe.Sizeof() 函數(shù)可以獲取成員大小,進(jìn)而計(jì)算出成員的地址,直接修改內(nèi)存。

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 的相互轉(zhuǎn)換

這是一個(gè)非常精典的例子。實(shí)現(xiàn)字符串和 bytes 切片之間的轉(zhuǎn)換,要求是 zero-copy。想一下,一般的做法,都需要遍歷字符串或 bytes 切片,再挨個(gè)賦值。

完成這個(gè)任務(wù),我們需要了解 slice 和 string 的底層數(shù)據(jù)結(jié)構(gòu):

type StringHeader struct {
    Data uintptr
    Len  int
}

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

上面是反射包下的結(jié)構(gòu)體,路徑:src/reflect/value.go。只需要共享底層 []byte 數(shù)組就可以實(shí)現(xiàn) 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))
}

代碼比較簡單,不作詳細(xì)解釋。通過構(gòu)造 slice header 和 string header,來完成 string 和 byte slice 之間的轉(zhuǎn)換。

總結(jié)

unsafe 包繞過了 Go 的類型系統(tǒng),達(dá)到直接操作內(nèi)存的目的,使用它有一定的風(fēng)險(xiǎn)性。但是在某些場(chǎng)景下,使用 unsafe 包提供的函數(shù)會(huì)提升代碼的效率,Go 源碼中也是大量使用 unsafe 包。

unsafe 包定義了 Pointer 和三個(gè)函數(shù):

type ArbitraryType int
type Pointer *ArbitraryType

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

通過三個(gè)函數(shù)可以獲取變量的大小、偏移、對(duì)齊等信息。

uintptr 可以和 unsafe.Pointer 進(jìn)行相互轉(zhuǎn)換,uintptr 可以進(jìn)行數(shù)學(xué)運(yùn)算。這樣,通過 uintptr 和 unsafe.Pointer 的結(jié)合就解決了 Go 指針不能進(jìn)行數(shù)學(xué)運(yùn)算的限制。

通過 unsafe 相關(guān)函數(shù),可以獲取結(jié)構(gòu)體私有成員的地址,進(jìn)而對(duì)其做進(jìn)一步的讀寫操作,突破 Go 的類型安全限制。關(guān)于 unsafe 包,我們更多關(guān)注它的用法。

順便說一句,unsafe 包用多了之后,也不覺得它的名字有多么地不“美觀”了。相反,因?yàn)槭褂昧斯俜讲⒉惶岢臇|西,反而覺得有點(diǎn)酷炫。這就是叛逆的感覺吧。

最后,點(diǎn)擊閱讀原文,你將參與見證一個(gè)千星項(xiàng)目的成長,你值得擁有!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容