golang unsafe.Pointer使用原则以及 uintptr 隐藏的坑

Posted 惜暮

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了golang unsafe.Pointer使用原则以及 uintptr 隐藏的坑相关的知识,希望对你有一定的参考价值。

unsafe.Pointer 和 uintptr 隐藏的坑

new的对象,内存在哪里开辟

使用go标准编译器编译的代码,每个协程都会有自己的协程栈,一个协程栈是一个预申请的内存块。每个协程的初始栈大小比较小(在64位系统上2KB)。 每个栈的大小在协程运行的时候将按照需要增长和收缩( stack grow )。

当我们创建对象申请内存块的时候,可以从协程栈上申请,也可以从堆上申请。

从协程栈上申请的对象,只能在此协程内部被使用(引用),比如指针指向的对象不能逃逸到协程外,其它协程是无法访问到这些内存块的。一个协程不需要使用任何数据同步技术而使用开辟在它的栈上的内存块上的值。

也可以从堆上申请对象, 开辟在堆上的内存块可以被多个协程并发地访问。 在需要的时候需要做并发安全控制。

编译器在编译代码时候会做逃逸分析,关于逃逸分析细节可以参考:golang 逃逸分析与栈、堆分配分析

逃逸分析在编译阶段可以确定一个对象是分配在堆上还是协程栈上。如果编译器觉察到一个内存块在运行时将会被多个协程访问,或者不能轻松地断定此内存块是否只会被一个协程访问,则此内存块将会被开辟在堆上。 也就是说,编译器将采取保守但安全的策略,使得某些可以安全地被开辟在栈上的内存块也有可能会被开辟在堆上。

go支持协程栈是为了提升性能。

  • 从栈上开辟内存块比在堆上快得多;
  • 开辟在栈上的内存块不需要被垃圾回收;
  • 开辟在栈上的内存块对CPU缓存更加友好。

如上所述,目前官方Go编译器中的逃逸分析器并不十分完美,因此某些可以安全地开辟在栈上的值也可能会逃逸到了堆上。

不过我们可以认为每个包级变量(常称全局变量)都被开辟在了堆上,并且它被一个开辟在一个全局内存区上的隐式指针所引用着。 一个开辟在堆上的内存块可能同时被开辟在若干不同栈上的值部所引用着。

一些事实:

  • 如果一个结构体值的一个字段逃逸到了堆上,则此整个结构体值也逃逸到了堆上。
  • 如果一个数组的某个元素逃逸到了堆上,则此整个数组也逃逸到了堆上。
  • 如果一个切片的某个元素逃逸到了堆上,则此切片中的所有元素都将逃逸到堆上,但此切片值的直接部分(SliceHeader)可能开辟在栈上。
  • 如果一个值部v被一个逃逸到了堆上的值部所引用,则此值部v也将逃逸到堆上。

使用内置new函数开辟的内存可能开辟在堆上,也可能开辟在栈上,也就是不是所有的指针指向的对象都保存在堆上。这是与C++不同的一点。

当一个协程的栈的大小改变(grow)时,一个新的内存段将申请给此栈使用。原先已经开辟在老的内存段上的内存块将很有可能被转移到新的内存段上,或者说这些内存块的地址将改变。 相应地,引用着这些开辟在此栈上的内存块的指针(它们同样开辟在此栈上)中存储的地址也将得到刷新。 这里很重要,这也是 uintptr 变量不要轻易使用的原因。

unsafe.Pointer 和 uintptr 是什么

关于这一块可以参考:golang unsafe实践与原理

这里需要再次强调的是: uintptr 就是一个16进制的整数,这个数字表示对象的地址,但是uintptr没有指针的语义。所以有一些情况:一,如果一个对象只有一个 uintptr 表示的地址表示"引用"关系,那么这个对象会在GC时被无情的回收掉,那么uintptr表示一个野地址。二,如果uintptr表示的地址指向的对象发生了copy移动(比如协程栈增长,slice的扩容等),那么uintptr也表示一个野地址。 但是unsafe.Pointer 有指针语义,可以保护它所指向的对象在“有用”的时候不会被垃圾回收,并且在发生移动时候更新地址值。

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

这部分主要是参考unsafe.Pointer的官方文档和:go101的非类型安全指针一文

一些事实

一:非类型安全指针值(unsafe.Pointer)是指针但uintptr值是整数

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

Go的GC会检查对象引用关系并回收不再被程序中的任何仍在使用中的值所引用的对象。指针在这一过程中扮演着重要的角色。值与值之间和内存块与值之间的引用关系是通过指针来表征的。

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

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

也就是GC的开始时间是不确定的。

下面有个例子:

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是一个 uintptr, 不具有指针含义而是一个整数,所以不能保证z指针值所引用的int值所占的内存块一定还没有被回收。 换句话说,当*(*T)(unsafe.Pointer(p2))) = 3被执行的时候,此内存块有可能已经被回收了。 所以,接引p2中存储的地址可能是接引野指针。

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

参考 unsafe.Pointer 和 uintptr 是什么

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

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

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

还是上面 事实二 的例子:

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所引用的值仍在使用中

这里通过最后添加一个runtime.KeepAlive(z)调用,表明在调用runtime.KeepAlive(z)之前,z指针指向的地址都不会被GC回收。 那么*(*int)(unsafe.Pointer(p2))) = 3可以被安全地执行了。

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

