Golang指针的使用限制和unsafe.Pointer的突破之路

1,827 阅读11分钟

大家好呀,今天想在这篇文章里好好跟大家聊一下 Go 语言指针这个话题,相较于 C 而言,Go 语言在设计时为了使用安全给指针在类型和运算上增加了限制,这让Go程序员既可以享受指针带来的便利,又避免了指针的危险性。除了常规的指针外,Go 语言在 unsafe 包里其实还通过 unsafe.Pointer 提供了通用指针,通过这个通用指针以及 unsafe 包的其他几个功能又让使用者能够绕过 Go 语言的类型系统直接操作内存进行例如:指针类型转换,读写结构体私有成员这样操作。网管觉得正是因为功能强大同时伴随着操作不慎读写了错误的内存地址即会造成的严重后果所以 Go 语言的设计着才会把这些功能放在 unsafe 包里。其实也没有想得那么不安全,掌握好了使用得当还是能带来很大的便利的,在一些偏向底层的源码中 unsafe 包使用的频率还是不低的。对于励志成为高阶 Gopher 的各位,这也是一项必不可少需要掌握的技能啦。接下来网管就带大家从基本的指针使用方法和限制开始看看怎么用 unsafe 包跨过这些限制直接读写内存。

基础知识

指针保存着一个值的内存地址,类型 *T代表指向T 类型值的指针。其零值为nil

&操作符为它的操作数生成一个指针。

i := 42
p = &i

*操作符则会取出指针指向地址的值,这个操作也叫做“解引用”。

fmt.Println(*p) // 通过指针p读取存储的值
*p = 21         // 通过指针p设置p执行的内存地址存储的值

为什么需要指针类型呢?参考一个从go101网站上看到的例子 :

package main

import "fmt"

func double(x int) { 
   x += x
}

func main() { 
   var a = 3 
   double(a)
    fmt.Println(a) // 3
}

在 double 函数里将 a 翻倍,但是例子中的函数却做不到。因为 Go 语言的函数传参都是值传递。double 函数里的 x 只是实参 a 的一个拷贝,在函数内部对 x 的操作不能反馈到实参 a。

把参数换成一个指针就可以解决这个问题了。

package main
import "fmt"

func double(x *int) { 
   *x += *x
    x = nil
}

func main() {
    var a = 3
    double(&a)
    fmt.Println(a) // 6
    p := &a
    double(p)
    fmt.Println(a, p == nil) // 12 false
}

上面的程序乍一看你可能对下面这一行代码有些疑惑

x = nil

稍微思考一下上面说的Go语言里面参数都是值传递,你就会知道这一行代码根本不影响外面的变量 a。因为参数都是值传递,所以函数内的 x 也只是对 &a 的一个拷贝。

*x += *x

这一句把 x 指向的值(也就是 &a 指向的值,即变量 a)变为原来的 2 倍。但是对 x 本身(一个指针)的操作却不会影响外层的 a,所以在double函数内部的 x=nil 不会影响外面。

指针的限制

相较于 C 语言指针的灵活,Go 语言里指针多了不少限制,不过这让我们:既可以享受指针带来的便利,又避免了指针的危险性。下面就简单说一下 Go 对指针操作的一些限制

限制一:指针不能参与运算

来看一个简单的例子:

package main

import "fmt"

func main() {
	a := 5
	p := a
	fmt.Println(p)
	p = &a + 3
}

上面的代码将不能通过编译,会报编译错误:

invalid operation: &a + 3 (mismatched types *int and int)

也就是说 Go 不允许对指针进行数学运算。

限制二:不同类型的指针不允许相互转换。

下面的程序同样也不能编译成功:

package main

func main() {
	var a int = 100
	var f *float64
	f = *float64(&a)
}

限制三:不同类型的指针不能比较和相互赋值

这条限制同上面的限制二,因为指针之间不能做类型转换,所以也没法使用==或者!=进行比较了,同样不同类型的指针变量相互之间不能赋值。比如下面这样,也是会报编译错误。

package main

func main() {
	var a int = 100
	var f *float64
	f = &a
}

Go语言的指针是类型安全的,但它有很多限制,所以 Go 还有提供了可以进行类型转换的通用指针,这就是 unsafe 包提供的 unsafe.Pointer。在某些情况下,它会使代码更高效,当然,也更危险。

