Go Unsafe

Circuit Breaker 记录一些 unsafe 包相关内容。(图片与 unsafe 无关,只是可爱: )

概述

Go 的 unsafe 包只提供了两个类型(Pointer、ArbitraryType)和三个函数(Alignof、Offsetof、Sizeof),但是却提供了非常强大的功能。 而对其的使用就如包名unsafe一样,会造成系统级错误,而且偶现并难以调查,所以要把底层原理彻底搞懂再使用。

基本概念

  • unsafe.Pointer
    • 是 unsafe 包的核心,类似 C 语言的void*类型,可以由任何类型的指针转换而来,也可以转换为任何类型的指针。
    • Go 语言是内存安全语言,为了保证安全 Go Runtime 做了一些牺牲(比如字符串拷贝),可以利用unsafe.Pointer在特定场景下修复这种性能的牺牲
    • 使用了unsafe.Pointer的代码不受 Go1 兼容性保证,也就是1.13写的代码在1.14上不保证兼容。(实际上绝大多多数还是兼容的)
  • uintptr
    • uintptr 是整数类型,与操作系统的位数保持一致,用来表示一个内存地址
    • unsafe.Pointer 可以转换为 uintptr,uintptr 可以转换为 unsafe.Pointer
    • uintptr 就是用于指针偏移操作,不可以转换成非 unsafe.Pointer 的其他类型
  • ArbitraryType
    • 作为参数类型,表示任意类型

使用原则

  • Go 的内存对象必须要有有效指针指向,否则随时可能被垃圾回收。 这里的指针必须是类型安全指针(普通指针)或者是非类型安全指针(unsafe.Pointer)。 uintptr 中存储内存地址整数值,uintptr 对象本身不会被 Go 识别为有效的指针,所以其保存的整数值对应的指针所指向的对象随时可能被回收。
  • 任何指针都不应该引用未知内存块,否则可能引发各种未知的系统问题,类似 C++野指针引发的各种问题

注意点

  • 某些对象(值)的地址在程序运行中可能改变
    • 例如栈上的对象,如果发生栈扩容/栈缩容,则对象会被拷贝到新空间,地址改变。Go 的类型安全指针具有自动更新的特性,不会丢失地址
    • 所以在利用 uintptr 时,要尽可能快的转换成 unsafe.Pointer 这种有效指针,以保证地址变更后仍然能被引用到
  • 一个对象(值)的生命范围可能小于代码显示的范围
    • 例如a := 1; b := a里面的 a 会被编译器直接优化掉,最终结果是b := 1
    • 所以如果把局部变量地址转换为 uintptr 会比较危险,对象可能在转换为 unsafe.Pointer 之前就被回收掉
  • *unsafe.Pointer是类型安全的指针类型,其基类是unsafe.Pointer
    • 这个特性可用于 atomic 指针操作
  • 可以利用func KeepAlive(x interface{})延长对象的生命周期,以便中间做 uintptr 运算
    • 这里涉及一个讨论:对象的部分子对象被有效指针引用,那父对象如果不再被引用是否会被回收?
// atomic 指针操作示例
func main() {
    type T struct {x int}
    var p *T
    var unsafePPT = (*unsafe.Pointer)(unsafe.Pointer(&p))
    atomic.LoadPointer(unsafePPT, unsafe.Pointer(&T{123}))
    fmt.Println(p)  // &{123}
}

应用场景示例

模式 1: 将 *T1 换为 unsafe.Pointer 然后再转换为 *T2

  • 要求unsafe.Sizeof(T1) >= unsafe.Sizeof(T2),避免转换后的对象越界
// package math
func Float64bits(f float64) uint64 {
    return *(*uint64)(unsafe.Pointer(&f))
}

模式 2: 将 unsafe.Pointer 转换为 uintptr 然后对 uintptr 做数学运算再转回 unsafe.Pointer

这种用法类似 C 中的指针操作

  • 要求:
    1. 转换前后的非安全指针必须指向同一个内存块
    2. 两次转换必须在同一条语句中,避免临时 uintptr 变量可能导致对象被回收
// 示例
type T struct {x bool; y [3]int16}
const N = unsafe.Offsetof(T{}.y)  // 取得 y 起始位置相对于 T 起始位置的偏移量,单位字节
const M = unsafe.Sizeof(T{}.y[0]) // 取得 int16 占的字节数
func main() {
  t := T{y: [3]int16{123, 456, 789}}
  p := unsafe.Pointer(&t)
  ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M*2))
  fmt.Println(*ty2)
}

模式 3: 利用 unsafe.Pointer 保护 reflect.Value.Pointer 或者 reflet.Value.UnsafeAddr 方法的 uintptr 返回值

