Go猜想录
大道至简,悟者天成
unsafe 包使用指南

对象内存布局

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

需要掌握:

  • 计算地址
  • 计算偏移量
  • 直接操作内存
// 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)
	}
}

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{})
}
=== RUN   TestPrintFieldOffset
64
Name: 0
age: 16
Alias: 24
Address: 48

64
Name: 0
age: 16
agev1: 20
Alias: 24
Address: 48

64
Name: 0
Alias: 16
Address: 40
age: 56
--- PASS: TestPrintFieldOffset (0.00s)
PASS
type User struct {
	Name    string
	age     int32
	Alias   []byte
	Address string
}

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

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

64 是结构体的总大小。

问题在于,为什么 Alias 的偏移量是 24,按照道理来说应该是 20?

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

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

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

unsafe-1.png

代码演示 —— 使用 unsafe 来读写字段

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

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

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

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

type FieldAccessor interface {
	Field(field string) (int, error)
	SetField(field string, val int) error
}

type UnsafeAccessor struct {
	fields     map[string]FieldMeta
	entityAddr unsafe.Pointer
}

func NewUnsafeAccessor(entity interface{}) (*UnsafeAccessor, error) {
	if entity == nil {
		return nil, errors.New("invalid entity")
	}
	val := reflect.ValueOf(entity)
	typ := reflect.TypeOf(entity)
	if typ.Kind() != reflect.Pointer || typ.Elem().Kind() != reflect.Struct {
		return nil, errors.New("invalid entity")
	}
	fields := make(map[string]FieldMeta, typ.Elem().NumField())
	elemType := typ.Elem()
	for i := 0; i < elemType.NumField(); i++ {
		fd := elemType.Field(i)
		fields[fd.Name] = FieldMeta{offset: fd.Offset, typ: fd.Type}
	}
	return &UnsafeAccessor{entityAddr: val.UnsafePointer(), fields: fields}, nil
}

func (u *UnsafeAccessor) Field(field string) (int, error) {
	fdMeta, ok := u.fields[field]
	if !ok {
		return 0, fmt.Errorf("invalid field %s", field)
	}
	ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
	if ptr == nil {
		return 0, fmt.Errorf("invalid address of the field: %s", field)
	}
	res := *(*int)(ptr)
	return res, nil
}

func (u *UnsafeAccessor) FieldAny(field string) (any, error) {
	fdMeta, ok := u.fields[field]
	if !ok {
		return 0, fmt.Errorf("invalid field %s", field)
	}
	ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
	if ptr == nil {
		return 0, fmt.Errorf("invalid address of the field: %s", field)
	}
	refVal := reflect.NewAt(fdMeta.typ, ptr).Elem()
	return refVal.Interface(), nil
}

func (u *UnsafeAccessor) SetField(field string, val int) error {
	fdMeta, ok := u.fields[field]
	if !ok {
		return fmt.Errorf("invalid field %s", field)
	}
	ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
	if ptr == nil {
		return fmt.Errorf("invalid address of the field: %s", field)
	}
	*(*int)(ptr) = val
	return nil
}

func (u *UnsafeAccessor) SetFieldAny(field string, val any) error {
	fdMeta, ok := u.fields[field]
	if !ok {
		return fmt.Errorf("invalid field %s", field)
	}
	ptr := unsafe.Pointer(uintptr(u.entityAddr) + fdMeta.offset)
	if ptr == nil {
		return fmt.Errorf("invalid address of the field: %s", field)
	}
	refVal := reflect.NewAt(fdMeta.typ, ptr).Elem()
	if !refVal.CanSet() {
		return fmt.Errorf("field can't be set: %s", field)
	}
	refVal.Set(reflect.ValueOf(val))
	return nil
}

type FieldMeta struct {
	// offset 后期在我们考虑组合,或者复杂类型字段的时候,它的含义衍生为表达相当于最外层的结构体的偏移量
	offset uintptr
	typ    reflect.Type
}
func TestUnsafeAccessor_Field(t *testing.T) {
	testCases := []struct {
		name    string
		entity  interface{}
		field   string
		wantVal int
		wantErr error
	}{
		{
			name:    "normal case",
			entity:  &User{Age: 18},
			field:   "Age",
			wantVal: 18,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			accessor, err := NewUnsafeAccessor(tc.entity)
			if err != nil {
				assert.Equal(t, tc.wantErr, err)
				return
			}
			val, err := accessor.FieldAny(tc.field)
			assert.Equal(t, tc.wantErr, err)
			if err != nil {
				return
			}
			assert.Equal(t, tc.wantVal, val)
		})
	}
}

func TestUnsafeAccessor_SetField(t *testing.T) {
	testCases := []struct {
		name    string
		entity  *User
		field   string
		newVal  int
		wantErr error
	}{
		{
			name:   "normal case",
			entity: &User{},
			field:  "Age",
			newVal: 18,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			accessor, err := NewUnsafeAccessor(tc.entity)
			if err != nil {
				assert.Equal(t, tc.wantErr, err)
				return
			}
			err = accessor.SetField(tc.field, tc.newVal)
			assert.Equal(t, tc.wantErr, err)
			if err != nil {
				return
			}
			assert.Equal(t, tc.newVal, tc.entity.Age)
		})
	}
}

type User struct {
	Age int
}

unsafe.Pointer 和 uintptr

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

  • unsafe.Pointer:是 Go 层面的指针,GC 会维护 unsafe.Pointer 的值
  • uintptr:直接就是一个数字,代表的是一个内存地址
type UnsafeAccessor struct {
	fields     map[string]FieldMeta
	entityAddr unsafe.Pointer
}
type FieldMeta struct {
	offset uintptr
}

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

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

unsafe-2.png

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

例如在 GC 前,计算到的 entityAddr = 0xAAAA,那么 GC 后因为复制的原因,实际上的地址变成了 0xAABB。

因为 GC 不会维护 uintptr 变量,所以 entityAddr 还是 0xAAAA,这个时候再用 0xAAAA 作为起始地址去访问字段,就不知道访问到什么东西了。

但是 uintptr 可以用于表达相对的量。

例如字段偏移量。这个字段的偏移量是不管怎么 GC 都不会变的。

如果怕出错,那么就只在进行地址运算的时候使用 uintptr,其它时候都用 unsafe.Pointer。

总结

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

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。