Go語言 非類型安全指針

2023-02-16 17:38 更新

我們已經(jīng)從Go中的指針一文中學(xué)習(xí)到關(guān)于指針的各種概念和規(guī)則。 從那篇文章中,我們得知,相對(duì)于C指針,Go指針有很多限制。 比如,Go指針不支持算術(shù)運(yùn)算,并且對(duì)于任意兩個(gè)指針值,很可能它們不能轉(zhuǎn)換到對(duì)方的類型。

事實(shí)上,在那篇文章中解釋的指針的完整稱呼應(yīng)該為類型安全指針。 雖然類型安全指針有助于我們輕松寫出安全的代碼,但是有時(shí)候施加在類型安全指針上的限制也確實(shí)導(dǎo)致我們不能寫出最高效的代碼。

實(shí)際上,Go也支持限制較少的非類型安全指針。 非類型安全指針和C指針類似,它們都很強(qiáng)大,但同時(shí)也都很危險(xiǎn)。 在某些情形下,通過非類型安全指針的幫助,我們可以寫出效率更高的代碼; 但另一方面,使用非類型安全指針也導(dǎo)致我們可能輕易地寫出潛在的不安全的代碼,這些潛在的不安全點(diǎn)很難在它們產(chǎn)生危害之前被及時(shí)發(fā)現(xiàn)。

使用非類型安全指針的另外一個(gè)較大的風(fēng)險(xiǎn)是Go中目前提供的非類型安全指針機(jī)制并不受到Go 1 兼容性保證的保護(hù)。 使用了非類型安全指針的代碼可能從今后的某個(gè)Go版本開始將不再能編譯通過,或者運(yùn)行行為發(fā)生了變化。

如果出于種種原因,你確實(shí)希望在你的代碼中使用非類型安全指針,你不僅需要提防上述風(fēng)險(xiǎn),你還需遵守Go官方文檔中列出的非類型安全指針使用模式,并清楚地知曉使用非類型安全指針帶來的效果。否則,你很難使用非類型安全指針寫出安全的代碼。

關(guān)于unsafe標(biāo)準(zhǔn)庫包

非類型安全指針在Go中為一種特別的類型。 我們必須引入unsafe標(biāo)準(zhǔn)庫包來使用非類型安全指針。 非類型安全指針unsafe.Pointer被聲明定義為:

type Pointer *ArbitraryType

當(dāng)然,這不是一個(gè)普通的類型定義。這里的ArbitraryType僅僅是暗示unsafe.Pointer類型值可以被轉(zhuǎn)換為任意類型安全指針(反之亦然)。換句話說,unsafe.Pointer類似于C語言中的void*

非類型安全指針是指底層類型為unsafe.Pointer的類型。

非類型安全指針的零值也使用預(yù)聲明的nil標(biāo)識(shí)符來表示。

在Go 1.17之前,unsafe標(biāo)準(zhǔn)庫包只提供了三個(gè)函數(shù):

  • func Alignof(variable ArbitraryType) uintptr。 此函數(shù)用來取得一個(gè)值在內(nèi)存中的地址對(duì)齊保證(address alignment guarantee)。 注意,同一個(gè)類型的值做為結(jié)構(gòu)體字段和非結(jié)構(gòu)體字段時(shí)地址對(duì)齊保證可能是不同的。 當(dāng)然,這和具體編譯器的實(shí)現(xiàn)有關(guān)。對(duì)于目前的標(biāo)準(zhǔn)編譯器,同一個(gè)類型的值做為結(jié)構(gòu)體字段和非結(jié)構(gòu)體字段時(shí)的地址對(duì)齊保證總是相同的。 gccgo編譯器對(duì)這兩種情形是區(qū)別對(duì)待的。
  • func Offsetof(selector ArbitraryType) uintptr。 此函數(shù)用來取得一個(gè)結(jié)構(gòu)體值的某個(gè)字段的地址相對(duì)于此結(jié)構(gòu)體值的地址的偏移。 在一個(gè)程序中,對(duì)于同一個(gè)結(jié)構(gòu)體類型的不同值的對(duì)應(yīng)相同字段,此函數(shù)的返回值總是相同的。
  • func Sizeof(variable ArbitraryType) uintptr。 此函數(shù)用來取得一個(gè)值的尺寸(亦即此值的類型的尺寸)。 在一個(gè)程序中,對(duì)于同一個(gè)類型的不同值,此函數(shù)的返回值總是相同的。

注意:

  • 這三個(gè)函數(shù)的返回值的類型均為內(nèi)置類型uintptr。下面我們將了解到uintptr類型的值可以轉(zhuǎn)換為非類型安全指針(反之亦然)。
  • 盡管這三個(gè)函數(shù)之一的任何調(diào)用的返回結(jié)果在同一個(gè)編譯好的程序中總是一致的,但是這樣的一個(gè)調(diào)用在不同架構(gòu)的操作系統(tǒng)中(或者使用不同的編譯器編譯時(shí))的返回值可能是不一樣的。
  • 這三個(gè)函數(shù)的調(diào)用總是在編譯時(shí)刻被估值,估值結(jié)果為類型為uintptr的常量。
  • 傳遞給Offsetof函數(shù)的實(shí)參必須為一個(gè)字段選擇器形式value.field。 此選擇器可以表示一個(gè)內(nèi)嵌字段,但此選擇器的路徑中不能包含指針類型的隱式字段。