有时一些临时的 uintptr 需要保存,以便未来利用,这时可以用 unsafe.Pointer 将其转换为不安全指针(合法指针)以避免对象被回收

注意这里要充分利用条件转换必须在同一条语句中

模式 4: reflect.SliceHeader/reflect.StringHeader 的 Data 字段和 unsafe.Pointer 之间相互转换

使用原则:不要凭空生成 SliceHeader 或 StringHeader,用 Slice/string 转换出它们

模式 5: syscall.Syscall 函数直接以 uintptr 做参数

syscall.Syscall函数具有特权,编译器会对其做特殊处理,以保证指针地址对象不被释放

  • 此模式也适用于 Windows 系统的syscall.Proc.Callsyscall.LazyProc.Call

string 和 []byte 转换

string 和 []byte 底层结构非常相近,只相差一个 cap 字段, 但是在类型转换时会发生完整的复制,导致性能很差(100 次转换要 1 秒时间)

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

type string struct {
    array unsafe.Pointer // 元素指针
    len   int // 长度
}

转换函数实现:

// 有隐患的形式:
func Str2Bytes(s string) []byte {
	x := (*[2]uintptr)(unsafe.Pointer(&s))
	b := [3]uintptr{x[0], x[1], x[1]}
	return *(*[]byte)(unsafe.Pointer(&b))
}

// 模式 1
func Bytes2Str(b []byte) string {
	return *(*string)(unsafe.Pointer(&b))
}
  • 隐患:

    • 由于引用了相同底层数组,所以源 string 的字符值可能被修改
    • 本函数是基于s形参分配在栈上(实际上确实在栈上),否则b := [3]uintptr{x[0], x[1], x[1]}以及之后是不安全的。 所以这种用局部变量保存指针值的方式一定要小心
// 更安全的实现形式:
type StringEX struct {
  string
  cap int
}
func Str2Bytes(s string) []byte {
  se := StringEX{string: s, cap: len(s)}
  return *(*[]byte)(unsafe.Pointer(&se))
}
  • 上面的转换过程没有临时变量,更安全

  • 优化后,转换速度 Benchmark 提高 1e7 倍

内存对齐

内存对齐的原因

先要搞清下面的几个概念:

  • 字节 8 位组成一个字节;
  • 字符 根据编码格式、字符集,一个字符可能占不同容量;
  • 字长 CPU 的处理单位、数据总线的位数,32 位是 4 字节、64 位是 8 字节;

对于CPU来说,时钟周期是最重要的指标之一,每个时钟周期只能处理一个字长的数据,符合它字长的整数倍的数据处理速度会最优, 所以当数据字长不足字长整数倍时,编译器会自动根据目标操作系统的位数进行内存对齐优化。 如果代码中存在过多 padding 的情况,就会浪费时钟周期,不过通常效率不会低太多,手动优化后可能提高 30%。

type TestClass struct {
    a byte
    b int32
    c byte
}
fmt.Println(unsafe.Sizeof(TestClass{}))

上面示例对象TestClass{}在 32 位系统下的容量应该是 1 + 4 + 1 = 6 字节,但实际输出是 12 = 1 + 3(padding) + 4 + 1 + 3(padding)

自动对齐策略

  • 每个大于等于字长的字段,都要从一个整字长开始,前面如果不整,则需要 padding 对齐
  • 结构体如果最后没有对齐,则末尾 padding 对齐
  • 对于空结构体,如果在另一个结构体内靠前的字段,不会影响结果,也比较省容量;如果放在最后,则需要至少占 1 字节然后再对齐,原因是结尾往往用来识别对象边界,不允许 0 字节的字段。这点和 C++空对象一样,C++空对象也要占 1 字节。
  • 为什么不能直接在编译时自动调整顺序优化对齐? 因为很多结构体操作要基于字段顺序,所以不能随意变动

优化方式

  • 将 struct 中的成员从大到小排列,这样不容易出现需要 padding 的情况
  • 可以用下面方式手动填充对齐(32 位系统)
type TestClass struct {
    a byte
    _ [3]byte
    c int32
}

常用工具

对齐显示工具

用 SVG 矢量图显示对象结构,包括 padding 等结构

优化工具

其他

  • 在 32bit 平台下进行 64bit 原子操作要求必须 8 字节对齐,否则程序会 panic 在 32 位系统下,int64 本质上就是两个 int32 合在一起实现的,如果不进行 64 位对齐,原子操作 CPU 会无法支持。 这是目前的局限,需要程序员自己遵守。 如果对性能要求没那么高,可以用锁来实现原子操作。