欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

深入理解go unsafe用法及注意事項

 更新時間:2024年01月09日 14:48:05   作者:rubys007  
go雖然是一種高級語言,但是也還是給開發(fā)者提供了指針的類型unsafe.Pointer,我們可以通過它來直接讀寫變量的內存,本文來了解一下?unsafe?里所能提供的關于指針的一些功能,以及使用unsafe.Pointer的一些注意事項

學過 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 *intSizeof(x) 會返回 8(在我的機器上,不同機器可能不一樣),另外就算引用指向了一個復合類型,比如結構體,返回的還是 8(因為變量本身存儲的只是內存地址)。
  • 結構體類型,如果是結構體,那么 Sizeof 返回的大小包含了用于內存對齊的內存(所以可能會比結構體底層類型所需要的實際大小要大)
  • 切片,Sizeof 返回的是 24(返回的是切片這個類型所需要占用空間的大小,我們需要知道,切片底層是 slice 結構體,里面三個字段分別是 array unsafe.Pointerlen 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 => uintptruintptr => 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語言實現(xiàn)類似c++中的多態(tài)功能實例

    Go本身不具有多態(tài)的特性,不能夠像Java、C++那樣編寫多態(tài)類、多態(tài)方法。但是,使用Go可以編寫具有多態(tài)功能的類綁定的方法。下面來一起看看吧
    2016-09-09
  • Go語言中的方法定義用法分析

    Go語言中的方法定義用法分析

    這篇文章主要介紹了Go語言中的方法定義用法,實例分析了方法的定義及使用技巧,具有一定參考借鑒價值,需要的朋友可以參考下
    2015-02-02
  • Golang處理parquet文件實戰(zhàn)指南

    Golang處理parquet文件實戰(zhàn)指南

    這篇文章主要給大家介紹了關于Golang處理parquet文件的相關資料,文中通過實例代碼介紹的非常詳細,對大家學習或者使用Golang具有一定的參考學習價值,需要的朋友可以參考下
    2023-03-03
  • go xorm存庫處理null值問題

    go xorm存庫處理null值問題

    這篇文章主要介紹了go xorm存庫處理null值問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教
    2023-12-12
  • Go語言基礎枚舉的用法及示例詳解

    Go語言基礎枚舉的用法及示例詳解

    這篇文章主要為大家介紹了Go語言基礎枚舉的用法及示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪
    2021-11-11
  • go-zero服務部署配置及源碼解讀

    go-zero服務部署配置及源碼解讀

    這篇文章主要為大家介紹了go-zero服務部署配置及源碼解讀,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪
    2023-08-08
  • GO語言操作Elasticsearch示例分享

    GO語言操作Elasticsearch示例分享

    這篇文章主要介紹了GO語言操作Elasticsearch示例分享的相關資料,需要的朋友可以參考下
    2023-01-01
  • Golang 實現(xiàn)interface類型轉string類型

    Golang 實現(xiàn)interface類型轉string類型

    這篇文章主要介紹了Golang 實現(xiàn)interface類型轉string類型的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧
    2021-04-04
  • Hugo 游樂場內容初始化示例詳解

    Hugo 游樂場內容初始化示例詳解

    這篇文章主要為大家介紹了Hugo 游樂場內容初始化示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪
    2023-02-02
  • golang DNS服務器的簡單實現(xiàn)操作

    golang DNS服務器的簡單實現(xiàn)操作

    這篇文章主要介紹了golang DNS服務器的簡單實現(xiàn)操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧
    2021-04-04

最新評論