一個(gè)使用了這三個(gè)函數(shù)的例子:

package main

import "fmt"
import "unsafe"

func main() {
	var x struct {
		a int64
		b bool
		c string
	}
	const M, N = unsafe.Sizeof(x.c), unsafe.Sizeof(x)
	fmt.Println(M, N) // 16 32

	fmt.Println(unsafe.Alignof(x.a)) // 8
	fmt.Println(unsafe.Alignof(x.b)) // 1
	fmt.Println(unsafe.Alignof(x.c)) // 8

	fmt.Println(unsafe.Offsetof(x.a)) // 0
	fmt.Println(unsafe.Offsetof(x.b)) // 8
	fmt.Println(unsafe.Offsetof(x.c)) // 16
}

下面是一個(gè)展示了上面提到的最后一個(gè)注意點(diǎn)的例子:

package main

import "fmt"
import "unsafe"

func main() {
	type T struct {
		c string
	}
	type S struct {
		b bool
	}
	var x struct {
		a int64
		*S
		T
	}

	fmt.Println(unsafe.Offsetof(x.a)) // 0
	
	fmt.Println(unsafe.Offsetof(x.S)) // 8
	fmt.Println(unsafe.Offsetof(x.T)) // 16
	
	// 此行可以編譯過,因?yàn)檫x擇器x.c中的隱含字段T為非指針。
	fmt.Println(unsafe.Offsetof(x.c)) // 16
	
	// 此行編譯不過,因?yàn)檫x擇器x.b中的隱含字段S為指針。
	//fmt.Println(unsafe.Offsetof(x.b)) // error
	
	// 此行可以編譯過,但是它將打印出字段b在x.S中的偏移量.
	fmt.Println(unsafe.Offsetof(x.S.b)) // 0
}

注意,上面程序中的注釋所暗示的輸出結(jié)果是此程序在AMD64架構(gòu)上使用標(biāo)準(zhǔn)編譯器1.19版本編譯時(shí)的結(jié)果。

unsafe包提供的這三個(gè)函數(shù)看上去并不怎么危險(xiǎn)。 它們的原型在以后的Go 1版本中幾乎不可能會(huì)發(fā)生改變。 Rob Pike甚至曾經(jīng)將這幾個(gè)函數(shù)挪到其它包中。 unsafe包的危險(xiǎn)性基本上來自于非類型安全指針。它們和C指針一樣危險(xiǎn),這是Go安全指針千方百計(jì)設(shè)法去避免的。

Go 1.17引入了一個(gè)新類型和兩個(gè)新函數(shù)。 此新類型為IntegerType。它的定義如下。 此類型不代表著一個(gè)具體類型,它只是表示任意整數(shù)類型(有點(diǎn)泛型的意思)。

type IntegerType int

Go 1.17引入的兩個(gè)函數(shù)為:

  • func Add(ptr Pointer, len IntegerType) Pointer。 此函數(shù)在一個(gè)(非安全)指針表示的地址上添加一個(gè)偏移量,然后返回表示新地址的一個(gè)指針。 此函數(shù)以一種更正規(guī)的形式部分地覆蓋了下面將要介紹的使用模式3中展示的合法用法。
  • func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType。 此函數(shù)用來從一個(gè)任意(安全)指針派生出一個(gè)指定長度的切片。

Go 1.20進(jìn)一步引入了三個(gè)函數(shù):

  • func String(ptr *byte, len IntegerType) string。 此函數(shù)用來從一個(gè)任意(安全)byte指針派生出一個(gè)指定長度的字符串。
  • func StringData(str string) *byte。 此函數(shù)用來獲取一個(gè)字符串底層字節(jié)序列中的第一個(gè)byte的指針。
  • func SliceData(slice []ArbitraryType) *ArbitraryType。 此函數(shù)用來獲取一個(gè)切片底層元素序列中的第一個(gè)元素的指針。

Go 1.17之后引入的這些函數(shù)具有一定的危險(xiǎn)性,需謹(jǐn)慎使用。 下面是使用了Go 1.17引入的兩個(gè)函數(shù)的一個(gè)例子。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	a := [16]int{3: 3, 9: 9, 11: 11}
	fmt.Println(a)
	eleSize := int(unsafe.Sizeof(a[0]))
	p9 := &a[9]
	up9 := unsafe.Pointer(p9)
	p3 := (*int)(unsafe.Add(up9, -6 * eleSize))
	fmt.Println(*p3) // 3
	s := unsafe.Slice(p9, 5)[:3]
	fmt.Println(s) // [9 0 11]
	fmt.Println(len(s), cap(s)) // 3 5

	t := unsafe.Slice((*int)(nil), 0)
	fmt.Println(t == nil) // true

	// 下面是兩個(gè)不正確的調(diào)用。因?yàn)樗鼈?	// 的返回結(jié)果引用了未知的內(nèi)存塊。
	_ = unsafe.Add(up9, 7 * eleSize)
	_ = unsafe.Slice(p9, 8)
}

