Go 非类型安全指针
非类型安全指针
相对于 C 指针,Go 指针有很多限制。 比如,Go 指针不支持算术运算,并且对于任意两个指针值,很可能它们不能转换到对方的类型。
事实上,一般指针的完整称呼应该为类型安全指针。 虽然类型安全指针有助于我们轻松写出安全的代码,但是有时候施加在类型安全指针上的限制也确实导致我们不能写出最高效的代码。
实际上,Go 也支持限制较少的非类型安全指针。 非类型安全指针和 C 指针类似,它们都很强大,但同时也都很危险。 在某些情形下,通过非类型安全指针的帮助,我们可以写出效率更高的代码; 但另一方面,使用非类型安全指针也导致我们可能轻易地写出潜在的不安全的代码,这些潜在的不安全点很难在它们产生危害之前被及时发现。
使用非类型安全指针的另外一个较大的风险是 Go 中目前提供的非类型安全指针机制并不受到 Go1 兼容性保证的保护。 使用了非类型安全指针的代码可能从今后的某个 Go 版本开始将不再能编译通过,或者运行行为发生了变化。
如果出于种种原因,你确实希望在你的代码中使用非类型安全指针,你不仅需要提防上述风险,你还需遵守 Go 官方文档中列出的非类型安全指针使用模式,并清楚地知晓使用非类型安全指针带来的效果。否则,你很难使用非类型安全指针写出安全的代码。
关于 unsafe
标准库包
非类型安全指针在 Go 中为一种特别的类型。 我们必须引入 unsafe
标准库包来使用非类型安全指针。 非类型安全指针 unsafe.Pointer
被声明定义为:
|
|
当然,这不是一个普通的类型定义。这里的 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
。 此选择器可以表示一个内嵌字段,但此选择器的路径中不能包含指针类型的隐式字段。
一个使用了这三个函数的例子:
|
|
下面是一个展示了上面提到的最后一个注意点的例子:
|
|
注意,上面程序中的注释所暗示的输出结果是此程序在 AMD64 架构上使用标准编译器 1.17 版本编译时的结果。
unsafe
包提供的这三个函数看上去并不怎么危险。 它们的原型在以后的 Go 1 版本中几乎不可能会发生改变。 Rob Pike 甚至曾经将这几个函数挪到其它包中。 unsafe
包的危险性基本上来自于非类型安全指针。它们和 C 指针一样危险,这是 Go 安全指针千方百计设法去避免的。
Go 1.17 引入了一个新类型和两个新函数。 此新类型为IntegerType
。它的定义如下。 此类型不代表着一个具体类型,它只是表示任意整数类型(有点泛型的意思)。
|
|
Go 1.17 引入的两个函数为:
func Add(ptr Pointer, len IntegerType) Pointer
。 此函数在一个(非安全)指针表示的地址上添加一个偏移量,然后返回表示新地址的一个指针。 此函数以一种更正规的形式部分地覆盖了下面将要介绍的使用模式 3 中展示的合法用法。func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
。 此函数用来从一个任意(安全)指针派生出一个指定长度的切片。
Go 1.17 引入的这两个函数具有一定的危险性,需谨慎使用。下面时使用了这两个函数的一个例子。
|
|
非类型安全指针相关的类型转换
目前(Go 1.17),Go 支持下列和非类型安全指针相关的类型转换:
- 一个类型安全指针值可以被显式转换为一个非类型安全指针类型,反之亦然。
- 一个 uintptr 值可以被显式转换为一个非类型安全指针类型,反之亦然。 但是,注意,一个 nil 非类型安全指针类型不应该被转换为 uintptr 并进行算术运算后再转换回来。
通过使用这些转换规则,我们可以将任意两个类型安全指针转换为对方的类型,我们也可以将一个安全指针值和一个 uintptr 值转换为对方的类型。
然而,尽管这些转换在编译时刻是合法的,但是它们中一些在运行时刻并非是合法和安全的。 这些转换摧毁了 Go 的类型系统(不包括非类型安全指针部分)精心设立的内存安全屏障。 我们必须遵循本文后面要介绍的一些用法指示来使用非类型安全指针才能写出合法并安全的代码。
我们需要知道的一些事实
在开始介绍合法的非类型安全指针使用模式之前,我们需要知道一些事实。
事实一:非类型安全指针值是指针但uintptr值是整数
每一个非零安全或者不安全指针值均引用着另一个值。但是一个 uintptr 值并不引用任何值,它被看作是一个整数,尽管常常它存储的是一个地址的数字表示。
Go 是一门支持垃圾回收的语言。 当一个 Go 程序在运行中,Go 运行时(runtime)将不时地检查哪些内存块将不再被程序中的任何仍在使用中的值所引用并且回收这些内存块。 指针在这一过程中扮演着重要的角色。值与值之间和内存块与值之间的引用关系是通过指针来表征的。
既然一个 uintptr 值是一个整数,那么它可以参与算术运算。
下一节中的例子将展示指针和 uintptr 值的不同。
事实二:不再被使用的内存块的回收时间点是不确定的
在运行时刻,一次新的垃圾回收过程可能在一个不确定的时间启动,并且此过程可能需要一段不确定的时长才能完成。 所以一个不再被使用的内存块的回收时间点是不确定的。
一个例子:
|
|
在上面这个例子中,值p2
仍旧在使用这个事实并不能保证曾经被z
指针值所引用的int
值所占的内存块一定还没有被回收。 换句话说,当*(*int)(unsafe.Pointer(p2)) = 3
被执行的时候,此内存块有可能已经被回收了。 所以,继续通过解引用值p2
中存储的地址是非常危险的,因为此内存块可能已经被重新分配给其它值使用了。
事实三:一个值的地址在程序运行中可能改变
详情请阅读内存块一文(见链接所指一节的尾部)。 这里我们只需要知道当一个协程的栈的大小改变时,开辟在此栈上的内存块需要移动,从而相应的值的地址将改变。
事实四:一个值的生命范围可能并没有代码中看上去的大
比如中下面这个例子,值t
仍旧在使用中并不能保证被值t.y
所引用的值仍在被使用。
|
|
事实五:*unsafe.Pointer
是一个类型安全指针类型
是的,类型*unsafe.Pointer
是一个类型安全指针类型。 它的基类型为unsafe.Pointer
。 既然它是一个类型安全指针类型,根据上面列出的类型转换规则,它的值可以转换为类型unsafe.Pointer
,反之亦然。
一个例子:
|
|
如何正确地使用非类型安全指针?
unsafe
标准库包的文档中列出了六种非类型安全指针的使用模式。 下面将对它们逐一进行讲解。
使用模式一:将类型*T1
的一个值转换为非类型安全指针值,然后将此非类型安全指针值转换为类型*T2
。
利用前面列出的非类型安全指针相关的转换规则,我们可以将一个*T1
值转换为类型*T2
,其中T1
和T2
为两个任意类型。 然而,我们只有在T1
的尺寸不小于T2
并且此转换具有实际意义的时候才应该实施这样的转换。
通过将一个*T1
值转换为类型*T2
,我们也可以将一个T1
值转换为类型T2
。
一个这样的例子是math
标准库包中的Float64bits
函数。 此函数将一个float64
值转换为一个uint64
值。 在此转换过程中,此float64
值在内存中的每个位(bit)都保持不变。 函数math.Float64frombits
为此转换的逆转换。
|
|
请注意,函数调用math.Float64bits(aFloat64)
的结果和显式转换uint64(aFloat64)
的结果不同。
在下面这个例子中,我们使用此模式将一个[]MyString
值和一个[]string
值转换为对方的类型。 结果切片和被转换的切片将共享底层元素。(这样的转换是不可能通过安全的方式来实现的。)
|
|
当然,从 Go 1.17 开始,我们也可以使用unsafe.Slice((*string)(&ms[0]), len(ms))
来实现此类型转换。
此模式在实践中的另一个应用是将一个不再使用的字节切片转换为一个字符串(从而避免对底层字节序列的一次开辟和复制)。如下例所示:
|
|
此实现借鉴于strings
标准库包中的Builder
类型的String
方法的实现。 字节切片的尺寸比字符串的尺寸要大,并且它们的底层结构类似,所以此转换(对于当前的主流 Go 编译器来说)是安全的。 即使这样,此实现也只推荐在标准库中使用,而不推荐在用户代码中使用。 在用户代码中,最好尽量使用文末提供的另一种实现。
反过来,下面这个例子中的转换是非法的,因为字符串的尺寸比字节切片的尺寸小。
|
|
在后面的模式六中展示了一种合法的(无需复制底层字节序列即可)将一个字符串转换为字节切片的实现。
注意:当运用上面展示的使用非类型安全指针将一个字节切片转换为字符串的技巧时,请确保结果字符串在使用过程中绝对不修改此字节切片中的字节值。
使用模式二:将一个非类型安全指针值转换为一个uintptr值,然后使用此uintptr值。
此模式不是很有用。一般我们将最终的转换结果 uintptr 值输出到日志中用来调试,但是有很多其它安全并且简洁的途径也可以实现此目的。
一个例子:
|
|
输出地址在每次运行中可能都会不同。
使用模式三:将一个非类型安全指针转换为一个uintptr值,然后此uintptr值参与各种算术运算,再将算术运算的结果uintptr值转回非类型安全指针。
转换前后的非类型安全指针必须指向同一个内存块。一个例子:
|
|
其实,对于这样地址加减运算,更推荐使用上面介绍的 Go 1.17 中引入的unsafe.Add
函数来完成。
注意:在上面这个例子中,转换unsafe.Pointer(uintptr(p) + N + M + M)
不应该像下面这样被拆成两行。 请阅读下面的代码中的注释以获取原因。
|
|
这样的 bug 是非常微妙和很难被觉察到的,并且爆发出来的几率是相当得低。 一旦这样的 bug 爆发出来,将很让人摸不到头脑。这也是使用非类型安全指针被认为是危险操作的原因之一。
中间 uintptr 值可以参与&^
清位运算来进行内存对齐计算,只要保证转换前后的非类型安全指针同时指向同一个内存块,整个转换就是合法安全的。
另一个需要注意的细节是最好不要将一个内存块的结尾边界地址存储在一个(安全或非安全)指针中。 这样做将导致紧随着此内存块的另一个内存块因为被引用而不会被垃圾回收掉,或者因为形成非法指针而导致程序崩溃(取决于具体编译器实现)。 请阅读这个问答以获取更多解释。
使用模式四:将非类型安全指针值转换为uintptr
值并传递给syscall.Syscall
函数调用。
通过对上一个使用模式的解释,我们知道像下面这样含有 uintptr 类型的参数的函数定义是危险的。
|
|
上面这个函数是危险的原因在于此函数本身不能保证传递进来的地址处的内存块一定没有被回收。 如果此内存块已经被回收了或者被重新分配给了其它值,那么此函数内部的操作将是非法和危险的。
然而,syscall
标准库包中的Syscall
函数的原型为:
|
|
那么此函数是如何保证处于传递给它的地址参数值a1
、a2
和a3
处的内存块在此函数执行过程中一定没有被回收和被移动呢? 此函数无法做出这样的保证。事实上,是编译器做出了这样的保证。 这是syscall.Syscall
这样的函数的特权。其它自定义函数无法享受到这样的待遇。
我们可以认为编译器针对每个syscall.Syscall
函数调用中的每个被转换为uintptr
类型的非类型安全指针实参添加了一些指令,从而保证此非类型安全指针所引用着的内存块在此调用返回之前不会被垃圾回收和移动。
注意:在 Go 1.15 之前,类型转换表达式uintptr(anUnsafePointer)
可以呈现为相关实参的子表达式。 但是,从 Go 1.15 开始,使用此模式的要求变得略加严格:相关实参必须呈现为uintptr(anUnsafePointer)
这种形式。
下面这个调用是安全的:
|
|
但下面这个调用则是危险的:
|
|
再提醒一次,此使用模式不适用于其它自定义函数。
使用模式五:将reflect.Value.Pointer
或者reflect.Value.UnsafeAddr
方法的uintptr
返回值立即转换为非类型安全指针。
reflect
标准库包中的Value
类型的Pointer
和UnsafeAddr
方法都返回一个uintptr
值,而不是一个unsafe.Pointer
值。 这样设计的目的是避免用户不引用unsafe
标准库包就可以将这两个方法的返回值(如果是unsafe.Pointer
类型)转换为任何类型安全指针类型。
这样的设计需要我们将这两个方法的调用的uintptr
结果立即转换为非类型安全指针。 否则,将出现一个短暂的可能导致处于返回的地址处的内存块被回收掉的时间窗。 此时间窗是如此短暂以至于此内存块被回收掉的几率非常之低,因而这样的编程错误造成的 bug 的重现几率亦十分得低。
比如,下面这个调用是安全的:
|
|
而下面这个调用是危险的:
|
|
注意:此使用模式也适用于 Windows 系统中的syscall.Proc.Call和syscall.LazyProc.Call系统调用。
使用模式六:将一个reflect.SliceHeader
或者reflect.StringHeader
值的Data
字段转换为非类型安全指针,以及其逆转换。
和上一小节中提到的同样的原因,reflect
标准库包中的SliceHeader
和StringHeader
类型的Data
字段的类型被指定为uintptr
,而不是unsafe.Pointer
。
我们可以将一个字符串的指针值转换为一个*reflect.StringHeader
指针值,从而可以对此字符串的内部进行修改。 类似地,我们可以将一个切片的指针值转换为一个*reflect.SliceHeader
指针值,从而可以对此切片的内部进行修改。
一个使用reflect.StringHeader
的例子:
|
|
一个使用了reflect.SliceHeader
的例子:
|
|
一般说来,我们只应该从一个已经存在的字符串值得到一个*reflect.StringHeader
指针, 或者从一个已经存在的切片值得到一个*reflect.SliceHeader
指针, 而不应该从一个StringHeader
值生成一个字符串,或者从一个SliceHeader
值生成一个切片。 比如,下面的代码是不安全的:
|
|
下面是一个展示了如何通过使用非类型安全途径将一个字符串转换为字节切片的例子。 和使用类型安全途径进行转换不同,使用非类型安全途径避免了复制一份底层字节序列。
|
|
reflect
标准库包中SliceHeader
和StringHeader
类型的文档提到这两个结构体类型的定义不保证在以后的版本中不发生改变。好在目前的两个主流 Go 编译器(标准编译器和 gccgo 编译器)都认可当前版本中的定义。这也可以看作是使用非类型安全指针的另一个(较低的)潜在风险。
注意:当使用上面展示的使用非类型安全指针将一个字符串转换为字节切片时,请确保结果此源字符串的生命期内务必不要修改结果字节切片中的字节值(上面的例子违背了此原则)。 事实上,更为推荐的是最好永远不要修改结果字节切片中的字节值。此非类型安全方式的目的主要是为了在局部感知范围内避免一次内存开辟,而不是一种通用的方式。
我们可以使用类似的实现(如下所示)来将一个字节切片转换为字符串。 此实现被模式一中展示的方法略为安全一些(但是也更慢一些)。
|
|
同样地,请确保结果此结果字符串的生命期内务必不要修改实参字节切片中的字节值。
最后,顺便举一个违背了模式三的使用原则的例子:
|
|
下面是两个正确的实现:
|
|
上面这几个例子借鉴自 Bryan C. Mills 在 slack 中发表的一个留言。
总结一下
从上面解释中,我们得知,对于某些情形,非类型安全机制可以帮助我们写出运行效率更高的代码。 但是,使用非类型安全指针也使得我们可能轻易地写出一些重现几率非常低的微妙的 bug。 一个含有这样的 bug 的程序很可能在很长一段时间内都运行正常,但是突然变得不正常甚至崩溃。 这样的 bug 很难发现和调试。
我们只应该在不得不使用非类型安全机制的时候才使用它们。 特别地,当我们使用非类型安全机制时,请务必遵循上面列出的使用模式。
重申一次,我们应该知晓当前的非类型安全机制规则和使用模式可能在以后的 Go 版本中完全失效。 当然,目前没有任何迹象表明这种变化将很快会来到。 但是,一旦发生这种变化,本文中列出的当前是正确的代码将变得不再安全甚至编译不通过。 所以,在实践中,请尽量保证能够将使用了非类型安全机制的代码轻松改为使用安全途径实现。
最后值得提一下的是,Go 官方工具链 1.14 中加入了一个-gcflags=all=-d=checkptr
编译器动态分析选项(在 Windows 平台上推荐使用工具链 1.15+)。 当此选项被使用的时候,编译出的程序在运行时会监测到很多(但并非所有)非类型安全指针的错误使用。一旦错误的使用被监测到,恐慌将产生。 感谢Matthew Dempsky实现了此特性。