深度解密Go語言之unsafe

上一篇文章咱們詳細分析了 map 的底層實現,若是你也跟着閱讀了源碼,那必定對 unsafe.Pointer 不陌生,map 對 key 進行定位的時候,大量使用。git

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

上次發佈文章的時候,包括代碼超過 5w 字,後臺編輯器的體驗很是差,一度讓我懷疑人生。我以前說過,像 map 那樣的長文,估計能讀完的不超過 1 %。像下面這幾位同窗的評價,並很少見。github

wechat

我的認爲,學習自己並非一件輕鬆愉快的事情,寓教於樂是個美好的願望。想要深入地領悟,就得付出別人看不見的努力。學習歷來都不會是一件輕鬆的事情,枯燥是正常的。耐住性子,深刻研究某個問題,讀書、看文章、寫博客均可以,浮躁時代作個專一的人!golang

指針類型

在正式介紹 unsafe 包以前,須要着重介紹 Go 語言中的指針類型。shell

我本科開始學編程的時候,第一門語言就是 C。以後又陸續學過 C++,Java,Python,這些語言都挺強大的,可是沒了 C 語言那麼「單純」。直到我開始接觸 Go 語言,又找到了那種感受。Go 語言的做者之一 Ken Thompson 也是 C 語言的做者。因此,Go 能夠看做 C 系語言,它的不少特性都和 C 相似,指針就是其中之一。編程

然而,Go 語言的指針相比 C 的指針有不少限制。這固然是爲了安全考慮,要知道像 Java/Python 這些現代語言,生怕程序員出錯,哪有什麼指針(這裏指的是顯式的指針)?更別說像 C/C++ 還須要程序員本身清理「垃圾」。因此對於 Go 來講,有指針已經很不錯了,僅管它有不少限制。segmentfault

爲何須要指針類型呢?參考文獻 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 掀不起任何大風大浪。

下面的這張圖能夠「自證清白」:

pointer copy

然而,相比於 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 能夠相互轉換。

type pointer uintptr

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 => 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 函數返回的是 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 源碼中,mapaccess一、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 的所有操做:

map assign

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 包用多了以後,也不以爲它的名字有多麼地不「美觀」了。相反,由於使用了官方並不提倡的東西,反而以爲有點酷炫。這就是叛逆的感受吧。

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

QR

參考資料

【飛雪無情的博客】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

【煎魚大佬的博客】http://www.javashuo.com/article/p-ftgedxeb-da.html

【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】http://www.javashuo.com/article/p-ddbbfvxh-go.html

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

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

相關文章
相關標籤/搜索