go非类型安全指针

141 阅读17分钟

相对于C指针,Go指针有很多限制。 比如,Go指针不支持算术运算,并且对于任意两个指针值,很可能它们不能转换到对方的类型,这类指针被称为类型安全指针。虽然类型安全指针有助于我们轻松写出安全的代码,但是有时候施加在类型安全指针上的限制也确实导致我们不能写出最高效的代码。

实际上,Go也支持限制较少的非类型安全指针。 非类型安全指针和C指针类似,它们都很强大,但同时也都很危险。 在某些情形下,通过非类型安全指针的帮助,我们可以写出效率更高的代码; 但另一方面,使用非类型安全指针也导致我们可能轻易地写出潜在的不安全的代码,这些潜在的不安全点很难在它们产生危害之前被及时发现。

关于unsafe标准库包

非类型安全指针在Go中为一种特别的类型。 我们必须引入unsafe标准库包来使用非类型安全指针。 非类型安全指针unsafe.Pointer被声明定义为:

type Pointer *ArbitraryType

这不是一个普通的类型定义。这里的ArbitraryType仅仅是暗示unsafe.Pointer类型值可以被转换为任意类型安全指针(反之亦然)。换句话说,unsafe.Pointer类似于C语言中的void*

非类型安全指针是指底层类型为unsafe.Pointer的类型。

非类型安全指针的零值也使用预声明的nil标识符来表示。

在Go 1.17之前,unsafe标准库包只提供了三个函数:

  • func Alignof(variable ArbitraryType) uintptr。 此函数用来取得一个值在内存中的地址对齐保证(address alignment guarantee)。 注意,同一个类型的值做为结构体字段和非结构体字段时地址对齐保证可能是不同的。 当然,这和具体编译器的实现有关。对于目前的标准编译器,同一个类型的值做为结构体字段和非结构体字段时的地址对齐保证总是相同的。 gccgo编译器对这两种情形是区别对待的。
  • func Offsetof(selector ArbitraryType) uintptr。 此函数用来取得一个结构体值的某个字段的地址相对于此结构体值的地址的偏移。 在一个程序中,对于同一个结构体类型的不同值的对应相同字段,此函数的返回值总是相同的。
  • func Sizeof(variable ArbitraryType) uintptr。 此函数用来取得一个值的尺寸(亦即此值的类型的尺寸)。 在一个程序中,对于同一个类型的不同值,此函数的返回值总是相同的。

注意:

  • 这三个函数的返回值的类型均为内置类型uintptr。下面我们将了解到uintptr类型的值可以转换为非类型安全指针(反之亦然)。
  • 尽管这三个函数之一的任何调用的返回结果在同一个编译好的程序中总是一致的,但是这样的一个调用在不同架构的操作系统中(或者使用不同的编译器编译时)的返回值可能是不一样的。
  • 这三个函数的调用总是在编译时刻被估值,估值结果为类型为uintptr的常量。
  • 传递给Offsetof函数的实参必须为一个字段选择器形式value.field。 此选择器可以表示一个内嵌字段,但此选择器的路径中不能包含指针类型的隐式字段

例子:

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
}

unsafe包提供的这三个函数看上去并不怎么危险。它的危险性基本上来自于非类型安全指针。它们和C指针一样危险,这是Go安全指针千方百计设法去避免的。

Go 1.17引入了一个新类型和两个新函数。 此新类型为IntegerType。它的定义如下。 此类型不代表着一个具体类型,它只是表示任意整数类型。

type IntegerType int

Go 1.17引入的两个函数为:

  • func Add(ptr Pointer, len IntegerType) Pointer。 此函数在一个(非安全)指针表示的地址上添加一个偏移量,然后返回表示新地址的一个指针。 此函数以一种更正规的形式部分地覆盖了下面将要介绍的使用模式3中展示的合法用法。
  • func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType。 此函数用来从一个任意(安全)指针派生出一个指定长度的切片。

