什么是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 执行查询。