下面這兩個(gè)函數(shù)使用了非類型安全的方式實(shí)現(xiàn)了字符串和字節(jié)切片之間的類型轉(zhuǎn)換。 和類型安全方式相比,它們不用復(fù)制字符串和字節(jié)切片的底層字節(jié)序列,因此效率更高。

import "unsafe"

func String2ByteSlice(str string) []byte {
	if str == "" {
		return nil
	}
	return unsafe.Slice(unsafe.StringData(str), len(str))
}

func ByteSlice2String(bs []byte) string {
	if len(bs) == 0 {
		return ""
	}
	return unsafe.String(unsafe.SliceData(bs), len(bs))
}

非類型安全指針相關(guān)的類型轉(zhuǎn)換

目前(Go 1.19),Go支持下列和非類型安全指針相關(guān)的類型轉(zhuǎn)換:

  • 一個(gè)類型安全指針值可以被顯式轉(zhuǎn)換為一個(gè)非類型安全指針類型,反之亦然。
  • 一個(gè)uintptr值可以被顯式轉(zhuǎn)換為一個(gè)非類型安全指針類型,反之亦然。 但是,注意,一個(gè)nil非類型安全指針類型不應(yīng)該被轉(zhuǎn)換為uintptr并進(jìn)行算術(shù)運(yùn)算后再轉(zhuǎn)換回來。

通過使用這些轉(zhuǎn)換規(guī)則,我們可以將任意兩個(gè)類型安全指針轉(zhuǎn)換為對(duì)方的類型,我們也可以將一個(gè)安全指針值和一個(gè)uintptr值轉(zhuǎn)換為對(duì)方的類型。

然而,盡管這些轉(zhuǎn)換在編譯時(shí)刻是合法的,但是它們中一些在運(yùn)行時(shí)刻并非是合法和安全的。 這些轉(zhuǎn)換摧毀了Go的類型系統(tǒng)(不包括非類型安全指針部分)精心設(shè)立的內(nèi)存安全屏障。 我們必須遵循本文后面要介紹的一些用法指示來使用非類型安全指針才能寫出合法并安全的代碼。

我們需要知道的一些事實(shí)

在開始介紹合法的非類型安全指針使用模式之前,我們需要知道一些事實(shí)。

事實(shí)一:非類型安全指針值是指針但uintptr值是整數(shù)

每一個(gè)非零安全或者不安全指針值均引用著另一個(gè)值。但是一個(gè)uintptr值并不引用任何值,它被看作是一個(gè)整數(shù),盡管常常它存儲(chǔ)的是一個(gè)地址的數(shù)字表示。

Go是一門支持垃圾回收的語言。 當(dāng)一個(gè)Go程序在運(yùn)行中,Go運(yùn)行時(shí)(runtime)將不時(shí)地檢查哪些內(nèi)存塊將不再被程序中的任何仍在使用中的值所引用并且回收這些內(nèi)存塊。 指針在這一過程中扮演著重要的角色。值與值之間和內(nèi)存塊與值之間的引用關(guān)系是通過指針來表征的。

既然一個(gè)uintptr值是一個(gè)整數(shù),那么它可以參與算術(shù)運(yùn)算。

下一節(jié)中的例子將展示指針和uintptr值的不同。

事實(shí)二:不再被使用的內(nèi)存塊的回收時(shí)間點(diǎn)是不確定的

在運(yùn)行時(shí)刻,一次新的垃圾回收過程可能在一個(gè)不確定的時(shí)間啟動(dòng),并且此過程可能需要一段不確定的時(shí)長才能完成。 所以一個(gè)不再被使用的內(nèi)存塊的回收時(shí)間點(diǎn)是不確定的。

一個(gè)例子:

import "unsafe"

// 假設(shè)此函數(shù)不會(huì)被內(nèi)聯(lián)(inline)。
//go:noinline
func createInt() *int {
	return new(int)
}

func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y) // 和y一樣引用著同一個(gè)值
	var p2 = uintptr(unsafe.Pointer(z))

	// 此時(shí),即使z指針值所引用的int值的地址仍舊存儲(chǔ)
	// 在p2值中,但是此int值已經(jīng)不再被使用了,所以垃圾
	// 回收器認(rèn)為可以回收它所占據(jù)的內(nèi)存塊了。另一方面,
	// p0和p1各自所引用的int值仍舊將在下面被使用。

	// uintptr值可以參與算術(shù)運(yùn)算。
	p2 += 2; p2--; p2--

	*p0 = 1                         // okay
	*(*int)(p1) = 2                 // okay
	*(*int)(unsafe.Pointer(p2)) = 3 // 危險(xiǎn)操作!
}

在上面這個(gè)例子中,值p2仍舊在使用這個(gè)事實(shí)并不能保證曾經(jīng)被z指針值所引用的int值所占的內(nèi)存塊一定還沒有被回收。 換句話說,當(dāng)*(*int)(unsafe.Pointer(p2)) = 3被執(zhí)行的時(shí)候,此內(nèi)存塊有可能已經(jīng)被回收了。 所以,繼續(xù)通過解引用值p2中存儲(chǔ)的地址是非常危險(xiǎn)的,因?yàn)榇藘?nèi)存塊可能已經(jīng)被重新分配給其它值使用了。

