非类型安全指针
事实上,在那篇文章中解释的指针的完整称呼应该为类型安全指针。虽然类型安全指针有助于我们轻松写出安全的代码,但是有时候施加在类型安全指针上的限制也确实导致我们不能写出最高效的代码。
实际上,Go也支持限制较少的非类型安全指针。非类型安全指针和C指针类似,它们都很强大,但同时也都很危险。在某些情形下,通过非类型安全指针的帮助,我们可以写出效率更高的代码;但另一方面,使用非类型安全指针也导致我们可能轻易地写出潜在的不安全的代码,这些潜在的不安全点很难在它们产生危害之前被及时发现。
使用非类型安全指针的另外一个较大的风险是Go中目前提供的非类型安全指针机制并不受到Go 1 兼容性保证的保护。使用了非类型安全指针的代码可以从今后的某个Go版本开始将不再能编译通过,或者运行行为发生了变化。
如果出于种种原因,你确实希望在你的代码中使用非类型安全指针,你不仅需要提防上述风险,你还需遵守Go官方文档中列出的非类型安全指针使用模式,并清楚地知晓使用非类型安全指针带来的效果。否则,你很难使用非类型安全指针写出安全的代码。
非类型安全指针在Go中为特别的类型。我们必须引入标准库包来使用非类型安全指针。非类型安全指针unsafe.Pointer
被声明定义为:
当然,这不是一个普通的类型定义。这里的ArbitraryType
仅仅是暗示unsafe.Pointer
类型值可以被转换为任意类型安全指针(反之亦然)。换句话说,unsafe.Pointer
类似于C语言中的void*
。
非类型安全指针是指底层类型为unsafe.Pointer
的类型。
非类型安全指针的零值也使用预声明的nil
标识符来表示。
unsafe
标准库包提供了三个函数:
func Alignof(variable ArbitraryType) uintptr
。此函数用来取得一个值在内存中的地址对齐保证(address alignment guarantee)。注意,同一个类型的值做为结构体字段和非结构体字段时地址对齐保证可能是不同的。当然,这和具体编译器的实现有关。对于目前的标准编译器,同一个类型的值做为结构体字段和非结构体字段时的地址对齐保证总是相同的。gccgo编译器对这两种情形是区别对待的。func Offsetof(selector ArbitraryType) uintptr
。此函数用来取得一个结构体值的某个字段的地址相对于此结构体值的地址的偏移。在一个程序中,对于同一个结构体类型的不同值的对应相同字段,此函数的返回值总是相同的。func Sizeof(variable ArbitraryType) uintptr
。此函数用来取得一个值的尺寸(亦即此值的类型的尺寸)。在一个程序中,对于同一个类型的不同值,此函数的返回值总是相同的。 注意,这三个函数的返回值的类型均为内置类型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.13版本编译时的结果。
目前(Go 1.13),Go支持下列和非类型安全指针相关的类型转换:
- 一个类型安全指针值可以被显式转换为一个非类型安全指针类型,反之亦然。
- 一个uintptr值可以被显式转换为一个非类型安全指针类型,反之亦然。但是,注意,一个nil非类型安全指针类型不应该被转换为uintptr并进行算术运算后再转换回来。 通过使用这些转换规则,我们可以将任意两个类型安全指针转换为对方的类型,我们也可以将一个安全指针值和一个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 // 危险操作!
}
事实三:一个值的地址在程序运行中可能改变
详情请阅读一文(见链接所指一节的尾部)。这里我们只需要知道当一个协程的栈的大小改变时,开辟在此栈上的内存块需要移动,从而相应的值的地址将改变。
事实四:我们可以将一个值的指针传递给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 // 危险操作!
}
事实六:*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
,其中T1
和T2
为两个任意类型。然而,我们只有在T1
的尺寸不大于T2
并且此转换具有实际意义的时候才应该实施这样的转换。
通过将一个T1
值转换为类型T2
,我们也可以将一个值转换为类型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值输出到日志中用来调试,但是有很多其它安全的途径也可以实现此目的。 一个例子:
输出地址在每次运行中可能都会不同。
使用模式三:将一个非类型安全指针转换为一个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]值的曾经
// 的地址是非法和危险的!另一个危险的原因是
// t的地址在执行下一行之前可能改变(见事实三)。
// 另一个潜在的危险是:如果在此期间发生了一些
// 导致协程堆栈大小改变的情况,则记录在addr中
// 的地址将失效。当然,此危险对于这个特定的例子
// 并不存在。
ty2 := (*int16)(unsafe.Pointer(addr))
fmt.Println(*ty2)
}
这样的bug是非常微妙和很难被觉察到的,并且爆发出来的几率是相当得低。一旦这样的bug爆发出来,将很让人摸不到头脑。这是为什么使用非类型安全指针是危险的原因之一。官方Go编译器1.14版本可能会添加一个-d=checkptr
选项,用来检查这样的危险。
如果我们确实希望将上面提到的转换拆成两行,我们应该在拆分后的两行后添加一条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)
}
然而,我并不推荐在此使用模式中使用此runtime.KeepAlive
技巧。具体原因见上面的注释中提到的潜在的危险。因为存在着这样一种可能:当Go运行时为变量ty2
开辟内存的时候,当前协程的栈的大小需要进行增大调整。调整之后t
的地址将改变,但是存储在变量addr
中的地址值却未得到更新(因为只有开辟在栈上的指针类型的值才会被更新,而变量addr
的类型为整数类型uintptr
)。这直接导致存储在变量ty2
的地址值时无效的(野指针)。但是,实事求是地讲,如果上例中的代码使用官方标准编译器编译,则此潜在的危险并不存在。原因是在官方标准编译器的实现中,一个runtime.KeepAlive
调用将使它的实参和被此实参引用的值开辟到堆上,并且开辟在堆上的内存块从不会被移动。
另一个需要注意的细节是最好不要将一个内存块的结尾边界地址存储在一个(安全或非安全)指针中。这样做将导致紧随着此内存块的另一个内存块肯定不会被垃圾回收掉。请阅读以获取更多解释。
使用模式四:将非类型安全指针值转换为uintptr值并传递给syscall.Syscall函数调用。
通过对上一个使用模式的解释,我们知道像下面这样含有uintptr类型的参数的函数定义是危险的。
// 假设此函数不会被内联。
func DoSomething(addr uintptr) {
// 对处于传递进来的地址处的值进行读写...
}
上面这个函数是危险的原因在于此函数本身不能保证传递进来的地址处的内存块一定没有被回收。如果此内存块已经被回收了或者被重新分配给了其它值,那么此函数内部的操作将是非法和危险的。
然而,syscall
标准库包中的Syscall
函数的原型为:
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
我们可以认为编译器针对每个syscall.Syscall
函数调用中的每个被转换为uintptr
类型的非类型安全指针实参添加了一些指令,从而保证此非类型安全指针所引用着的内存块在此调用返回之前不会被垃圾回收和移动。
下面这个调用是安全的:
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))
再提醒一次,此使用模式不适用于其它自定义函数。
使用模式五:将reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值转换为非类型安全指针。
reflect
标准库包中的Value
类型的Pointer
和UnsafeAddr
方法都返回一个uintptr
值,而不是一个unsafe.Pointer
值。这样设计的目的是避免用户不引用unsafe
标准库包就可以将这两个方法的返回值(如果是unsafe.Pointer
类型)转换为任何类型安全指针类型。
这样的设计需要我们将这两个方法的调用的uintptr
结果立即转换为非类型安全指针。否则,将出现一个短暂的可能导致处于返回的地址处的内存块被回收掉的时间窗。此时间窗是如此短暂以至于此内存块被回收掉的几率非常之低,因而这样的编程错误造成的bug的重现几率亦十分得低。
比如,下面这个调用是安全的:
而下面这个调用是危险的:
u := reflect.ValueOf(new(int)).Pointer()
// 在这个时刻,处于存储在u中的地址处的内存块
注意:此使用模式也适用于Windows系统中的syscall.Proc.Call和系统调用。
使用模式六:将一个reflect.SliceHeader或者reflect.StringHeader值的Data字段转换为非类型安全指针,以及其逆转换。
和上一小节中提到的同样的原因,reflect
标准库包中的SliceHeader
和StringHeader
类型的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
标准库包中SliceHeader
和StringHeader
类型的提到这两个结构体类型的定义不保证在以后的版本中不发生改变。好在目前的两个主流Go编译器(标准编译器和gccgo编译器)都认可当前版本中的定义。这也可以看作是使用非类型安全指针的另一个潜在风险。
我们可以使用类似的实现来将一个字节切片转换为字符串。然而,当前(Go 1.13),有一个更简单和更有效的方法来实现这一转换:
func ByteSlice2String(bs []byte) string {
return *(*string)(unsafe.Pointer(&bs))
}
此实现借鉴于strings
标准库包中的Builder
类型的String
方法的实现。此实现利用了上述第一个使用模式。
事实上,为了避免因为忘记调用runtime.KeepAlive
函数而造成的危险,在日常编程中更推荐使用我们自定义Data
字段的类型为unsafe.Pointer
SliceHeader
和StringHeader
结构体。比如:
type SliceHeader struct {
Data unsafe.Pointer
Len int
Cap int
}
type StringHeader struct {
Data unsafe.Pointer
Len int
}
func String2ByteSlice(str string) (bs []byte) {
strHdr := (*StringHeader)(unsafe.Pointer(&str))
sliceHdr := (*SliceHeader)(unsafe.Pointer(&bs))
sliceHdr.Data = strHdr.Data
sliceHdr.Len = strHdr.Len
sliceHdr.Cap = strHdr.Len
// 此KeepAlive调用变得不再必需。
//runtime.KeepAlive(&str)
}
从上面解释中,我们得知,对于某些情形,非类型安全机制可以帮助我们写出运行效率更高的代码。但是,使用非类型安全指针也使得我们可能轻易地写出一些重现几率非常低的微妙的bug。一个含有这样的bug的程序很可能在很长一段时间内都运行正常,但是突然变得不正常甚至崩溃。这样的bug很难发现和调试。
我们只应该在不得不使用非类型安全机制的时候才使用它们。特别地,当我们使用非类型安全机制时,请务必遵循上面列出的使用模式。
重申一次,我们应该知晓当前的非类型安全机制规则和使用模式可能在以后的Go版本中完全失效。当然,目前没有任何迹象表明这种变化将很快会来到。但是,一旦发生这种变化,本文中列出的当前是正确的代码将变得不再安全甚至编译不通过。所以,在实践中,请尽量保证能够将使用了非类型安全机制的代码轻松改为使用安全途径实现。
Go语言101项目目前同时托管在Github和上。欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。
本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。