Go unsafe

什么是unsafe?

Go的指针是类型安全的,但它有很多限制。Go 还有非类型安全的指针,这就是 unsafe 包提供的 unsafe.Pointer。在某些情况下,它会使代码更高效,当然,也更危险。unsafe 包用于 Go 编译器,在编译阶段使用。从名字就可以看出来,它是不安全的,官方并不建议使用。但是高阶的 Gopher,怎么能不会使用 unsafe 包呢?它可以绕过 Go 语言的类型系统,直接操作内存。例如,一般我们不能操作一个结构体的未导出成员,但是通过 unsafe 包就能做到。unsafe 包让我可以直接读写内存,还管你什么导出还是未导出。

为什么有unsafe?

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

1.1 对象内存布局

要理解 unsafe,核心就是要理解 Go 中 一个对象在内存中究竟是怎么布局的。

要理解对象内存布局前需要掌握以下知识:

  • 计算地址
  • 计算偏移量
  • 直接操作内

可以跑一下以下代码示例,看看不同写法各种偏移量怎么算。

package unsafe

import (
   "fmt"
   "reflect"
)

// PrintFieldOffset 用来打印字段偏移量
// 用于研究内存布局
// 只接受结构体作为输入
func PrintFieldOffset(entity any) {
   typ := reflect.TypeOf(entity)
   for i := 0; i < typ.NumField(); i++ {
      fd := typ.Field(i)
      fmt.Printf("%s: %d \n", fd.Name, fd.Offset)
   }
}

复制代码
复制代码
package layout

import (
   "fmt"
   "testing"
   "unsafe"
   "unsafe/types"
)

func TestPrintFieldOffset(t *testing.T) {
   fmt.Println(unsafe.Sizeof(types.User{}))
   PrintFieldOffset(types.User{})

   fmt.Println(unsafe.Sizeof(types.UserV1{}))
   PrintFieldOffset(types.UserV1{})

   fmt.Println(unsafe.Sizeof(types.UserV2{}))
   PrintFieldOffset(types.UserV2{})
}
复制代码
复制代码

(1) 对象内存布局例子

package types

type User struct {
   Name    string
   age     int32
   Alias   []byte
   Address string
}

复制代码
复制代码
=== RUN   TestPrintFieldOffset
64
Name: 0 
age: 16 
Alias: 24 
Address: 48 
--- PASS: TestPrintFieldOffset (0.00s)
PASS
复制代码
复制代码

64 是结构体的总大小。 问题在于,为什么 Alias 的偏移量是 24,按照 道理来说应该是20?(因为int32类型字段偏移量是4) 这就涉及到了go的对齐规则。

  • Go 对齐规则

    • 按照字长对齐。因为 Go 本身每一次访 问内存都是按照字长的倍数来访问的。

  • 在 32 位字长机器上,就是按照 4 个 字节对齐
  • 在 64 位字长机器上,就是按照 8 个 字节对齐

再回来看刚刚对象内存布局的例子,在age字段之后再添加一个agev1字段,经过运行可以看到agev1的偏移量是20,Alias 的偏移量无变化,这是为什么?

type UserV1 struct {
   Name    string
   age     int32
   agev1   int32
   Alias   []byte
   Address string
}
复制代码
复制代码
=== RUN   TestPrintFieldOffset
64
Name: 0 
age: 16 
agev1: 20 
Alias: 24 
Address: 48 
--- PASS: TestPrintFieldOffset (0.00s)
PASS
复制代码
复制代码

64 是结构体的总大小。 因为 Go 是按照字长来对齐的,所以在64位机器上, age + agev1 恰好一个字长,所以 Alias 的偏移量其实没有变。

1.2 使用 unsafe 来读写字段

:*(*T)(ptr),T 是目标类型,如果类型不知道,只能拿到反射的 Type typ,那么可以用reflect.NewAt(typ, ptr).Elem()。

:*(*T)(ptr) = T,T 是目标类型。

ptr 是字段偏移量: ptr = 结构体起始地址 + 字段偏移量

type UnsafeAccessor struct {
   fields     map[string]FieldMeta //  主要是字段偏移量
   entityAddr unsafe.Pointer       //  结构体的起始地址
}

func NewUnsafeAccessor(entity any) (*UnsafeAccessor, error) {
   if entity == nil {
      return nil, errors.New("invalid entity")
   }
   val := reflect.ValueOf(entity)
   typ := reflect.TypeOf(entity)
   //  typ.Elem() 获取具体类型
   elemType := typ.Elem()

   if typ.Kind() != reflect.Pointer || elemType.Kind() != reflect.Struct {
      return nil, errors.New("invalid entity")
   }
   //  typ.Elem().NumField() 从typ 的具体类型里 获取字段总数量
   fieldNum := elemType.NumField()
   fields := make(map[string]FieldMeta, fieldNum)
   for i := 0; i < fieldNum; i++ {
      fd := elemType.Field(i)
      // fd.Offset 字段偏移量
      fields[fd.Name] = FieldMeta{typ: fd.Type, offset: fd.Offset}
   }

   // val.UnsafePointer() 从值里获得起始地址
   return &UnsafeAccessor{entityAddr: val.UnsafePointer(), fields: fields}, nil
}
复制代码
复制代码

注意:unsafe 操作的是内存,本质上是对象的起始地址。

res := *(*T)(ptr) 读的例子:

func (u *UnsafeAccessor) Field(field string) (int, error) {
   fdMeta, ok := u.fields[field]
   if !ok {
      return 0, errors.New("不存在字段")
   }
   // 计算地址了
   ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
   if ptr == nil {
      return 0, fmt.Errorf("invalid address of the field: %s", field)
   }
   // 关键操作 获取字段地址 (字段地址) = 结构体起始地址 + 字段偏移量
   // res := *(*int)(unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset))
   res := *(*int)(ptr)
   return res, nil
}

func (u *UnsafeAccessor) FieldAny(field string) (any, error) {
   meta, ok := u.fields[field]
   if !ok {
      return 0, errors.New("不存在字段")
   }
   // 计算地址了
   res := reflect.NewAt(meta.typ, unsafe.Pointer(uintptr(u.entityAddr)+meta.offset))
   return res.Interface(), nil
}
复制代码
复制代码

*(*T)(ptr)=newVal 写的例子:

func (u *UnsafeAccessor) SetField(field string, val int) error {
   fdMeta, ok := u.fields[field]
   if !ok {
      return errors.New("不存在字段")
   }
   // 计算地址了 获取字段地址 (字段地址) = 结构体起始地址 + 字段偏移量
   ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
   // 关键操作 
   // *(*int)(unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)) = val
   *(*int)(ptr) = val
   return nil
}

func (u *UnsafeAccessor) SetFieldAny(field string, val any) error {
   meta, ok := u.fields[field]
   if !ok {
      return errors.New("不存在字段")
   }
   // 计算地址了
   res := reflect.NewAt(meta.typ, unsafe.Pointer(uintptr(u.entityAddr)+meta.offset))
   if res.CanSet() {
      res.Set(reflect.ValueOf(val))
   }
   return nil
}
复制代码
复制代码

1.3 unsafe.Pointer 和 uintptr

前面我们使用了 unsafe.Pointer 和 uintptr,这两者都代表指针,那么有什么区别?

  • unsafe.Pointer:是 Go 层面的指针,GC 会维护 unsafe.Pointer 的值
  • uintptr:直接就是一个数字,代表的是一个内存地址(指针的存放地址)
  • unsafe.Pointer(s)将s指针类型转化为unsafe.Pointer非类型安全指针类型,uintptr获取值(该值为s指针存放地址值,即s指向的初始值)。

type FieldMeta struct {
   typ reflect.Type
   // offset 后期在我们考虑组合,或者复杂类型字段的时候,它的含义衍生为表达相当于最外层的结构体的偏移量
   offset uintptr
}

type UnsafeAccessor struct {
   fields     map[string]FieldMeta //  主要是字段偏移量
   entityAddr unsafe.Pointer       //  结构体的起始地址
}
复制代码
复制代码

1.4 unsafe.Pointer 和 GC

假设说 GC 前一个 unsafe.Pointer 代表 对象的指针,它此时指向的地址是 0xAAAA。

如果发生了 GC, GC 之后这个对象依 旧存活,但是此时这个对象被复制过去 了另外一个位置(Go GC 算法是标记- 复制)。那么此时代表对象的 unsafe.Pointer 会被 GC 修正,指向新 的地址 0xAABB

1.5 uintptr 使用误区

如果使用 uintptr 来保存对象的起始地 址,那么如果发生 GC 了,原本的代码会 直接崩溃。

type UnsafeAccessor struct {
   fields     map[string]FieldMeta //  主要是字段偏移量
   entityAddr unsafe.Pointer       //  结构体的起始地址
}


type UnsafeAccessorV1 struct {
   fields     map[string]FieldMeta
   entityAddr uintptr
}
复制代码
复制代码

例如在 GC 前,计算到的 entityAddr = 0xAAAA,那么 GC 后因为复制的原因, 实际上的地址变成了 0xAABB。因为 GC 不会维护 uintptr 类型变量(只维护 unsafe.Pointer 类型) ,所以 entityAddr 还是0xAAAA,这个时候再用 0xAAAA 作为起始地址去访问字段,就不 知道访问到什么东西了。

但是 uintptr 可以用于表达相对的量,例如字段偏移量。这个字段的偏移量是 不管怎么 GC 都不会变的。 如果怕出错,那么就只在进行地址运算的时候使用 uintptr,其它时候都用 unsafe.Pointer。

type FieldMeta struct {
   typ reflect.Type
   // offset 后期在我们考虑组合,或者复杂类型字段的时候,它的含义衍生为表达相当于最外层的结构体的偏移量
   offset uintptr
}
复制代码
复制代码

1.6 unsafe 面试要点

  • uintptr 和 unsafe.Pointer 的区别:前者代表的是一个具体的地址,后者代表的是一个逻辑上的指针。后者在 GC 等情况下,go runtime 会帮你调整,使其永远指向真实存放对象的地址。
  • Go 对象是怎么对齐的?按照字长。有些比较恶心的面试官可能要你手动演示如何对齐,或者写一个对象问你怎么计算对象的大小。
  • 怎么计算对象地址?对象的起始地址是通过反射来获取,对象内部字段的地址是通过起始地址 + 字段偏移量来计算。
  • unsafe 为什么比反射高效?可以简单认为反射帮我们封装了很多 unsafe 的操作,所以我们直接使用 unsafe 绕开了这种封装的开销。有点像是我们不用 ORM 框架,而是直接自己写 SQL 执行查询。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值