事實(shí)三:一個(gè)值的地址在程序運(yùn)行中可能改變

詳情請(qǐng)閱讀內(nèi)存塊一文(見鏈接所指一節(jié)的尾部)。 這里我們只需要知道當(dāng)一個(gè)協(xié)程的棧的大小改變時(shí),開辟在此棧上的內(nèi)存塊需要移動(dòng),從而相應(yīng)的值的地址將改變。

事實(shí)四:一個(gè)值的生命范圍可能并沒有代碼中看上去的大

比如中下面這個(gè)例子,值t仍舊在使用中并不能保證被值t.y所引用的值仍在被使用。

type T struct {
	x int
	y *[1<<23]byte
}

func bar() {
	t := T{y: new([1<<23]byte)}
	p := uintptr(unsafe.Pointer(&t.y[0]))

	... // 使用t.x和t.y

	// 一個(gè)聰明的編譯器能夠覺察到值t.y將不會(huì)再被用到,
	// 所以認(rèn)為t.y值所占的內(nèi)存塊可以被回收了。

	*(*byte)(unsafe.Pointer(p)) = 1 // 危險(xiǎn)操作!

	println(t.x) // ok。繼續(xù)使用值t,但只使用t.x字段。
}

事實(shí)五:*unsafe.Pointer是一個(gè)類型安全指針類型

是的,類型*unsafe.Pointer是一個(gè)類型安全指針類型。 它的基類型為unsafe.Pointer。 既然它是一個(gè)類型安全指針類型,根據(jù)上面列出的類型轉(zhuǎn)換規(guī)則,它的值可以轉(zhuǎn)換為類型unsafe.Pointer,反之亦然。

一個(gè)例子:

package main

import "unsafe"

func main() {
	x := 123                // 類型為int
	p := unsafe.Pointer(&x) // 類型為unsafe.Pointer
	pp := &p                // 類型為*unsafe.Pointer
	p = unsafe.Pointer(pp)
	pp = (*unsafe.Pointer)(p)
}

如何正確地使用非類型安全指針?

unsafe標(biāo)準(zhǔn)庫包的文檔中列出了六種非類型安全指針的使用模式。 下面將對(duì)它們逐一進(jìn)行講解。

使用模式一:將類型*T1的一個(gè)值轉(zhuǎn)換為非類型安全指針值,然后將此非類型安全指針值轉(zhuǎn)換為類型*T2。

利用前面列出的非類型安全指針相關(guān)的轉(zhuǎn)換規(guī)則,我們可以將一個(gè)*T1值轉(zhuǎn)換為類型*T2,其中T1T2為兩個(gè)任意類型。 然而,我們只有在T1的尺寸不小于T2并且此轉(zhuǎn)換具有實(shí)際意義的時(shí)候才應(yīng)該實(shí)施這樣的轉(zhuǎn)換。

通過將一個(gè)*T1值轉(zhuǎn)換為類型*T2,我們也可以將一個(gè)T1值轉(zhuǎn)換為類型T2。

一個(gè)這樣的例子是math標(biāo)準(zhǔn)庫包中的Float64bits函數(shù)。 此函數(shù)將一個(gè)float64值轉(zhuǎn)換為一個(gè)uint64值。 在此轉(zhuǎn)換過程中,此float64值在內(nèi)存中的每個(gè)位(bit)都保持不變。 函數(shù)math.Float64frombits為此轉(zhuǎn)換的逆轉(zhuǎn)換。

func Float64bits(f float64) uint64 {
	return *(*uint64)(unsafe.Pointer(&f))
}

func Float64frombits(b uint64) float64 {
	return *(*float64)(unsafe.Pointer(&b))
}

請(qǐng)注意,函數(shù)調(diào)用math.Float64bits(aFloat64)的結(jié)果和顯式轉(zhuǎn)換uint64(aFloat64)的結(jié)果不同。

在下面這個(gè)例子中,我們使用此模式將一個(gè)[]MyString值和一個(gè)[]string值轉(zhuǎn)換為對(duì)方的類型。 結(jié)果切片和被轉(zhuǎn)換的切片將共享底層元素。(這樣的轉(zhuǎn)換是不可能通過安全的方式來實(shí)現(xiàn)的。)

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	type MyString string
	ms := []MyString{"C", "C++", "Go"}
	fmt.Printf("%s\n", ms)  // [C C++ Go]
	// ss := ([]string)(ms) // 編譯錯(cuò)誤
	ss := *(*[]string)(unsafe.Pointer(&ms))
	ss[1] = "Zig"
	fmt.Printf("%s\n", ms) // [C Zig Go]
	// ms = []MyString(ss) // 編譯錯(cuò)誤
	ms = *(*[]MyString)(unsafe.Pointer(&ss))
}

