深入理解go unsafe用法及注意事項
學過 C 的朋友應該知道,有一種類型是指針類型,指針類型存儲的是一個內存地址,通過這個內存地址可以找到它指向的變量。go 雖然是一種高級語言,但是也還是給開發(fā)者提供了指針的類型 unsafe.Pointer
,我們可以通過它來直接讀寫變量的內存。
正因為如此,如果我們操作不當,極有可能會導致程序崩潰。今天就來了解一下 unsafe
里所能提供的關于指針的一些功能,以及使用 unsafe.Pointer
的一些注意事項。
內存里面的二進制數(shù)據(jù)表示什么?
我們知道,計算機存儲數(shù)據(jù)的時候是以二進制的方式存儲的,當然,內存里面存儲的數(shù)據(jù)也是二進制的。二進制的 01 本身其實并沒有什么特殊的含義。
它們的具體含義完全取決于我們怎么去理解它們,比如 0010 0000
,如果我們將其看作是一個十進制數(shù)字,那么它就是 32,如果我們將其看作是字符,那么他就是一個空格(具體可參考 ASCII 碼表)。
對應到編程語言層面,其實我們的變量存儲在內存里面也是 01 表示的二進制,這些二進制數(shù)表示是什么類型都是語言層面的事,更準確來說,是編譯器來處理的,我們寫代碼的時候將變量聲明為整數(shù),那么我們取出來的時候也會表示成一個整數(shù)。
這跟本文有什么關系呢?我們下面會講到很多關于類型轉換的內容,如果我們理解了這一節(jié)說的內容,下面的內容會更容易理解
在我們做類型轉換的時候,實際上底層的二進制表示是沒有變的,變的只是我們所看到的表面的東西。
內存布局
有點想直接開始講 unsafe
里的 Pointer
的,但是如果讀者對計算機內存怎么存儲變量不太熟悉的話,看起來可能會比較費解,所以在文章開頭會花比較大的篇幅來講述計算機是怎么存儲數(shù)據(jù)的,相信讀完會再閱讀后面的內容(比如指針的算術運算、通過指針修改結構體字段)會沒有那么多障礙。
變量在內存中是怎樣的?
我們先來看一段代碼:
package main import ( "fmt" "unsafe" ) func main() { var a int8 = 1 var b int16 = 2 // unsafe.Sizeof() 可以獲取存儲變量需要的內存大小,單位為字節(jié) // 輸出:1 2 // int8 意味著,用 8 位,也就是一個字節(jié)來存儲整型數(shù)據(jù) // int16 意味著,用 16 位,也就是兩個字節(jié)來存儲整型數(shù)據(jù) fmt.Println(unsafe.Sizeof(a), unsafe.Sizeof(b)) }
在這段代碼中我們定義了兩個變量,占用一個字節(jié)的 a
和占用兩個字節(jié)的 b
,在內存中它們大概如下圖:
我們可以看到,在圖中,a
存儲在低地址,占用一個字節(jié),而 b
存儲在 a
相鄰的地方,占用兩個字節(jié)。
結構體在內存中是怎樣的?
我們再來看看結構體在內存中的存儲:
package main import ( "fmt" "unsafe" ) type Person struct { age int8 score int8 } func main() { var p Person // 輸出:2 1 1 // 意味著 p 占用兩個字節(jié), // 其中 age 占用一個字節(jié),score 占用一個字節(jié) fmt.Println(unsafe.Sizeof(p), unsafe.Sizeof(p.age), unsafe.Sizeof(p.score)) }
這段代碼中,我們定義了一個 Person
結構體,其中兩個字段 age
和 score
都是 int8
類型,都是只占用一個字節(jié)的,它的內存布局大概如下圖:
我們可以看到,在內存中,結構體字段是占用了內存中連續(xù)的一段存儲空間的,具體來說是占用了連續(xù)的兩個字節(jié)。
指針在內存中是怎么存儲的?
在下面的代碼中,我們定義了一個 a
變量,大小為 1 字節(jié),然后我們定義了一個指向 a
的指針 p
:
需要先說明的是,下面有兩個操作符,一個是 &
,這個是取地址的操作符,var p = &a
意味著,取得 a
的內存地址,將其存儲在變量 p
中,另一個操作符是 *
,這個操作符的意思是解指針,*p
就是通過 p
的地址取得 p
指向的內容(也就是 a
)然后進行操作。
*p = 4
意味著,將 p
指向的 a
修改為 4。
package main import ( "fmt" "unsafe" ) func main() { var a int8 = 3 // ... 其他變量 var p = &a fmt.Println(unsafe.Sizeof(p)) fmt.Println(*p) // 3 *p = 4 fmt.Println(a) // 4 }
需要注意的是,這里面不再是一個單元格一個字節(jié)了,p
(指針變量)是要占用 8 個字節(jié)的(這個跟機器有關,我的是 64 位的 CPU,所以是 8 個字節(jié))。
從這個圖,我們可以得知,指針實際上存儲的是一個內存地址,通過這個地址我們可以找到它實際存儲的內容。
結構體的內存布局真的是我們上面說的那樣嗎?
上面我們說了,下面這個結構體占用了兩個字節(jié),結構體里面的一個字段占用一個字節(jié):
type Person struct { age int8 score int8 }
然后我們再來看看下面這個結構體,它會占用多少字節(jié)呢?
type Person struct { age int8 score int16 // 類型由 int8 改為了 int16 }
也許我們這個時候已經(jīng)算好了 1 + 2 = 3
,3 個字節(jié)不是嗎?說實話,真的不是,它會占用 4 個字節(jié),這可能會有點反常理,但是這跟計算機的體系結構有著密切的關系,先看具體的運行結果:
package main import ( "fmt" "unsafe" ) type Person struct { age int8 score int16 } func main() { var p Person // 輸出:4 1 2 // 意味著 p 占用 4 個字節(jié), // 其中 age 占用 2 個字節(jié),score 占用 2 個字節(jié) fmt.Println(unsafe.Sizeof(p), unsafe.Sizeof(p.age), unsafe.Sizeof(p.score)) }
為什么會這樣呢?因為 CPU 運行的時候,需要從內存讀取數(shù)據(jù),而從內存取數(shù)據(jù)的過程是按字讀取的,如果我們數(shù)據(jù)的內存沒有對齊,則可能會導致 CPU 本來一次可以讀取完的數(shù)據(jù)現(xiàn)在需要多次讀取,這樣就會造成效率的下降。
關于內存對齊,是一個比較龐大的話題,這里不展開了,我們需要明確的是,go 編譯器會對我們的結構體字段進行內存對齊。
內存對我們的影響就是,它可能會導致結構體所占用的空間比它字段類型所需要的空間大(所以我們做指針的算術運算的時候需要非常注意),
具體大多少其實我們其實不需要知道,因為有方法可以知道,哪就是unsafe.Offsetof
,下面會說到。
uintptr 是什么意思?
在開始下文之前,還是得啰嗦一句,uintptr
這種命名方式是 C 語言里面的一種類型命名的慣例,u
前綴表示是無符號數(shù)(unsigned),ptr
是指針(pointer)的縮寫,這個 uintptr
按這個命名慣例解析的話,就是一個指向無符號整數(shù)的指針。
另外,還有另外一種命名慣例,就是在整型類型的后面加上一個表示占用 bit 數(shù)的數(shù)字,(1字節(jié)=8bit)
比如 int8
表示一個占用 8 位的整數(shù),只可以存儲 1 個字節(jié)的數(shù)據(jù),然后 int64
表示的是一個 8 字節(jié)數(shù)(64位)。
unsafe 包定義的三個新類型
ArbitraryType
type ArbitraryType int
,這個類型實際上是一個 int
類型,但是從名字上我們可以看到,它被命名為任意類型,也就是說,他會被我們用來表示任意的類型,具體怎么用,是下面說的 unsafe.Pointer
用的。
IntegerType
type IntegerType int
,它表示的是一個任意的整數(shù),在 unsafe
包中它被用來作為表示切片或者指針加減的長度。
Pointer
type Pointer *ArbitraryType
,這個就是我們上一節(jié)提到的指針了,它可以指向任何類型的數(shù)據(jù)(*ArbitraryType
)。
內存地址實際上就是計算機內存的編號,是一個整數(shù),所以我們才可以使用
int
來表示指針。
unsafe 包計算內存的三個方法
這幾個方法在我們對內存進行操作的時候會非常有幫助,因為根據(jù)這幾個方法,我們才可以得知底層數(shù)據(jù)類型的實際大小。
Sizeof
計算 x
所需要的內存大?。▎挝粸樽止?jié)),如果其中包含了引用類型,Sizeof
不會計算引用指向的內容的大小。
有幾種常見的情況(沒有涵蓋全部情況):
- 基本類型,如
int8
、int
,Sizeof
返回的是這個類型本身的大小,如unsafe.Sizeof(int8(x))
為 1,因為int8
只占用一個字節(jié)。 - 引用類型,如
var x *int
,Sizeof(x)
會返回 8(在我的機器上,不同機器可能不一樣),另外就算引用指向了一個復合類型,比如結構體,返回的還是 8(因為變量本身存儲的只是內存地址)。 - 結構體類型,如果是結構體,那么
Sizeof
返回的大小包含了用于內存對齊的內存(所以可能會比結構體底層類型所需要的實際大小要大) - 切片,
Sizeof
返回的是 24(返回的是切片這個類型所需要占用空間的大小,我們需要知道,切片底層是slice
結構體,里面三個字段分別是array unsafe.Pointer
、len int
和cap int
,這三個字段所需要的大小為 24) - 字符串,跟切片類似,
Sizeof
會返回 16,因為字符串底層是一個用來存儲字符串內容的unsafe.Pointer
指針和一個表示長度的int
,所以是 16。
這個方法返回的大小跟機器密切相關,但一般開發(fā)者的電腦都是 64 位的,調用這個函數(shù)的值應該跟我的機器上得到的一樣。
例子:
package main import ( "fmt" "unsafe" ) type Person struct { age int8 score int16 } type School struct { students []Person } func main() { var x int8 var y int // 1 8 // int8 占用 1 個字節(jié),int 占用 8 個字節(jié) fmt.Println(unsafe.Sizeof(x), unsafe.Sizeof(y)) var p *int // 8 // 指針變量占用 8 個字節(jié) fmt.Println(unsafe.Sizeof(p)) var person Person // 4 // age 內存對齊需要 2 個字節(jié) // score 也需要兩個字節(jié) fmt.Println(unsafe.Sizeof(person)) var school School // 24 // 只有一個切片字段,切片需要 24 個字節(jié) // 不管這個切片里面有多少數(shù)據(jù),school 所需要占用的內存空間都是 24 字節(jié) fmt.Println(unsafe.Sizeof(school)) var s string // 16 // 字符串底層是一個 unsafe.Pointer 和一個 int fmt.Println(unsafe.Sizeof(s)) }
Offsetof 方法
這個方法用于計算結構體字段的內存地址相對于結構體內存地址的偏移。具體來說就是,我們可以通過 &
(取地址)操作符獲取結構體地址。
實際上,結構體地址就是結構體中第一個字段的地址。
拿到了結構體的地址之后,我們可以通過 Offsetof
方法來獲取結構體其他字段的偏移量,下面是一個例子:
package main import ( "fmt" "unsafe" ) type Person struct { age int8 score int16 } func main() { var person Person // 0 2 // person.age 是第一個字段,所以是 0 // person.score 是第二個字段,因為需要內存對齊,實際上 age 占用了 2 個字節(jié), // 因此 unsafe.Offsetof(person.score) 是 2,也就是說從第二個字節(jié)開始才是 person.score fmt.Println(unsafe.Offsetof(person.age), unsafe.Offsetof(person.score)) }
我們上面也說了,編譯器會對結構體做一些內存對齊的操作,這會導致結構體底層字段占用的內存大小會比實際需要的大小要大。
因此,我們在取結構體字段地址的時候,最好是通過結構體地址加上 unsafe.Offsetof(x.y)
拿到的地址來操作。如下:
package main import ( "fmt" "unsafe" ) type Person struct { age int8 score int16 } func main() { var person = Person{ age: 10, score: 20, } // {10 20} fmt.Println(person) // 取得 score 字段的指針 // 通過結構體地址,加上 score 字段的偏移量,得到 score 字段的地址 score := (*int16)(unsafe.Pointer(uintptr(unsafe.Pointer(&person)) + unsafe.Offsetof(person.score))) *score = 30 // {10 30} fmt.Println(person) }
這個例子看起來有點復雜,但是沒關系,后面會詳細展開的,這里主要要說明的是:
我們通過 unsafe.Pointer
來操作結構體底層字段的時候,我們是通過 unsafe.Offsetof
來獲取結構體字段地址偏移量的,因為我們看到的類型大小并不是內存實際占用的大小,通過 Offsetof
拿到的結果是已經(jīng)將內存對齊等因素考慮在內的了。
(如果我們錯誤的認為 age
只占用一個字節(jié),然后將 unsafe.Offsetof(person.score)
替換為 1,那么我們就修改不了 score 字段了)
Alignof 方法
這個方法用以獲取某一個類型的對齊系數(shù),就是對齊一個類型的時候需要多少個字節(jié)。
這個對開發(fā)者而言意義不是非常大,go 里面只有 WaitGroup
用到了一下,沒有看到其他地方有用到這個方法,所以本文不展開了,有興趣的自行了解。
unsafe.Pointer 是什么?
讓我們再來回顧一下,Pointer
的定義是 type Pointer *ArbitraryType
,也就是一個指向任意類型的指針類型。
首先它是指針類型,所以我們初始化 unsafe.Pointer
的時候,需要通過 &
操作符來將變量的地址傳遞進去。我們可以將其想象為指針類型的包裝類型。
例子:
package main import ( "fmt" "unsafe" ) func main() { var a int // 打印出 a 的地址:0xc0000240a8 fmt.Println(unsafe.Pointer(&a)) }
unsafe.Pointer 類型轉換
在使用 unsafe.Pointer
的時候,往往需要另一個類型來配合,那就是 uintptr
,這個 uintptr
在文檔里面的描述是:uintptr
是一種整數(shù)類型,其大小足以容納任何指針的位模式。這里的關鍵是 “任何指針”,也就是說,它設計出來是被用來存儲指針的,而且其大小保證能存儲下任何指針。
而我們知道 unsafe.Pointer
也是表示指針,那么 uintptr
跟 unsafe.Pointer
有什么區(qū)別呢?
只需要記住最關鍵的一點,uintptr
是內存地址的整數(shù)表示,而且可以進行算術運算,而 unsafe.Pointer
除了可以表示一個內存地址之外,還能保證其指向的內存不會被垃圾回收器回收,但是 uintptr
這個地址不能保證其指向的內存不被垃圾回收器回收。
我們先來看看與 unsafe.Pointer
相關的幾種類型轉換,這在我們下文幾乎所有地方都會用到:
- 任何類型的指針值都能轉換為
unsafe.Pointer
unsafe.Pointer
可以轉換為一個指向任何類型的指針值unsafe.Pointer
可以轉換為uintptr
uintptr
可以轉換為unsafe.Pointer
例子(下面這個例子中輸出的地址都是變量 a
所在的內存地址,都是一樣的地址):
package main import ( "fmt" "unsafe" ) func main() { var a int var p = &a // 1. int 類型指針轉換為 unsafe.Pointer fmt.Println(unsafe.Pointer(p)) // 0xc0000240a8 // 2. unsafe.Pointer 轉換為普通類型的指針 pointer := unsafe.Pointer(&a) var pp *int = (*int)(pointer) // 0xc0000240a8 fmt.Println(pp) // 3. unsafe.Pointer 可以轉換為 uintptr var p1 = uintptr(unsafe.Pointer(p)) fmt.Printf("%x\n", p1) // c0000240a8,沒有 0x 前綴 // 4. uintptr 可以轉換為 unsafe.Pointer p2 := unsafe.Pointer(p1) fmt.Println(p2) // 0xc0000240a8 }
如何正確地使用指針?
指針允許我們忽略類型系統(tǒng)而對任意內存進行讀寫,這是非常危險的,所以我們在使用指針的時候要格外的小心。
我們使用 Pointer
的模式有以下幾種,如果我們不是按照以下模式來使用 Pointer
的話,那使用的方式很可能是無效的,或者在將來變得無效,但就算是下面的幾種使用模式,也有需要注意的地方。
運行 go vet
可以幫助查找不符合這些模式的 Pointer
的用法,但 go vet
沒有警告也并不能保證代碼有效。
以下我們就來詳細學習一下使用 Pointer
的幾種正確的模式:
1. 將 *T1
轉換為指向 *T2
的 Pointer
前提條件:
T2
類型所需要的大小不大于T1
類型的大小。(大小大的類型轉換為占用空間更小的類型)T1
和T2
的內存布局一樣。
這是因為如果直接將占用空間小的類型轉換為占用空間更大的類型的話,多出來的部分是不確定的內容,當然我們也可以通過
unsafe.Pointer
來修改這部分內容。
這種轉換允許將一種類型的數(shù)據(jù)重新解釋為另外一種數(shù)據(jù)類型。下面是一個例子(為了方便演示用了 int32
和 int8
類型):
在這個例子中,
int8
類型不大于int32
類型,而且它們的內存布局是一樣的,所以可以轉換。
package main import ( "fmt" "unsafe" ) func main() { var a int32 = 2 // p 是 *int8 類型,由 *int32 轉換而來 var p = (*int8)(unsafe.Pointer(&a)) var b int8 = *p fmt.Println(b) // 2 }
unsafe.Pointer(&a)
是指向 a
的 unsafe.Pointer
(本質上是指向 int32
的指針),(*int8)
表示類型轉換,將這個 unsafe.Pointer
轉換為 (*int8)
類型。
覺得代碼不好理解的可以看下圖:
在上圖,我們實際上是創(chuàng)建了一個指向了 a
最低位那 1 字節(jié)的指針,然后取出了這個字節(jié)里面存儲的內容,將其存入了 b
中。
上面提到有一個比較重要的地方,那就是:轉換的時候是占用空間大的類型,轉換為占用空間小的類型,比如 int32
轉 int8
就是符合這個條件的,那么如果我們將一個小的類型轉換為大的類型會發(fā)生什么呢?我們來看看下面這個例子:
package main import ( "fmt" "unsafe" ) type A struct { a int8 } type B struct { b int8 c int8 } func main() { var a = A{1} var b = B{2, 3} // 1. 大轉小 var pa = (*A)(unsafe.Pointer(&b)) fmt.Println(*pa) // {2} // 2. 錯誤示例:小轉大(危險,A 里面 a 后面的內存其實是未知的) var pb = (*B)(unsafe.Pointer(&a)) fmt.Println(*pb) // {1 2} }
大轉?。?code>*B 轉換為 *A
的具體轉換過程可以表示為下圖:
在這個過程中,其實 a
和 b
都沒有改變,本質上我們只是創(chuàng)建了一個 A
類型的指針,這個指針指向變量 b
的地址(但是 *pa
會被看作是 A
類型),所以 pa
實際上是跟 b
共享了內存。
我們可以嘗試修改 (*pa).a = 3
,我們就會發(fā)現(xiàn) b.b
也變成了 3。
也就是說,最終的內存布局是下圖這樣的:
小轉大:*A
轉換為 *B
的具體轉換過程可以表示為下圖:
注意:這是錯誤的用法。(當然也不是完全不行)
在 *A
轉換為 *B
的過程中,因為 B
需要 2 個字節(jié)空間,所以我們拿到的 pb
實際上是包含了 a
后面的 1 個字節(jié),但是這個字節(jié)本來是屬于 b
變量的,這個時候 b
和 *pb
都引用了第 2 個字節(jié),這樣依賴它們在修改這個字節(jié)的時候,會相互影響,這可能不是我們想要的結果,而且這種操作非常危險。
2. 將 Pointer 轉換為 uintptr(但不轉換回 Pointer)
將 Pointer
轉換為 uintptr
會得到 Pointer
指向的內存地址,是一個整數(shù)。這種 uintptr
的通常用途是打印它。
但是,將 uintptr
轉換回 Pointer
通常無效。uintptr
是一個整數(shù),而不是一個引用。將指針轉換為 uintptr
會創(chuàng)建一個沒有指針語義的整數(shù)值。
即使 uintptr
持有某個對象的地址,如果該對象移動,垃圾收集器也不會更新該 uintotr
的值,也不會阻止該對象被回收。
如下面這種,我們取得了變量的地址 p
,然后做了一些其他操作,最后再從這個地址里面讀取數(shù)據(jù):
package main import ( "fmt" "unsafe" ) func main() { var a int = 10 var p = uintptr(unsafe.Pointer(&a)) // ... 其他代碼 // 下面這種轉換是危險的,因為有可能 p 指向的對象已經(jīng)被垃圾回收器回收 fmt.Println(*(*int)(unsafe.Pointer(p))) }
具體如下圖:
只有下面的模式中轉換 uintptr
到 Pointer
是有效的。
3. 使用算術運算將 Pointer 轉換為 uintptr 并轉換回去
如果 p
指向一個已分配的對象,我們可以將 p
轉換為 uintptr
然后加上一個偏移量,再轉換回 Pointer
。如:
p = unsafe.Pointer(uintptr(p) + offset)
這種模式最常見的用法是訪問結構體或者數(shù)組元素中的字段:
// 等價于 f := unsafe.Pointer(&s.f) f := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f)) // 等價于 e := unsafe.Pointer(&x[i]) e := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + i*unsafe.Sizeof(x[0]))
對于第一個例子,完整代碼如下:
package main import ( "fmt" "unsafe" ) type S struct { d int8 f int8 } func main() { var s = S{ d: 1, f: 2, } f := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f)) fmt.Println(*(*int8)(f)) // 2 }
最終的內存布局如下圖(s
的兩個字段都是 1 字節(jié),所以圖中 d
和 f
都是 1 字節(jié)):
詳細說明一下:
第一小節(jié)我們說過了,結構體字段的內存布局是連續(xù)的。上面沒有說的是,其實數(shù)組的內存布局也是連續(xù)的。這對理解下面的內容很有幫助。
&s
取得了結構體s
的地址unsafe.Pointer(&s)
轉換為Pointer
對象,這個指針對象指向的是結構體s
uintptr(unsafe.Pointer(&s))
取得Pointer
對象的內存地址(整數(shù))unsafe.Offsetof(s.f)
取得了f
字段的內存偏移地址(相對地址,相對于s
的地址)uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f)
就是s.f
的實際內存地址了(絕對地址)- 最后轉換回
unsafe.Pointer
對象,這個對象指向的地址是s.f
的地址
最終 f
指向的地址是 s.f
,然后我們可以通過 (*int8)(f)
將 unsafe.Pointer
轉換為 *int8
類型指針,最后通過 *
操作符取得這個指針指向的值。
對于第二個例子,完整代碼如下:
package main import ( "fmt" "unsafe" ) func main() { var x = [3]int8{4, 5, 6} e := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 2*unsafe.Sizeof(x[0])) fmt.Println(*(*int8)(e)) // 6 }
最終的內存布局如下圖,e
指向了數(shù)組的第 3 個元素(下標從 0 開始算的):
代碼中的 2 可以是其他任何有效的數(shù)組下標。
&s
取得了數(shù)組x
的地址unsafe.Pointer(&x)
轉換為Pointer
對象,這個指針對象指向的是數(shù)組x
uintptr(unsafe.Pointer(&x))
取得Pointer
對象的內存地址(也就是0xab
)unsafe.Sizeof(x[0])
是數(shù)組x
里面每一個元素所需要的內存大小,乘以2
表示是元素x[2]
的地址偏移量(相對地址,相對于x[0]
的地址)uintptr(unsafe.Pointer(&x)) + 2*unsafe.Sizeof(x[0])
表示的是數(shù)組元素x[2]
的實際內存地址(絕對地址)- 最后轉換回
unsafe.Pointer
對象,這個對象指向的地址是x[2]
的地址(也就是0xab + 2
)。
最終,我們可以通過 (*int8)
將 e
轉換為 *int8
類型的指針,最后通過 *
操作符獲取其指向的內容,也就是 6。
以這種方式對指針進行加減偏移量的運算都是有效的。(em…這里說的是寫在同一行的這種方式)。這種情況下使用 &^
這兩個操作符也是有效的(通常用于內存對齊)。
在所有情況下,得到的結果必須指向原始分配的對象。
不像 C 語言,將指針加上一個超出其原始分配的內存區(qū)域的偏移量是無效的:
// 無效: end 指向了分配的空間以外的區(qū)域 var s thing end = unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Sizeof(s))
下面對切片的這種操作也跟上圖類似。
// 無效: end 指向了分配的空間以外的區(qū)域 b := make([]byte, n) end = unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n))
這是因為,內存的地址范圍是 [start, end)
,是不包含終點的那個地址的,上面的 end
都指向了地址的邊界,這是無效的。
當然,除了邊界上,邊界以外都是無效的。(end
指向的內存不是屬于那個變量的)
注意:兩個轉換(Pointer
=> uintptr
, uintptr
=> Pointer
)必須出現(xiàn)在同一個表達式中,只有中間的算術運算:
// 無效: uintptr 在轉換回 Pointer 之前不能存儲在變量中 // 原因上面也說過了,就是 p 指向的內容可能會被垃圾回收器回收。 u := uintptr(p) p = unsafe.Pointer(u + offset)
注意:指針必須指向已分配的對象,因此它不能是 nil
。
// 無效: nil 指針轉換 u := unsafe.Pointer(nil) p := unsafe.Pointer(uintptr(u) + offset)
4. 調用 syscall.Syscall 時將指針轉換為 uintptr
覺得文字太啰嗦可以直接看圖:
syscall
包中的 Syscall
函數(shù)將其 uintptr
參數(shù)直接傳遞給操作系統(tǒng),然后操作系統(tǒng)可以根據(jù)調用的細節(jié)將其中一些參數(shù)重新解釋為指針。
也就是說,系統(tǒng)調用實現(xiàn)隱式地將某些參數(shù)從 uintptr
轉換回指針。
如果必須將指針參數(shù)轉換為 uintptr
以用作參數(shù),則該轉換必須出現(xiàn)在調用表達式本身中:
syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))
編譯器通過安排被引用的分配對象(如果有的話)被保留,并且在調用完成之前不移動,來處理在調用程序集中實現(xiàn)的函數(shù)的參數(shù)列表中轉換為 uintptr
的指針,
即使僅從類型來看,在調用期間似乎不再需要對象。
為了使編譯器識別該模式,轉換必須出現(xiàn)在參數(shù)列表中:
// 無效:在系統(tǒng)調用期間隱式轉換回指針之前, // uintptr 不能存儲在變量中。 u := uintptr(unsafe.Pointer(p)) syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))
5. 將 reflect.Value.Pointer 或 reflect.Value.UnsafeAddr 的結果從 uintptr 轉換為 Pointer
reflect.Value
的 Pointer
和 UnsafeAddr
方法返回類型 uintptr
而不是 unsafe.Pointer
,從而防止調用者在未導入 unsafe
包的情況下將結果更改為任意類型。(這是為了防止開發(fā)者對 Pointer
的誤操作。)
然而,這也意味著這個返回的結果是脆弱的,我們必須在調用之后立即轉換為 Pointer
(如果我們確切的需要一個 Pointer
):
其實就是為了讓開發(fā)者明確自己知道在干啥,要不然寫出了 bug 都不知道。
// 在調用了 reflect.Value 的 Pointer 方法后, // 立即轉換為 unsafe.Pointer。 p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))
與上述情況一樣,在轉換之前存儲結果是無效的:
// 無效: uintptr 在轉換回 Pointer 之前不能保存在變量中 u := reflect.ValueOf(new(int)).Pointer() // uintptr 保存到了 u 中 p := (*int)(unsafe.Pointer(u))
原因上面也說了,因為 u
指向的內存是不受保護的,可能會被垃圾回收器收集。
6. 將 reflect.SliceHeader 或 reflect.StringHeader 的 Data 字段跟 Pointer 互相轉換
與前面的情況一樣,反射數(shù)據(jù)結構 SliceHeader
和 StringHeader
將字段 Data
聲明為 uintptr
,以防止調用者在不首先導入 unsafe
的情況下將結果更改為任意類型。
然而,這意味著 SliceHeader
和 StringHeader
僅在解析實際切片或字符串值的內容時有效。
我們先來看看這兩個結構體的定義:
// SliceHeader 是切片的運行時表示(內存布局跟切片一致) // 它不能安全或可移植地使用,其表示形式可能會在以后的版本中更改。 // 此外,Data 字段不足以保證它引用的數(shù)據(jù)不會被垃圾回收器收集, // 因此程序必須保留一個指向底層數(shù)據(jù)的單獨的、正確類型的指針。 type SliceHeader struct { Data uintptr Len int Cap int } // StringHeader 字符串的運行時表示(內存布局跟字符串一致) // ... 其他注意事項跟 SliceHeader 一樣 type StringHeader struct { Data uintptr Len int }
使用示例:
// 將字符串的內容修改為 p 指向的內容 var s string hdr := (*reflect.StringHeader)(unsafe.Pointer(&s)) hdr.Data = uintptr(unsafe.Pointer(p)) hdr.Len = n
這種轉換是有效的,因為 SliceHeader 的內存布局和 StringHeader 的內存布局一致,并且 SliceHeader 所占用的內存空間比StringHeader 所占用內存空間大,也就是說,這是一種大小更大的類型轉換為大小更小的類型,這會丟失 SliceHeader 的一部分數(shù)據(jù),但是丟失的那部分對我們程序正常運行是沒有任何影響的。
在這個用法中,hdr.Data
實際上是引用字符串頭中的基礎指針的另一種方式,而不是 uintptr 變量本身。
(我們這里也是使用了 uintptr
表達式,而不是一個存儲了 uintptr
類型的變量)
通常來說,reflect.SliceHeader
和 reflect.StringHeader
通常用在指向實際切片或者字符串的*reflect.SliceHeader
和 *reflect.StringHeader
,永遠不會被當作普通結構體使用。
程序不應該聲明或者分配這些結構體類型的變量,下面的寫法是有風險的。
// 無效: 直接聲明的 Header 不會將 Data 作為引用 var hdr reflect.StringHeader hdr.Data = uintptr(unsafe.Pointer(p)) hdr.Len = n s := *(*string)(unsafe.Pointer(&hdr)) // p 可能已經(jīng)丟失
Add 函數(shù)
函數(shù)原型是:func Add(ptr Pointer, len IntegerType) Pointer
這個函數(shù)的作用是,可以將 unsafe.Pointer
類型加上一個偏移量得到一個指向新地址的 unsafe.Pointer
。
簡單點來說,就是對 unsafe.Pointer
做算術運算的,上面我們說過 unsafe.Pointer
是不能直接進行算術運算的,因此需要先轉換為 uintptr
然后再進行算術運算,算完再轉換回 unsafe.Pointer
類型,所以會很繁瑣。
有了 Add
方法,我們可以寫得簡單一些,不用做 uintptr
的轉換。
有了 Add
,我們可以簡化一下上面那個通過數(shù)組指針加偏移量的例子,示例:
package main import ( "fmt" "unsafe" ) func main() { var x = [3]int8{4, 5, 6} //e := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 2*unsafe.Sizeof(x[0])) e := unsafe.Add(unsafe.Pointer(&x), 2 * unsafe.Sizeof(x[0])) fmt.Println(*(*int8)(e)) // 6 }
在這個例子中,我們先是通過 unsafe.Pointer(&x)
獲取到了一個指向 x
的 unsafe.Pointer
對象,然后通過 unsafe.Add
加上了 2 個 int8
類型大小的偏移量,最終得到的是一個指向 x[2]
的 unsafe.Pointer
。
Add 方法可以簡化我們對指針的一些操作。
Slice 函數(shù)
Slice
函數(shù)的原型是:func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
函數(shù) Slice
返回一個切片,其底層數(shù)組以 ptr
開頭,長度和容量為 len
。
unsafe.Slice(ptr, len)
等價于:
(*[len]ArbitraryType)(unsafe.Pointer(ptr))[:]
除了這個,作為一種特殊情況,如果 ptr
為 nil
,len
為零,則 Slice
返回 nil
。
示例:
package main import ( "fmt" "unsafe" ) func main() { var x = [6]int8{4, 5, 6, 7, 8, 9} // 這里取了數(shù)組第一個元素 x[1] 的地址, // 從這個地址開始取了 3 個元素作為新的切片底層數(shù)組, // 返回這個新的切片 s := unsafe.Slice(&x[1], 3) fmt.Println(s) // [5 6 7] }
需要非常注意的是,第一個參數(shù)實際上隱含傳遞了該地址對應的類型信息,上面用了
&x[1]
,傳遞的類型實際上是int8
。
如果我們按照下面這樣寫,得到的結果就是錯誤的,因為它隱式傳遞的類型是 [6]int8
(這是一個數(shù)組),而不是 int8
:
// 錯誤示例: package main import ( "fmt" "unsafe" ) func main() { var x = [6]int8{4, 5, 6, 7, 8, 9} // unsafe.Slice 第一個參數(shù)接收到的類型是 [6]int, // 所以最終返回了一個切片,這個切片有三個元素, // 每一個元素都是長度為 6 數(shù)據(jù)類型為 int8 的數(shù)組。 // 也即形如 [[6]int8, [6]int8, [6]int8] 的切片 s := unsafe.Slice(&x, 3) // [[4 5 6 7 8 9] [91 91 52 32 53 32] [54 32 4 5 6 7]] fmt.Println(s) }
這樣顯然不是我們想要的結果,因為它讀取到了一部分未知的內存,如果我們修改這部分內存,可能會造成程序崩潰。
一個很常見的用法
在實際應用中,很多框架為了提高性能,在做 []byte
和 string
的切換的時候,往往會使用 unsafe.Pointer
來實現(xiàn)(比如 gin
框架):
下面這個例子實現(xiàn)了 []byte
到 string
的轉換,而且避免了內存分配。這是因為,切片和字符串的內存布局是一致的,只不過切片比字符串占用
的空間多了一點,還有一個 cap
容量字段,用來表示切片的容量是多少。具體我們可以再看看上面的 reflect.SliceHeader
和 reflect.StringHeader
,
在下面這個字節(jié)切片到字符串的轉換過程中,是從占用空間更大的類型轉換為占用空間更小的類型,所以是安全的,丟失的那個 cap
對我們程序正常運行無影響。
先看看 []byte
和 string
的類型底層定義:
// 字符串 type stringStruct struct { str unsafe.Pointer len int } // 切片,比 string 的結構體多了一個 cap 字段,但是前面的兩個字段是一樣的 type slice struct { array unsafe.Pointer len int cap int }
[]byte
轉字符串的示例:
func BytesToString(b []byte) string { // 將 b 解析為字符串 return *(*string)(unsafe.Pointer(&b)) }
這個操作如下圖:
在這個轉換過程中,其實只是將 b
表示的類型轉由 []byte
轉換為了 string
,之所以可以這么轉,是因為 []byte
的內存布局跟 string
的內存布局是一樣的,但是由于字符串實際占用空間比切片類型要小(不包括其底層指針指向的內容),所以在轉換過程中,cap
字段丟失了,但是 strin
也不需要這個字段,所以對程序運行沒影響。
同時字符串長度是按照字節(jié)計算的,所以字節(jié)切片和字符串的 len 字段是一樣的,不需要做額外處理。
字符串轉 []byte
的示例:
func StringToBytes(s string) []byte { return *(*[]byte)(unsafe.Pointer( // 定義匿名結構體變量,內存布局跟 []byte 一致, // 這樣就可以轉換為 []byte 了。 &struct { string Cap int }{s, len(s)}, )) }
這個操作如下圖:
這個過程只是需要分配很小一部分內存就可以完成了,效率比 go 自帶的轉換高。
go 里面字符串是不可變的,但 go 為了維持字符串不可變的特性,在字符串和字節(jié)切片之間轉換一般都是通過數(shù)據(jù)拷貝的方式實現(xiàn)的。
因為這樣就不會影響到原來的字符串或者字節(jié)切片了,但是這樣做的性能會非常低。
具體可參考slicebytetostring
和stringtoslicebyte
函數(shù),這兩個函數(shù)位于runtime/string.go
中。
總結
本文主要講了如下內容:
- 內存布局:結構體的字段存儲是占用了連續(xù)的一段內存,而且結構體可能會占用比實際需要空間更大的內存,因為需要對齊內存。
- 指針存儲了指向變量的地址,對這個地址使用
*
操作符可以獲取這個地址指向的內容。 uintptr
是 C 里面的一種命名慣例,u
前綴的意思是unsigned
,int
表示是int
類型,ptr
表示這個類型是用來表示指針的。unsafe
定義的Pointer
類型是一種可以指向任何類型的指針,ArbitraryType
可用于表示任意類型。- 我們通過
unsafe.Pointer
修改結構體字段的時候,要使用unsafe.Offsetof
獲取結構體的偏移量。 - 通過
unsafe.Sizeof
可以獲得某一種類型所需要的內存空間大?。ㄆ渲邪擞糜趦却鎸R的內存)。 unsafe.Pointer
與uintptr
之間的類型轉換。- 幾種使用
unsafe.Pointer
的模式:*T1
到*T2
的轉換unsafe.Pointer
轉換為uintptr
- 使用算術運算將
unsafe.Pointer
轉換為uintptr
并轉換回去(需要注意不能使用中間變量來保存uintptr(unsafe.Pointer(p))
) - 調用
syscall.Syscall
時將指針轉換為uintptr
- 將
reflect.Value
的Pointer
和UnsafeAddr
的結果從uintptr
轉換為unsafe.Pointer
- 將
reflect.SliceHeader
或reflect.StringHeader
的Data
字段跟Pointer
互相轉換
Add
函數(shù)可以簡化指針的算術運算,不用來回轉換類型(比如unsafe.Pointer
轉換為uintptr
,然后再轉換為unsafe.Pointer
)。Slice
函數(shù)可以獲取指針指向內存的一部分。- 最后介紹了
string
和[]byte
之間通過unsafe.Pointer
實現(xiàn)高效轉換的方法。
到此這篇關于深入理解go unsafe用法及注意事項的文章就介紹到這了,更多相關go unsafe用法內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Go語言實現(xiàn)類似c++中的多態(tài)功能實例
Go本身不具有多態(tài)的特性,不能夠像Java、C++那樣編寫多態(tài)類、多態(tài)方法。但是,使用Go可以編寫具有多態(tài)功能的類綁定的方法。下面來一起看看吧2016-09-09Golang 實現(xiàn)interface類型轉string類型
這篇文章主要介紹了Golang 實現(xiàn)interface類型轉string類型的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-04-04