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

本文详细探讨了Go语言中`unsafe.Pointer`和`uintptr`的使用原则,包括它们的区别、内存分配以及非类型安全指针的正确使用模式。文章指出,`uintptr`仅是一个整数,不具有指针的语义,可能导致内存被回收或地址改变。正确使用`unsafe.Pointer`涉及多种模式,如类型转换、避免野指针等问题。文章还提醒开发者注意内存的生命周期和GC行为,以确保代码的正确性和安全性。
摘要由CSDN通过智能技术生成

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(
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值