順便說一句,從Go 1.17開始,我們也可以使用unsafe.Slice函數(shù)來實(shí)現(xiàn)這樣的轉(zhuǎn)換:

func main() {
	...
	
	ss = unsafe.Slice((*string)(&ms[0]), len(ms))
	ms = unsafe.Slice((*MyString)(&ss[0]), len(ss))
}

此模式在實(shí)踐中的另一個(gè)應(yīng)用是將一個(gè)不再使用的字節(jié)切片轉(zhuǎn)換為一個(gè)字符串(從而避免對(duì)底層字節(jié)序列的一次開辟和復(fù)制)。如下例所示:

func ByteSlice2String(bs []byte) string {
	return *(*string)(unsafe.Pointer(&bs))
}

此實(shí)現(xiàn)借鑒于strings標(biāo)準(zhǔn)庫包中的Builder類型的String方法的實(shí)現(xiàn)。 字節(jié)切片的尺寸比字符串的尺寸要大,并且它們的底層結(jié)構(gòu)類似,所以此轉(zhuǎn)換(對(duì)于當(dāng)前的主流Go編譯器來說)是安全的。 即使這樣,此實(shí)現(xiàn)也只推薦在標(biāo)準(zhǔn)庫中使用,而不推薦在用戶代碼中使用。 從Go 1.20開始,在用戶代碼中,最好盡量使用本文前面介紹使用unsafe.String函數(shù)的實(shí)現(xiàn)。

反過來,下面這個(gè)例子中的轉(zhuǎn)換是非法的,因?yàn)樽址某叽绫茸止?jié)切片的尺寸小。

func String2ByteSlice(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(&s)) // 危險(xiǎn)
}

在后面的模式六中展示了一種合法的(無需復(fù)制底層字節(jié)序列即可)將一個(gè)字符串轉(zhuǎn)換為字節(jié)切片的實(shí)現(xiàn)。

注意:當(dāng)運(yùn)用上面展示的使用非類型安全指針將一個(gè)字節(jié)切片轉(zhuǎn)換為字符串的技巧時(shí),請(qǐng)確保結(jié)果字符串在使用過程中絕對(duì)不修改此字節(jié)切片中的字節(jié)值。

使用模式二:將一個(gè)非類型安全指針值轉(zhuǎn)換為一個(gè)uintptr值,然后使用此uintptr值。

此模式不是很有用。一般我們將最終的轉(zhuǎn)換結(jié)果uintptr值輸出到日志中用來調(diào)試,但是有很多其它安全并且簡潔的途徑也可以實(shí)現(xiàn)此目的。

一個(gè)例子:

package main

import "fmt"
import "unsafe"

func main() {
	type T struct{a int}
	var t T
	fmt.Printf("%p\n", &t)                          // 0xc6233120a8
	println(&t)                                     // 0xc6233120a8
	fmt.Printf("%x\n", uintptr(unsafe.Pointer(&t))) // c6233120a8
}

輸出地址在每次運(yùn)行中可能都會(huì)不同。

使用模式三:將一個(gè)非類型安全指針轉(zhuǎn)換為一個(gè)uintptr值,然后此uintptr值參與各種算術(shù)運(yùn)算,再將算術(shù)運(yùn)算的結(jié)果uintptr值轉(zhuǎn)回非類型安全指針。

轉(zhuǎn)換前后的非類型安全指針必須指向同一個(gè)內(nèi)存塊。一個(gè)例子:

package main

import "fmt"
import "unsafe"

type T struct {
	x bool
	y [3]int16
}

const N = unsafe.Offsetof(T{}.y)
const M = unsafe.Sizeof(T{}.y[0])

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// "uintptr(p) + N + M + M"為t.y[2]的內(nèi)存地址。
	ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	fmt.Println(*ty2) // 789
}

其實(shí),對(duì)于這樣地址加減運(yùn)算,更推薦使用上面介紹的Go 1.17中引入的unsafe.Add函數(shù)來完成。

注意:在上面這個(gè)例子中,轉(zhuǎn)換unsafe.Pointer(uintptr(p) + N + M + M)不應(yīng)該像下面這樣被拆成兩行。 請(qǐng)閱讀下面的代碼中的注釋以獲取原因。

func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(&t)
	// ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	addr := uintptr(p) + N + M + M
	
	// ...(一些其它操作)
	
	// 從這里到下一行代碼執(zhí)行之前,t值將不再被任何值
	// 引用,所以垃圾回收器認(rèn)為它可以被回收了。一旦
	// 它真地被回收了,下面繼續(xù)使用t.y[2]值的曾經(jīng)
	// 的地址是非法和危險(xiǎn)的!另一個(gè)危險(xiǎn)的原因是
	// t的地址在執(zhí)行下一行之前可能改變(見事實(shí)三)。
	// 另一個(gè)潛在的危險(xiǎn)是:如果在此期間發(fā)生了一些
	// 操作導(dǎo)致協(xié)程堆棧大小改變的情況,則記錄在addr
	// 中的地址將失效。
	ty2 := (*int16)(unsafe.Pointer(addr))
	fmt.Println(*ty2)
}