Go 1.17引入的这两个函数具有一定的危险性,需谨慎使用。下面时使用了这两个函数的一个例子。

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

	// 下面是两个不正确的调用。因为它们
	// 的返回结果引用了未知的内存块。
	_ = unsafe.Add(up9, 7 * eleSize)
	_ = unsafe.Slice(p9, 8)
}

非类型安全指针相关的类型转换

目前(Go 1.18),Go支持下列和非类型安全指针相关的类型转换:

  • 一个类型安全指针值可以被显式转换为一个非类型安全指针类型,反之亦然。
  • 一个uintptr值可以被显式转换为一个非类型安全指针类型,反之亦然。 但是,注意,一个nil非类型安全指针类型不应该被转换为uintptr并进行算术运算后再转换回来。

通过使用这些转换规则,我们可以将任意两个类型安全指针转换为对方的类型,我们也可以将一个安全指针值和一个uintptr值转换为对方的类型。

然而,尽管这些转换在编译时刻是合法的,但是它们中一些在运行时刻并非是合法和安全的。 这些转换摧毁了Go的类型系统(不包括非类型安全指针部分)精心设立的内存安全屏障。 我们必须遵循本文后面要介绍的一些用法指示来使用非类型安全指针才能写出合法并安全的代码。

我们需要知道的一些事实

一:非类型安全指针值是指针但uintptr值是整数

每一个非零安全或者不安全指针值均引用着另一个值。但是一个uintptr值并不引用任何值,它被看作是一个整数,尽管常常它存储的是一个地址的数字表示。

Go是一门支持垃圾回收的语言。 当一个Go程序在运行中,Go运行时(runtime)将不时地检查哪些内存块将不再被程序中的任何仍在使用中的值所引用并且回收这些内存块。 指针在这一过程中扮演着重要的角色。值与值之间和内存块与值之间的引用关系是通过指针来表征的。

既然一个uintptr值是一个整数,那么它可以参与算术运算。

二:不再被使用的内存块的回收时间点是不确定的

在运行时刻,一次新的垃圾回收过程可能在一个不确定的时间启动,并且此过程可能需要一段不确定的时长才能完成。 所以一个不再被使用的内存块的回收时间点是不确定的。

一个例子:

import "unsafe"

// 假设此函数不会被内联(inline)。
//go:noinline
func createInt() *int {
	return new(int)
}

func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y) // 和y一样引用着同一个值
	var p2 = uintptr(unsafe.Pointer(z))

	// 此时,即使z指针值所引用的int值的地址仍旧存储
	// 在p2值中,但是此int值已经不再被使用了,所以垃圾
	// 回收器认为可以回收它所占据的内存块了。另一方面,
	// p0和p1各自所引用的int值仍旧将在下面被使用。

	// uintptr值可以参与算术运算。
	p2 += 2; p2--; p2--

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

在上面这个例子中,值p2仍旧在使用这个事实并不能保证曾经被z指针值所引用的int值所占的内存块一定还没有被回收。 换句话说,当*(*int)(unsafe.Pointer(p2)) = 3被执行的时候,此内存块有可能已经被回收了。 所以,继续通过解引用值p2中存储的地址是非常危险的,因为此内存块可能已经被重新分配给其它值使用了。

三:一个值的地址在程序运行中可能改变

当一个协程的栈的大小改变时,开辟在此栈上的内存块需要移动,从而相应的值的地址将改变。

四:一个值的生命范围可能并没有代码中看上去的大

比如中下面这个例子,值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

	// 一个聪明的编译器能够觉察到值t.y将不会再被用到,
	// 所以认为t.y值所占的内存块可以被回收了。

	*(*byte)(unsafe.Pointer(p)) = 1 // 危险操作!

	println(t.x) // ok。继续使用值t,但只使用t.x字段。
}

五:*unsafe.Pointer是一个类型安全指针类型

是的,类型*unsafe.Pointer是一个类型安全指针类型。 它的基类型为unsafe.Pointer。 既然它是一个类型安全指针类型,根据上面列出的类型转换规则,它的值可以转换为类型unsafe.Pointer,反之亦然。

一个例子:

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)
}

如何正确地使用非类型安全指针?