unsafe 包

unsafe 包用于编译阶段可以绕过 Go 语言的类型系统,直接操作内存。例如,利用 unsafe 包操作一个结构体的未导出成员。unsafe 包让我可以直接读写内存的能力。

unsafe包只有两个类型,三个函数,但是功能很强大。

type ArbitraryType int
type Pointer *ArbitraryType

func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

ArbitraryTypeint的一个别名,在 Go 中ArbitraryType有特殊的意义。代表一个任意Go表达式类型。 Pointerint指针类型的一个别名,在 Go 中可以把任意指针类型转换成unsafe.Pointer类型。

三个函数的参数均是ArbitraryType类型,就是接受任何类型的变量。

  • Sizeof接受任意类型的值(表达式),返回其占用的字节数,这和c语言里面不同,c语言里面sizeof函数的参数是类型,而这里是一个值,比如一个变量。
  • Offsetof:返回结构体成员在内存中的位置距离结构体起始处的字节数,所传参数必须是结构体的成员(结构体指针指向的地址就是结构体起始处的地址,即第一个成员的内存地址)。
  • Alignof返回变量对齐字节数量,这个函数虽然接收的是任何类型的变量,但是有一个前提,就是变量要是一个struct类型,且还不能直接将这个struct类型的变量当作参数,只能将这个struct类型变量的值当作参数,具体细节咱们到以后聊内存对齐的文章里再说。。

注意以上三个函数返回的结果都是 uintptr 类型,这和 unsafe.Pointer 可以相互转换。三个函数都是在编译期间执行

unsafe.Pointer

unsafe.Pointer称为通用指针,官方文档对该类型有四个重要描述:

  1. 任何类型的指针都可以被转化为Pointer
  2. Pointer可以被转化为任何类型的指针
  3. uintptr可以被转化为Pointer
  4. Pointer可以被转化为uintptr

unsafe.Pointer是特别定义的一种指针类型(译注:类似C语言中的void类型的指针),在Go 语言中是用于各种指针相互转换的桥梁,它可以持有任意类型变量的地址

什么叫"可以持有任意类型变量的地址"呢?意思就是使用 unsafe.Pointer 转换的变量,该变量一定要是指针类型,否则编译会报错。

a := 1
b := unsafe.Pointer(a) //报错
b := unsafe.Pointer(&a) // 正确

和普通指针一样,unsafe.Pointer 指针也是可以比较的,并且支持和 nil 比较判断是否为空指针。

unsafe.Pointer 不能直接进行数学运算,但可以把它转换成 uintptr,对 uintptr 类型进行数学运算,再转换成 unsafe.Pointer 类型

// uintptr、unsafe.Pointer和普通指针之间的转换关系
uintptr <==> unsafe.Pointer <==> *T

uintptr

uintptr是 Go 语言的内置类型,是能存储指针的整型,在64位平台上底层的数据类型是 uint64。

// uintptr is an integer type that is large enough to hold the bit pattern of
// any pointer.
type uintptr uintptr

typedef unsigned long long int  uint64;
typedef uint64          uintptr;

一个unsafe.Pointer指针也可以被转化为uintptr类型,然后保存到uintptr类型的变量中(注:这个变量只是和当前指针有相同的一个数字值,并不是一个指针),然后用以做必要的指针数值运算。(uintptr是一个无符号的整型数,足以保存一个地址)这种转换虽然也是可逆的,但是随便将一个 uintptr 转为 unsafe.Pointer指针可能会破坏类型系统,因为并不是所有的数字都是有效的内存地址。

还有一点要注意的是,uintptr 并没有指针的语义,意思就是存储 uintptr 值的内存地址在Go发生GC时会被回收。而 unsafe.Pointer 有指针语义,可以保护它不会被垃圾回收。

聊了这么多概念性的话题,接下来网管带大家一起看看怎么使用 unsafe.Pointer 进行指针转换以及结合 uintptr 读写结构体的私有成员。

应用示例

使用unsafe.Pointer进行指针类型转换

import (
    "fmt"
    "reflect"
    "unsafe"
)
 