這樣的bug是非常微妙和很難被覺察到的,并且爆發(fā)出來的幾率是相當(dāng)?shù)玫汀?一旦這樣的bug爆發(fā)出來,將很讓人摸不到頭腦。這也是使用非類型安全指針被認(rèn)為是危險(xiǎn)操作的原因之一。

中間uintptr值可以參與&^清位運(yùn)算來進(jìn)行內(nèi)存對(duì)齊計(jì)算,只要保證轉(zhuǎn)換前后的非類型安全指針同時(shí)指向同一個(gè)內(nèi)存塊,整個(gè)轉(zhuǎn)換就是合法安全的。

另一個(gè)需要注意的細(xì)節(jié)是最好不要將一個(gè)內(nèi)存塊的結(jié)尾邊界地址存儲(chǔ)在一個(gè)(安全或非安全)指針中。 這樣做將導(dǎo)致緊隨著此內(nèi)存塊的另一個(gè)內(nèi)存塊因?yàn)楸灰枚粫?huì)被垃圾回收掉,或者因?yàn)樾纬煞欠ㄖ羔樁鴮?dǎo)致程序崩潰(取決于具體編譯器實(shí)現(xiàn))。 請(qǐng)閱讀這個(gè)問答以獲取更多解釋。

使用模式四:將非類型安全指針值轉(zhuǎn)換為uintptr值并傳遞給syscall.Syscall函數(shù)調(diào)用。

通過對(duì)上一個(gè)使用模式的解釋,我們知道像下面這樣含有uintptr類型的參數(shù)的函數(shù)定義是危險(xiǎn)的。

// 假設(shè)此函數(shù)不會(huì)被內(nèi)聯(lián)。
func DoSomething(addr uintptr) {
	// 對(duì)處于傳遞進(jìn)來的地址處的值進(jìn)行讀寫...
}

上面這個(gè)函數(shù)是危險(xiǎn)的原因在于此函數(shù)本身不能保證傳遞進(jìn)來的地址處的內(nèi)存塊一定沒有被回收。 如果此內(nèi)存塊已經(jīng)被回收了或者被重新分配給了其它值,那么此函數(shù)內(nèi)部的操作將是非法和危險(xiǎn)的。

然而,syscall標(biāo)準(zhǔn)庫包中的Syscall函數(shù)的原型為:

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

那么此函數(shù)是如何保證處于傳遞給它的地址參數(shù)值a1、a2a3處的內(nèi)存塊在此函數(shù)執(zhí)行過程中一定沒有被回收和被移動(dòng)呢? 此函數(shù)無法做出這樣的保證。事實(shí)上,是編譯器做出了這樣的保證。 這是syscall.Syscall這樣的函數(shù)的特權(quán)。其它自定義函數(shù)無法享受到這樣的待遇。

我們可以認(rèn)為編譯器針對(duì)每個(gè)syscall.Syscall函數(shù)調(diào)用中的每個(gè)被轉(zhuǎn)換為uintptr類型的非類型安全指針實(shí)參添加了一些指令,從而保證此非類型安全指針?biāo)弥膬?nèi)存塊在此調(diào)用返回之前不會(huì)被垃圾回收和移動(dòng)。

注意:在Go 1.15之前,類型轉(zhuǎn)換表達(dá)式uintptr(anUnsafePointer)可以呈現(xiàn)為相關(guān)實(shí)參的子表達(dá)式。 但是,從Go 1.15開始,使用此模式的要求變得略加嚴(yán)格:相關(guān)實(shí)參必須呈現(xiàn)為uintptr(anUnsafePointer)這種形式。

下面這個(gè)調(diào)用是安全的:

syscall.Syscall(syscall.SYS_READ, uintptr(fd),
			uintptr(unsafe.Pointer(p)), uintptr(n))

但下面這個(gè)調(diào)用則是危險(xiǎn)的:

u := uintptr(unsafe.Pointer(p))
// 被p所引用著的值在此時(shí)有可能會(huì)被回收掉,
// 或者它的地址已經(jīng)發(fā)生了改變。
syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))

// 相關(guān)實(shí)參必須呈現(xiàn)為"uintptr(anUnsafePointer)"
// 這種形式。事實(shí)上,Go 1.15之前,此調(diào)用是合法的;
// 但是Go 1.15略改了一點(diǎn)規(guī)則。
syscall.Syscall(SYS_XXX, uintptr(uintptr(fd)),
			uint(uintptr(unsafe.Pointer(p))), uintptr(n))

注意:此使用模式也適用于Windows系統(tǒng)中的syscall.Proc.Callsyscall.LazyProc.Call系統(tǒng)調(diào)用。

再提醒一次,此使用模式不適用于其它自定義函數(shù)。

使用模式五:將reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值立即轉(zhuǎn)換為非類型安全指針。

reflect標(biāo)準(zhǔn)庫包中的Value類型的PointerUnsafeAddr方法都返回一個(gè)uintptr值,而不是一個(gè)unsafe.Pointer值。 這樣設(shè)計(jì)的目的是避免用戶不引用unsafe標(biāo)準(zhǔn)庫包就可以將這兩個(gè)方法的返回值(如果是unsafe.Pointer類型)轉(zhuǎn)換為任何類型安全指針類型。