一:将类型*T1的一个值转换为非类型安全指针值,然后将此非类型安全指针值转换为类型*T2

利用前面列出的非类型安全指针相关的转换规则,我们可以将一个*T1值转换为类型*T2,其中T1T2为两个任意类型。 然而,我们只有在T1的尺寸不小于T2并且此转换具有实际意义的时候才应该实施这样的转换。

通过将一个*T1值转换为类型*T2,我们也可以将一个T1值转换为类型T2

一个这样的例子是math标准库包中的Float64bits函数。 此函数将一个float64值转换为一个uint64值。 在此转换过程中,此float64值在内存中的每个位(bit)都保持不变。 函数math.Float64frombits为此转换的逆转换。

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

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

请注意,函数调用math.Float64bits(aFloat64)的结果和显式转换uint64(aFloat64)的结果不同。

在下面这个例子中,我们使用此模式将一个[]MyString值和一个[]string值转换为对方的类型。 结果切片和被转换的切片将共享底层元素。(这样的转换是不可能通过安全的方式来实现的。)

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) // 编译错误
	ss := *(*[]string)(unsafe.Pointer(&ms))
	ss[1] = "Rust"
	fmt.Printf("%s\n", ms) // [C Rust Go]
	// ms = []MyString(ss) // 编译错误
	ms = *(*[]MyString)(unsafe.Pointer(&ss))
}

当然,从Go 1.17开始,我们也可以使用unsafe.Slice((*string)(&ms[0]), len(ms))来实现此类型转换。

此模式在实践中的另一个应用是将一个不再使用的字节切片转换为一个字符串(从而避免对底层字节序列的一次开辟和复制)。如下例所示:

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

此实现借鉴于strings标准库包中的Builder类型的String方法的实现。 字节切片的尺寸比字符串的尺寸要大,并且它们的底层结构类似,所以此转换(对于当前的主流Go编译器来说)是安全的。 即使这样,此实现也只推荐在标准库中使用,而不推荐在用户代码中使用。 在用户代码中,最好尽量使用文末提供的另一种实现。

反过来,下面这个例子中的转换是非法的,因为字符串的尺寸比字节切片的尺寸小。

func String2ByteSlice(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(&s)) // 危险
}

在后面的模式六中展示了一种合法的(无需复制底层字节序列即可)将一个字符串转换为字节切片的实现。

注意:当运用上面展示的使用非类型安全指针将一个字节切片转换为字符串的技巧时,请确保结果字符串在使用过程中绝对不修改此字节切片中的字节值。

二:将一个非类型安全指针值转换为一个uintptr值,然后使用此uintptr值。

此模式不是很有用。一般我们将最终的转换结果uintptr值输出到日志中用来调试,但是有很多其它安全并且简洁的途径也可以实现此目的。

一个例子:

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
}

输出地址在每次运行中可能都会不同。

三:将一个非类型安全指针转换为一个uintptr值,然后此uintptr值参与各种算术运算,再将算术运算的结果uintptr值转回非类型安全指针。

转换前后的非类型安全指针必须指向同一个内存块。一个例子:

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]的内存地址。
	ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
	fmt.Println(*ty2) // 789
}

其实,对于这样地址加减运算,更推荐使用上面介绍的Go 1.17中引入的unsafe.Add函数来完成。

注意:在上面这个例子中,转换unsafe.Pointer(uintptr(p) + N + M + M)不应该像下面这样被拆成两行。 请阅读下面的代码中的注释以获取原因。

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
	
	// ...(一些其它操作)
	
	// 从这里到下一行代码执行之前,t值将不再被任何值
	// 引用,所以垃圾回收器认为它可以被回收了。一旦
	// 它真地被回收了,下面继续使用t.y[2]值的曾经
	// 的地址是非法和危险的!另一个危险的原因是
	// t的地址在执行下一行之前可能改变(见事实三)。
	// 另一个潜在的危险是:如果在此期间发生了一些
	// 操作导致协程堆栈大小改变的情况,则记录在addr
	// 中的地址将失效。
	ty2 := (*int16)(unsafe.Pointer(addr))
	fmt.Println(*ty2)
}

