记录一些 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
上不保证兼容。(实际上绝大多多数还是兼容的)
- 是 unsafe 包的核心,类似 C 语言的
- 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 中的指针操作
- 要求:
- 转换前后的非安全指针必须指向同一个内存块
- 两次转换必须在同一条语句中,避免临时 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.Call
和syscall.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 会无法支持。 这是目前的局限,需要程序员自己遵守。 如果对性能要求没那么高,可以用锁来实现原子操作。