這樣的設(shè)計(jì)需要我們將這兩個(gè)方法的調(diào)用的uintptr結(jié)果立即轉(zhuǎn)換為非類型安全指針。 否則,將出現(xiàn)一個(gè)短暫的可能導(dǎo)致處于返回的地址處的內(nèi)存塊被回收掉的時(shí)間窗。 此時(shí)間窗是如此短暫以至于此內(nèi)存塊被回收掉的幾率非常之低,因而這樣的編程錯(cuò)誤造成的bug的重現(xiàn)幾率亦十分得低。

比如,下面這個(gè)調(diào)用是安全的:

p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))

而下面這個(gè)調(diào)用是危險(xiǎn)的:

u := reflect.ValueOf(new(int)).Pointer()
// 在這個(gè)時(shí)刻,處于存儲(chǔ)在u中的地址處的內(nèi)存塊
// 可能會(huì)被回收掉。
p := (*int)(unsafe.Pointer(u))

注意:Go 1.19引入了一個(gè)新的方法:reflect.Value.UnsafePointer()。 官方推薦以后使用此方法來替換上述兩個(gè)方法。也就說,承認(rèn)了原來的設(shè)計(jì)思路并不太對(duì)路。 UnsafePointer()放回一個(gè)unsafe.Pointer值。

使用模式六:將一個(gè)reflect.SliceHeader或者reflect.StringHeader值的Data字段轉(zhuǎn)換為非類型安全指針,以及其逆轉(zhuǎn)換。

和上一小節(jié)中提到的同樣的原因,reflect標(biāo)準(zhǔn)庫包中的SliceHeaderStringHeader類型的Data字段的類型被指定為uintptr,而不是unsafe.Pointer

我們可以將一個(gè)字符串的指針值轉(zhuǎn)換為一個(gè)*reflect.StringHeader指針值,從而可以對(duì)此字符串的內(nèi)部進(jìn)行修改。 類似地,我們可以將一個(gè)切片的指針值轉(zhuǎn)換為一個(gè)*reflect.SliceHeader指針值,從而可以對(duì)此切片的內(nèi)部進(jìn)行修改。

一個(gè)使用reflect.StringHeader的例子:

package main

import "fmt"
import "unsafe"
import "reflect"

func main() {
	a := [...]byte{'G', 'o', 'l', 'a', 'n', 'g'}
	s := "Java"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
	hdr.Data = uintptr(unsafe.Pointer(&a))
	hdr.Len = len(a)
	fmt.Println(s) // Golang
	// 現(xiàn)在,字符串s和切片a共享著底層的byte字節(jié)序列,
	// 從而使得此字符串中的字節(jié)變得可以修改。
	a[2], a[3], a[4], a[5] = 'o', 'g', 'l', 'e'
	fmt.Println(s) // Google
}

一個(gè)使用了reflect.SliceHeader的例子:

package main

import (
	"fmt"
	"unsafe"
	"reflect"
)

func main() {
	a := [6]byte{'G', 'o', '1', '0', '1'}
	bs := []byte("Golang")
	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	hdr.Data = uintptr(unsafe.Pointer(&a))

	hdr.Len = 2
	hdr.Cap = len(a)
	fmt.Printf("%s\n", bs) // Go
	bs = bs[:cap(bs)]
	fmt.Printf("%s\n", bs) // Go101
}

一般說來,我們只應(yīng)該從一個(gè)已經(jīng)存在的字符串值得到一個(gè)*reflect.StringHeader指針, 或者從一個(gè)已經(jīng)存在的切片值得到一個(gè)*reflect.SliceHeader指針, 而不應(yīng)該從一個(gè)全新的StringHeader值生成一個(gè)字符串,或者從一個(gè)全新的SliceHeader值生成一個(gè)切片。 比如,下面的代碼是不安全的:

var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// 在此時(shí)刻,上一行代碼中剛開辟的數(shù)組內(nèi)存塊已經(jīng)不再被任何值
// 所引用,所以它可以被回收了。
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr)) // 危險(xiǎn)!

下面是一個(gè)展示了如何通過使用非類型安全途徑將一個(gè)字符串轉(zhuǎn)換為字節(jié)切片的例子。 和使用類型安全途徑進(jìn)行轉(zhuǎn)換不同,使用非類型安全途徑避免了復(fù)制一份底層字節(jié)序列。

package main

import (
	"fmt"
	"reflect"
	"strings"
	"unsafe"
)

func String2ByteSlice(str string) (bs []byte) {
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	sliceHdr.Data = strHdr.Data
	sliceHdr.Cap = strHdr.Len
	sliceHdr.Len = strHdr.Len
	return
}

func main() {
	// str := "Golang"
	// 對(duì)于官方標(biāo)準(zhǔn)編譯器來說,上面這行將使str中的字節(jié)
	// 開辟在不可修改內(nèi)存區(qū)。所以這里我們使用下面這行。
	str := strings.Join([]string{"Go", "land"}, "")
	s := String2ByteSlice(str)
	fmt.Printf("%s\n", s) // Goland
	s[5] = 'g'
	fmt.Println(str) // Golang
}