func main() {
 
    v1 := uint(12)
    v2 := int(13)
 
    fmt.Println(reflect.TypeOf(v1)) //uint
    fmt.Println(reflect.TypeOf(v2)) //int
 
    fmt.Println(reflect.TypeOf(&v1)) //*uint
    fmt.Println(reflect.TypeOf(&v2)) //*int
 
    p := &v1
    p = (*uint)(unsafe.Pointer(&v2)) //使用unsafe.Pointer进行类型的转换
 
    fmt.Println(reflect.TypeOf(p)) // *unit
    fmt.Println(*p) //13
}

使用unsafe.Pointer 读写结构体的私有成员

通过 Offsetof 方法可以获取结构体成员的偏移量,进而获取成员的地址,读写该地址的内存,就可以达到改变成员值的目的。

这里有一个内存分配相关的事实:结构体会被分配一块连续的内存,结构体的地址也代表了第一个成员的地址。

package main
 
import (
    "fmt"
    "unsafe"
)
 
func main() {
 
    var x struct {
        a int
        b int
        c []int
    }
 
    // unsafe.Offsetof 函数的参数必须是一个字段,  比如 x.b,  方法会返回 b 字段相对于 x 起始地址的偏移量, 包括可能的空洞。

    // 指针运算 uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b)。
    
    // 和 pb := &x.b 等价
    pb := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b)))

    *pb = 42
    fmt.Println(x.b) // "42"
}

上面的写法尽管很繁琐,但在这里并不是一件坏事,因为这些功能应该很谨慎地使用。不要试图引入一个uintptr类型的临时变量,因为它可能会破坏代码的安全性

如果改为下面这种用法是有风险的:

tmp := uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b)
pb := (*int16)(unsafe.Pointer(tmp))
*pb = 42

随着程序执行的进行,goroutine 会经常发生栈扩容或者栈缩容,会把旧栈内存的数据拷贝到新栈区然后更改所有指针的指向。一个 unsafe.Pointer 是一个指针,因此当它指向的数据被移动到新栈区后指针也会被更新。但是uintptr 类型的临时变量只是一个普通的数字,所以其值不会该被改变。上面错误的代码因为引入一个非指针的临时变量 tmp,导致系统无法正确识别这个是一个指向变量 x 的指针。当第二个语句执行时,变量 x 的数据可能已经被转移,这时候临时变量tmp也就不再是现在的 &x.b 的地址。第三个语句向之前无效地址空间的赋值语句将让整个程序崩溃。

string 和 []byte 零拷贝转换

这是一个非常精典的例子。实现字符串和 bytes 切片之间的零拷贝转换。

string和[]byte 在运行时的类型表示为reflect.StringHeaderreflect.SliceHeader

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

type StringHeader struct {
	Data uintptr
	Len  int
}

只需要共享底层 []byte 数组就可以实现零拷贝转换。

代码比较简单,不作详细解释。通过构造reflect.StringHeaderreflect.SliceHeader,来完成 string 和 []byte 之间的转换。

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

func main() {
	s := "Hello World"
	b := string2bytes(s)
	fmt.Println(b)
	s = bytes2string(b)
	fmt.Println(s)

}

func string2bytes(s string) []byte {
	stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))

	bh := reflect.SliceHeader{
		Data: stringHeader.Data,
		Len: stringHeader.Len,
		Cap: stringHeader.Len,
	}

	return *(*[]byte)(unsafe.Pointer(&bh))
}

func bytes2string(b []byte) string {
	sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&b))

	sh := reflect.StringHeader{
		Data: sliceHeader.Data,
		Len:  sliceHeader.Len,
	}

	return *(*string)(unsafe.Pointer(&sh))
}

总结

Go 的源码中也在大量使用 unsafe 包,通过 unsafe 包绕过 Go 指针的限制,达到直接操作内存的目的,使用它有一定的风险性,但是在一些场景下,可以提升代码的效率。

参考资料

看到这里了,如果喜欢我的文章就帮我点个赞吧,我会每周通过技术文章分享我的所学所见和第一手实践经验,感谢你的支持。微信搜索关注公众号「网管叨bi叨」每周教会你一个进阶知识,还有专门写给开发工程师的Kubernetes入门教程。