非类型安全指针

我们已经从Go中的指针一文中学习到关于指针的各种概念和规则。 从那篇文章中,我们得知,相对于C指针,Go指针有很多限制。 比如,Go指针不支持算术运算,并且对于任意两个指针值,很可能它们不能转换到对方的类型。

事实上,在那篇文章中解释的指针的完整称呼应该为类型安全指针。 虽然类型安全指针有助于我们轻松写出安全的代码,但是有时候施加在类型安全指针上的限制也确实导致我们不能写出最高效的代码。

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

使用非类型安全指针的另外一个较大的风险是Go中目前提供的非类型安全指针机制并不受到Go 1 兼容性保证的保护。 使用了非类型安全指针的代码可以从今后的某个Go版本开始将不再能编译通过,或者运行行为发生了变化。

如果出于种种原因,你确实希望在你的代码中使用非类型安全指针,你不仅需要提防上述风险,你还需遵守Go官方文档中列出的非类型安全指针使用模式,并清楚地知晓使用非类型安全指针带来的效果。否则,你很难使用非类型安全指针写出安全的代码。

关于unsafe标准库包

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

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

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

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

unsafe标准库包提供了三个函数:

注意,这三个函数的返回值的类型均为内置类型uintptr。 下面我们将了解到uintptr类型的值可以转换为非类型安全指针(反之亦然)。

尽管这三个函数之一的任何调用的返回结果在同一个编译好的程序中总是一致的, 但是这样的一个调用在不同架构的操作系统中(或者使用不同的编译器编译时)的返回值可能是不一样的。

这三个函数的调用总是在编译时刻被估值,并且估值结果为常量。

一个使用了这三个函数的例子:
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
}

注意,上面程序中的注释所暗示的输出结果是此程序在AMD64架构上使用标准编译器1.12版本编译时的结果。

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

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

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

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

我们需要知道的一些事实

在开始介绍合法的非类型安全指针使用模式之前,我们需要知道一些事实。

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

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

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

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

下一节中的例子将展示指针和uintptr值的不同。

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

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

一个例子:
import "unsafe"

// 假设此函数不会被内联(inline)。
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值所占的内存块一定还没有被回收。 换句话说,当*(*T)(unsafe.Pointer(p2))) = 3被执行的时候,此内存块有可能已经被回收了。 所以,继续通过解引用值p2中存储的地址是非常危险的,因为此内存块可能已经被重新分配给其它值使用了。

事实三:我们可以将一个值的指针传递给runtime.KeepAlive函数调用来确保此值在此调用之前仍然处于被使用中

为了确保一个值部和它所引用着的值部仍然被认为在使用中,我们应该将引用着此值的另一个值传给一个runtime.KeepAlive函数调用。 在实践中,我们常常将此值的指针传递给一个runtime.KeepAlive函数调用。

比如,通过在上一小节的例子中的最后添加一个runtime.KeepAlive(z)调用, *(*int)(unsafe.Pointer(p2))) = 3可以被安全地执行了。
func foo() {
	p0, y, z := createInt(), createInt(), createInt()
	var p1 = unsafe.Pointer(y)
	var p2 = uintptr(unsafe.Pointer(z))

	p2 += 2; p2--; p2--

	*p0 = 1
	*(*int)(p1) = 2
	*(*int)(unsafe.Pointer(p2))) = 3 // 转危为安!

	runtime.KeepAlive(z) // 确保z所引用的值仍在使用中
}

事实四:一个值的可被使用范围可能并没有代码中看上去的大

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

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

unsafe标准库包的文档中列出了六种非类型安全指针的使用模式。 下面将对它们逐一进行讲解。

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

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

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

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

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

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

一个例子:
package main

import "fmt"
import "unsafe"

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

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

使用模式三:将一个非类型安全指针转换为一个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([3]int16{}[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
}

注意:在上面这个例子中,转换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]值的曾经
	// 的地址是非法和危险的!
	ty2 := (*int16)(unsafe.Pointer(addr))
	fmt.Println(*ty2)
}

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

如果我们确实希望将上面提到的转换拆成两行,我们应该在拆分后的两行后添加一条runtime.KeepAlive函数调用并将(直接或间接)引用着t.y[2]值的一个值传递给此调用做为实参。 比如:
func main() {
	t := T{y: [3]int16{123, 456, 789}}
	p := unsafe.Pointer(t)
	addr := uintptr(p) + N + M + M
	ty2 := (*int16)(unsafe.Pointer(addr))
	// 下面这条调用将确保整个t值的内存
	// 在此时刻不会被回收。
	runtime.KeepAlive(p)
	fmt.Println(*ty2)
}

另一个需要注意的细节是最好不要将一个内存块的结尾边界地址存储在一个(安全或非安全)指针中。 这样做将导致紧随着此内存块的另一个内存块肯定不会被垃圾回收掉。 请阅读这个问答以获取更多解释。

使用模式四:将非类型安全指针值转换为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类型的非类型安全指针实参添加了一条runtime.KeepAlive调用,从而确保被此非类型安全指针实参引用着的值在此syscall.Syscall函数调用中不会被垃圾回收掉。
syscall.Syscall(syscall.SYS_READ, uintptr(fd),
			uintptr(unsafe.Pointer(p)), uintptr(n))