这样的bug是非常微妙和很难被觉察到的,并且爆发出来的几率是相当得低。 一旦这样的bug爆发出来,将很让人摸不到头脑。这也是使用非类型安全指针被认为是危险操作的原因之一。

中间uintptr值可以参与&^清位运算来进行内存对齐计算,只要保证转换前后的非类型安全指针同时指向同一个内存块,整个转换就是合法安全的。

另一个需要注意的细节是最好不要将一个内存块的结尾边界地址存储在一个(安全或非安全)指针中。 这样做将导致紧随着此内存块的另一个内存块因为被引用而不会被垃圾回收掉,或者因为形成非法指针而导致程序崩溃(取决于具体编译器实现)。

四:将非类型安全指针值转换为uintptr值并传递给syscall.Syscall函数调用。

通过对上一个使用模式的解释,我们知道像下面这样含有uintptr类型的参数的函数定义是危险的。

// 假设此函数不会被内联。
func DoSomething(addr uintptr) {
	// 对处于传递进来的地址处的值进行读写...
}

上面这个函数是危险的原因在于此函数本身不能保证传递进来的地址处的内存块一定没有被回收。 如果此内存块已经被回收了或者被重新分配给了其它值,那么此函数内部的操作将是非法和危险的。

然而,syscall标准库包中的Syscall函数的原型为:

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

那么此函数是如何保证处于传递给它的地址参数值a1a2a3处的内存块在此函数执行过程中一定没有被回收和被移动呢? 此函数无法做出这样的保证。事实上,是编译器做出了这样的保证。 这是syscall.Syscall这样的函数的特权。其它自定义函数无法享受到这样的待遇。

我们可以认为编译器针对每个syscall.Syscall函数调用中的每个被转换为uintptr类型的非类型安全指针实参添加了一些指令,从而保证此非类型安全指针所引用着的内存块在此调用返回之前不会被垃圾回收和移动。

注意:在Go 1.15之前,类型转换表达式uintptr(anUnsafePointer)可以呈现为相关实参的子表达式。 但是,从Go 1.15开始,使用此模式的要求变得略加严格:相关实参必须呈现为uintptr(anUnsafePointer)这种形式。

下面这个调用是安全的:

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

但下面这个调用则是危险的:

u := uintptr(unsafe.Pointer(p))
// 被p所引用着的值在此时有可能会被回收掉,
// 或者它的地址已经发生了改变。
syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))

// 相关实参必须呈现为"uintptr(anUnsafePointer)"
// 这种形式。事实上,Go 1.15之前,此调用是合法的;
// 但是Go 1.15略改了一点规则。
syscall.Syscall(SYS_XXX, uintptr(uintptr(fd)),
			uint(uintptr(unsafe.Pointer(p))), uintptr(n))

五:将reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值立即转换为非类型安全指针。

reflect标准库包中的Value类型的PointerUnsafeAddr方法都返回一个uintptr值,而不是一个unsafe.Pointer值。 这样设计的目的是避免用户不引用unsafe标准库包就可以将这两个方法的返回值(如果是unsafe.Pointer类型)转换为任何类型安全指针类型。

这样的设计需要我们将这两个方法的调用的uintptr结果立即转换为非类型安全指针。 否则,将出现一个短暂的可能导致处于返回的地址处的内存块被回收掉的时间窗。 此时间窗是如此短暂以至于此内存块被回收掉的几率非常之低,因而这样的编程错误造成的bug的重现几率亦十分得低。

比如,下面这个调用是安全的:

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

而下面这个调用是危险的:

u := reflect.ValueOf(new(int)).Pointer()
// 在这个时刻,处于存储在u中的地址处的内存块
// 可能会被回收掉。
p := (*int)(unsafe.Pointer(u))

注意:Go 1.18引入了一个新的方法:reflect.Value.UnsafePointer()。 官方推荐以后使用此方法来替换上述两个方法。也就说,承认了原来的设计思路并不太对路。 UnsafePointer()放回一个unsafe.Pointer值。