golang unsafe

golang unsafe

先说说指针

相比于 C 语言中指针的灵活,Go 的指针多了一些限制。但这也算是 Go 的成功之处:既可以享受指针带来的便利,又避免了指针的危险性。

限制一:Go 的指针不能进行数学运算

来看一个简单的例子:

 a := 5
 p := &a
 ​
 p++
 p = &a + 3

上面的代码将不能通过编译,会报编译错误:invalid operation,也就是说不能对指针做数学运算。

限制二:不同类型的指针不能相互转换

例如下面这个简短的例子:

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

也会报编译错误:

 cannot use &a (type *int) as type *float64 in assignment

限制三:不同类型的指针不能使用 == 或 != 比较

只有在两个指针类型相同或者可以相互转换的情况下,才可以对两者进行比较。另外,指针可以通过 ==!= 直接和 nil 作比较。

限制四:不同类型的指针变量不能相互赋值

什么是 unsafe

前面所说的指针是类型安全的,但它有很多限制。Go 还有非类型安全的指针,这就是 unsafe 包提供的 unsafe.Pointer。在某些情况下,它会使代码更高效,当然,也更危险。

unsafe 包用于 Go 编译器,在编译阶段使用。从名字就可以看出来,它是不安全的,官方并不建议使用。我在用 unsafe 包的时候会有一种不舒服的感觉,可能这也是语言设计者的意图吧。

但是高阶的 Gopher,怎么能不会使用 unsafe 包呢?它可以绕过 Go 语言的类型系统,直接操作内存。例如,一般我们不能操作一个结构体的未导出成员,但是通过 unsafe 包就能做到。unsafe 包让我可以直接读写内存,还管你什么导出还是未导出。

为什么有 unsafe

Go 语言类型系统是为了安全和效率设计的,有时,安全会导致效率低下。有了 unsafe 包,高阶的程序员就可以利用它绕过类型系统的低效。因此,它就有了存在的意义,阅读 Go 源码,会发现有大量使用 unsafe 包的例子。

unsafe 源码

我们来看源码:

 type ArbitraryType int
 ​
 type Pointer *ArbitraryType

从命名来看,Arbitrary 是任意的意思,也就是说 Pointer 可以指向任意类型,实际上它类似于 C 语言里的 void*

unsafe 包还有其他三个函数:

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

Sizeof 返回类型 x 所占据的字节数,但不包含 x 所指向的内容的大小。例如,对于一个指针,函数返回的大小为 8 字节(64位机上),一个 slice 的大小则为 slice header 的大小。

Offsetof 返回结构体成员在内存中的位置离结构体起始处的字节数,所传参数必须是结构体的成员。

Alignof 返回 m,m 是指当类型进行内存对齐时,它分配到的内存地址能整除 m。

注意到以上三个函数返回的结果都是 uintptr 类型,这和 unsafe.Pointer 可以相互转换。三个函数都是在编译期间执行,它们的结果可以直接赋给 const 型变量。另外,因为三个函数执行的结果和操作系统、编译器相关,所以是不可移植的。

综上所述,unsafe 包提供了 2 点重要的能力:

  1. 任何类型的指针和 unsafe.Pointer 可以相互转换。
  2. uintptr 类型和 unsafe.Pointer 可以相互转换。

type pointer uintptr

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

 // uintptr 是一个整数类型,它足够大,可以存储
 type uintptr uintptr

还有一点要注意的是,uintptr 并没有指针的语义,意思就是 uintptr 所指向的对象会被 gc 无情地回收。而 unsafe.Pointer 有指针语义,可以保护它所指向的对象在“有用”的时候不会被垃圾回收。

unsafe 如何使用

获取 slice 长度

通过前面关于 slice 的文章,我们知道了 slice header 的结构体定义:

 // runtime/slice.go
 type slice struct {
     array unsafe.Pointer // 元素指针
     len   int // 长度 
     cap   int // 容量
 }

调用 make 函数新建一个 slice,底层调用的是 makeslice 函数,返回的是 slice 结构体:

 func makeslice(et *_type, len, cap int) slice