// 编译器将自动添加下面的这句调用。
runtime.KeepAlive(unsafe.Pointer(p))

再提醒一次,此使用模式不适用于其它自定义函数。我们在其它自定义函数的调用后应该手动添加需要的runtime.KeepAlive调用。

如果将上例中的syscall.Syscall函数调用改为下面这样,则它将是危险的。
u := uintptr(unsafe.Pointer(p))
// 被p所引用着的值在此时有可能会被回收掉。
syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))
// 这里编译器不会自动添加runtime.KeepAlive调用。

使用模式五:将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))

使用模式六:将一个reflect.SliceHeader或者reflect.StringHeader值的Data字段转换为非类型安全指针,以及其逆转换。

和上一小节中提到的同样的原因,reflect标准库包中的SliceHeaderStringHeader类型的Data字段的类型被指定为uintptr,而不是unsafe.Pointer

我们可以将一个字符串的指针值转换为一个*reflect.StringHeader指针值,从而可以对此字符串的内部进行修改。 类似地,我们可以将一个切片的指针值转换为一个*reflect.SliceHeader指针值,从而可以对此切片的内部进行修改。

一个使用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
	// 现在,字符串s和切片a共享着底层的byte字节序列,
	// 从而使得此字符串中的字节变得可以修改。
	a[2], a[3], a[4], a[5] = 'o', 'g', 'l', 'e'
	fmt.Println(s) // Google
}

一个使用了reflect.SliceHeader的例子:
package main

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

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))
	runtime.KeepAlive(&a) // 必不可少!
	hdr.Len = 2
	hdr.Cap = len(a)
	fmt.Printf("%s\n", bs) // Go
	bs = bs[:cap(bs)]
	fmt.Printf("%s\n", bs) // Go101
}

注意:上例中的runtime.KeepAlive调用必不可少。 否则,在转换uintptr(unsafe.Pointer(&a))被执行之前,分配给数组a的内存可能已经被回收。

一般说来,我们只应该从一个已经存在的字符串值得到一个*reflect.StringHeader指针, 或者从一个已经存在的切片值得到一个*reflect.SliceHeader指针, 而不应该从一个StringHeader值生成一个字符串,或者从一个SliceHeader值生成一个切片。 比如,下面的代码是不安全的:
var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// 在此时刻,上一行代码中刚开辟的数组内存块已经不再被任何值
// 所引用,所以它可以被回收了。
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr)) // 危险!

下面是一个展示了如何通过使用非类型安全途径将一个字符串转换为字节切片的例子。 和使用类型安全途径进行转换不同,使用非类型安全途径避免了复制一份底层字节序列。
package main

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

func String2ByteSlice(str string) (bs []byte) {
	strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
	sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
	sliceHdr.Data = strHdr.Data
	sliceHdr.Len = strHdr.Len
	sliceHdr.Cap = strHdr.Len
	// 下面的KeepAlive是必要的。
	runtime.KeepAlive(&str)
	return
}

func main() {
	str := strings.Join([]string{"Go", "land"}, "")
	s := String2ByteSlice(str)
	fmt.Printf("%s\n", s) // Goland
	s[5] = 'g'
	fmt.Println(str) // Golang
}

reflect标准库包中SliceHeaderStringHeader类型的文档提到这两个结构体类型的定义不保证在以后的版本中不发生改变。 好在目前的两个主流Go编译器(标准编译器和gccgo编译器)都认可当前版本中的定义。 这也可以看作是使用非类型安全指针的另一个潜在风险。

我们可以使用类似的实现来将一个字节切片转换为字符串。 然而,当前(Go 1.12),有一个更简单和更有效的方法来实现这一转换:
func ByteSlice2String(bs []byte) string {
	return *(*string)(unsafe.Pointer(&bs))
}

此实现借鉴于strings标准库包中的Builder类型的String方法的实现。 此实现利用了上述第一个使用模式。

总结一下

从上面解释中,我们得知,对于某些情形,非类型安全机制可以帮助我们写出运行效率更高的代码。 但是,使用非类型安全指针也使得我们可能轻易地写出一些重现几率非常低的微妙的bug。 一个含有这样的bug的程序很可能在很长一段时间内都运行正常,但是突然变得不正常甚至崩溃。 这样的bug很难发现和调试。

我们只应该在不得不使用非类型安全机制的时候才使用它们。 特别地,当我们使用非类型安全机制时,请务必遵循上面列出的使用模式。

重申一次,我们应该知晓当前的非类型安全机制规则和使用模式可能在以后的Go版本中完全失效。 当然,目前没有任何迹象表明这种变化将很快会来到。 但是,一旦发生这种变化,本文中列出的当前是正确的代码将变得不再安全甚至编译不通过。 所以,在实践中,请尽量保证能够将使用了非类型安全机制的代码轻松改为使用安全途径实现。

Go语言101项目目前同时托管在GithubGitlab上。 欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