注意:當(dāng)使用上面展示的使用非類型安全指針將一個(gè)字符串轉(zhuǎn)換為字節(jié)切片時(shí),請(qǐng)確保結(jié)果此源字符串的生命期內(nèi)務(wù)必不要修改結(jié)果字節(jié)切片中的字節(jié)值(上面的例子違背了此原則)。 事實(shí)上,更為推薦的是最好永遠(yuǎn)不要修改結(jié)果字節(jié)切片中的字節(jié)值。此非類型安全方式的目的主要是為了在局部感知范圍內(nèi)避免一次內(nèi)存開辟,而不是一種通用的方式。

我們可以使用類似的實(shí)現(xiàn)(如下所示)來將一個(gè)字節(jié)切片轉(zhuǎn)換為字符串。 此實(shí)現(xiàn)被模式一中展示的方法略為安全一些(但是也更慢一些)。

func ByteSlice2String(bs []byte) (str string) {
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	strHdr.Data = sliceHdr.Data
	strHdr.Len = sliceHdr.Len
	return
}

同樣地,請(qǐng)確保結(jié)果此結(jié)果字符串的生命期內(nèi)務(wù)必不要修改實(shí)參字節(jié)切片中的字節(jié)值。

最后,順便舉一個(gè)違背了模式三的使用原則的例子:

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func Example_Bad() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Pointer(hdr.Data + 2))
	return pbyte // *pbyte == 'd'
}

func main() {
	fmt.Println(string(*Example_Bad()))
}

下面是兩個(gè)正確的實(shí)現(xiàn):

func Example_Good1() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Pointer(
		uintptr(unsafe.Pointer(hdr.Data)) + 2))
	return pbyte
}

// 從Go 1.17開始也可以使用此實(shí)現(xiàn)。
func Example_Good2() *byte {
	var str = "godoc"
	hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	pbyte := (*byte)(unsafe.Add(unsafe.Pointer(hdr.Data), 2))
	return pbyte
}

上面這幾個(gè)例子借鑒自Bryan C. Mills在slack中發(fā)表的一個(gè)留言。

reflect標(biāo)準(zhǔn)庫包中SliceHeaderStringHeader類型的文檔提到這兩個(gè)結(jié)構(gòu)體類型的定義不保證在以后的版本中不發(fā)生改變。 這也可以看作是使用非類型安全指針的另一個(gè)(較低的)潛在風(fēng)險(xiǎn)。 好在目前的兩個(gè)主流Go編譯器(標(biāo)準(zhǔn)編譯器和gccgo編譯器)都認(rèn)可當(dāng)前版本中的定義。

Go核心開發(fā)團(tuán)隊(duì)也意識(shí)到了這兩個(gè)類型的使用不方便并且容易出錯(cuò),因此,這兩個(gè)類型在Go 1.20中被廢棄了。 取而代之,我們應(yīng)該盡量使用本文前面介紹的unsafe.String、unsafe.StringDataunsafe.Sliceunsafe.SliceData這幾個(gè)函數(shù)。

總結(jié)一下

從上面解釋中,我們得知,對(duì)于某些情形,非類型安全機(jī)制可以幫助我們寫出運(yùn)行效率更高的代碼。 但是,使用非類型安全指針也使得我們可能輕易地寫出一些重現(xiàn)幾率非常低的微妙的bug。 一個(gè)含有這樣的bug的程序很可能在很長一段時(shí)間內(nèi)都運(yùn)行正常,但是突然變得不正常甚至崩潰。 這樣的bug很難發(fā)現(xiàn)和調(diào)試。

我們只應(yīng)該在不得不使用非類型安全機(jī)制的時(shí)候才使用它們。 特別地,當(dāng)我們使用非類型安全機(jī)制時(shí),請(qǐng)務(wù)必遵循上面列出的使用模式。

重申一次,我們應(yīng)該知曉當(dāng)前的非類型安全機(jī)制規(guī)則和使用模式可能在以后的Go版本中完全失效。 當(dāng)然,目前沒有任何跡象表明這種變化將很快會(huì)來到。 但是,一旦發(fā)生這種變化,本文中列出的當(dāng)前是正確的代碼將變得不再安全甚至編譯不通過。 所以,在實(shí)踐中,請(qǐng)盡量保證能夠?qū)⑹褂昧朔穷愋桶踩珯C(jī)制的代碼輕松改為使用安全途徑實(shí)現(xiàn)。

最后值得提一下的是,Go官方工具鏈1.14中加入了一個(gè)-gcflags=all=-d=checkptr編譯器動(dòng)態(tài)分析選項(xiàng)(在Windows平臺(tái)上推薦使用工具鏈1.15+)。 當(dāng)此選項(xiàng)被使用的時(shí)候,編譯出的程序在運(yùn)行時(shí)會(huì)監(jiān)測到很多(但并非所有)非類型安全指針的錯(cuò)誤使用。一旦錯(cuò)誤的使用被監(jiān)測到,恐慌將產(chǎn)生。 感謝Matthew Dempsky實(shí)現(xiàn)了此特性。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)