比如下面这个例子,值t仍旧在使用中并不能保证被值t.y所引用的值仍在被使用。

 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,反之亦然。

正确使用非类型安全的指针的一些模式

unsafe标准库包的文档中列出了六种非类型安全指针的使用模式。

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

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

模式二:将一个非类型安全指针值转换为一个uintptr值,然后使用此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 := Ty: [3]int16123, 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) 必须要用一行运算。

假设拆成两行:

addr := uintptr(p) + N + M + M
// 从这里到下一行代码执行之前,t值将不再被任何值
// 引用,所以垃圾回收器认为它可以被回收了。一旦
// 它真得被回收了,下面继续使用t.y[2]值的曾经
// 的地址是非法和危险的!另一个危险的原因是
// t的地址在执行下一行之前可能改变(见事实三)。
// 另一个潜在的危险是:如果在此期间发生了一些
// 导致协程堆栈大小改变的情况,则记录在addr中
// 的地址将失效。当然,此危险对于这个特定的例子
// 并不存在。
ty2 := (*int16)(unsafe.Pointer(addr))
fmt.Println(*ty2)

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

如果我们确实希望将上面提到的转换拆成两行,我们应该在拆分后的两行后添加一条runtime.KeepAlive函数调用并将(直接或间接)引用着t.y[2]值的一个值传递给此调用做为实参。 比如:

func main() 
	t := Ty: [3]int16123, 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)

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

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

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

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

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

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

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

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

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

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

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

参考官方文档的一句话:

In general, reflect.SliceHeader and reflect.StringHeader should be used only as *reflect.SliceHeader and *reflect.StringHeader pointing at actual slices or strings, never as plain structs. A program should not declare or allocate variables of these struct types.

对于reflect.SliceHeader and reflect.StringHeader,只期待使用*reflect.SliceHeader and *reflect.StringHeader 指针,而不期待访问里面的成员变量,因为里面的Data属性是一个 uintptr。

一般说来,我们只应该从一个已经存在的字符串值得到一个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),参考 strings.Builder.String()方法,有一个更简单和更有效的方法来实现这一转换:

// String returns the accumulated string.
func (b *Builder) String() string 
	return *(*string)(unsafe.Pointer(&b.buf))

此实现利用了上述模式一,从SliceHeader的定义和StringHeader的定义,可以看到SliceHeader的size是大于StringHeader的,并且StringHeader和SliceHeader属性分布是相似的,都是data在第一个属性(这个不能更改),基于模式一,可以把slice byte转换成string, 而不发生内存拷贝。但是注意:这种方法是不可逆的,也就是这种病方法不能用于将string 零拷贝 成 []byte。

事实上,为了避免因为忘记调用runtime.KeepAlive函数而造成的危险,在日常编程中更推荐使用我们自定义Data字段的类型为unsafe.PointerSliceHeader和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)
	return

reflect.SliceHeader为啥不要使用于获取底层数组指针

我们看看 reflect包里面的定义:

type SliceHeader struct 
	Data uintptr
	Len  int
	Cap  int

可以看到Data域是一个uintptr, 表示一个整数语义,有几种case:

  1. 如果slice的元素都分配在协程栈上,如果协程栈发生了扩容,slice就是移动,那么Data就会失效,变成野地址。
  2. 如果slice发生了扩容(不管分配到堆还是栈),都会导致Data就会失效,变成野地址。
  3. 如果slice分配在堆上,而且没有对象引用slice对象,那么GC会回收,Data域也会失效。

所以真正来说,这个 reflect.SliceHeader 只能用于可读。

真正安全的string零拷贝

参考上面的模式六,推荐使用自定义的 SliceHeader 和 StringHeader,如下:

// SliceHeader is a safe version of SliceHeader used within this project.
type SliceHeader struct 
	Data unsafe.Pointer
	Len  int
	Cap  int


// StringHeader is a safe version of StringHeader used within this project.
type StringHeader struct 
	Data unsafe.Pointer
	Len  int

具体转换过程就不说了,参考文章:golang unsafe实践与原理 https://louyuting.blog.csdn.net/article/details/100178972

总结

使用uintptr始终要注意两点:

  1. GC回收导致内存地址不可用
  2. 协程栈上对象移动导致内存地址不可用(目前(go 1.13)GC算法堆上对象不会移动)

keepAlive的调用保证指针地址所指向对象在调用keepAlive之前部分对象不被回收,并且对象分配在堆上。

参考文献

unsafe包官方文档:https://golang.google.cn/pkg/unsafe/#Pointer
go101 非类型安全指针 https://gfw.go101.org/article/unsafe.html
go101 内存块 https://gfw.go101.org/article/memory-block.html#where-to-allocate
Golang升级到1.7后,之前正确的函数出现错误,分析原因及解决办法: https://zhuanlan.zhihu.com/p/22782641?utm_campaign=studygolang.com&utm_medium=studygolang.com&utm_source=studygolang.com

以上是关于golang unsafe.Pointer使用原则以及 uintptr 隐藏的坑的主要内容,如果未能解决你的问题,请参考以下文章

如何从 golang 中的数组 unsafe.Pointer 创建数组或切片?

golang type assertion and unsafe.Pointer 性能对比

golang 中的指针

Golang学习 - unsafe 包

golang有没有必要传递map指针

2022-06-18:golang与 C++数据结构类型对应关系是怎样的?