因此我们可以通过 unsafe.Pointer 和 uintptr 进行转换,得到 slice 的字段值。

 func main() {
     s := make([]int, 9, 20)
     var Len = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8)))
     fmt.Println(Len, len(s)) // 9 9
 ​
     var Cap = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(16)))
     fmt.Println(Cap, cap(s)) // 20 20
 }

Len,cap 的转换流程如下:

 Len: &s => pointer => uintptr => pointer => *int => int
 Cap: &s => pointer => uintptr => pointer => *int => int

Offsetof 获取成员偏移量

对于一个结构体,通过 offset 函数可以获取结构体成员的偏移量,进而获取成员的地址,读写该地址的内存,就可以达到改变成员值的目的。

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

我们来看一个例子:

 package main
 ​
 import (
     "fmt"
     "unsafe"
 )
 ​
 type Programmer struct {
     name string
     language string
 }
 ​
 func main() {
     p := Programmer{"stefno", "go"}
     fmt.Println(p)
     
     name := (*string)(unsafe.Pointer(&p))
     *name = "qcrao"
 ​
     lang := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + unsafe.Offsetof(p.language)))
     *lang = "Golang"
 ​
     fmt.Println(p)
 }

运行代码,输出:

 {stefno go}
 {qcrao Golang}

name 是结构体的第一个成员,因此可以直接将 &p 解析成 *string。这一点,在前面获取 map 的 count 成员时,用的是同样的原理。

对于结构体的私有成员,现在有办法可以通过 unsafe.Pointer 改变它的值了。

我把 Programmer 结构体升级,多加一个字段:

 type Programmer struct {
     name string
     age int
     language string
 }

并且放在其他包,这样在 main 函数中,它的三个字段都是私有成员变量,不能直接修改。但我通过 unsafe.Sizeof() 函数可以获取成员大小,进而计算出成员的地址,直接修改内存。

 func main() {
     p := Programmer{"stefno", 18, "go"}
     fmt.Println(p)
 ​
     lang := (*string)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + unsafe.Sizeof(int(0)) + unsafe.Sizeof(string(""))))
     *lang = "Golang"
 ​
     fmt.Println(p)
 }

输出:

 {stefno 18 go}
 {stefno 18 Golang}

string 和 slice 的相互转换

这是一个非常精典的例子。实现字符串和 bytes 切片之间的转换,要求是 zero-copy。想一下,一般的做法,都需要遍历字符串或 bytes 切片,再挨个赋值。

完成这个任务,我们需要了解 slice 和 string 的底层数据结构:

 type StringHeader struct {
     Data uintptr
     Len  int
 }
 ​
 type SliceHeader struct {
     Data uintptr
     Len  int
     Cap  int
 }

上面是反射包下的结构体,路径:src/reflect/value.go。只需要共享底层 []byte 数组就可以实现 zero-copy

 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))
 }

代码比较简单,不作详细解释。通过构造 slice header 和 string header,来完成 string 和 byte slice 之间的转换。

错误示例

 // NOTE: subtly incorrect!
 tmp := uintptr(unsafe.Pointer(&x)) + unsafe.Offsetof(x.b)
 pb := (*int16)(unsafe.Pointer(tmp))
 *pb = 42

产生错误的原因很微妙。

有时候垃圾回收器会移动一些变量以降低内存碎片等问题。这类垃圾回收器被称为移动GC。当一个变量被移动,所有的保存改变量旧地址的指针必须同时被更新为变量移动后的新地址。从垃圾收集器的视角来看,一个unsafe.Pointer是一个指向变量的指针,因此当变量被移动是对应的指针也必须被更新;但是uintptr类型的临时变量只是一个普通的数字,所以其值不应该被改变。上面错误的代码因为引入一个非指针的临时变量tmp,导致垃圾收集器无法正确识别这个是一个指向变量x的指针。当第二个语句执行时,变量x可能已经被转移,这时候临时变量tmp也就不再是现在的&x.b地址。第三个向之前无效地址空间的赋值语句将彻底摧毁整个程序。

参考链接

www.jianshu.com/p/10b8870a9…

www.cnblogs.com/qcrao-2018/…

猜你喜欢

转载自juejin.im/post/7118327418883932167