上一篇文章我們詳細分析了 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 點重要的能力:
- 任何類型的指針和 unsafe.Pointer 可以相互轉換。
- 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 => 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 源碼中,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.html