Go 语言
标准库、反射和 cgo
unsafe 的用法

unsafe 的用法

Go 在常规操作下是类型安全的。所谓类型安全是指一块内存地址数据一旦被特定的类型所解析,它就不能解析为其他类型,不能再与其他类型建立关联。

Go 语言的类型安全建立在 Go 编译器的静态检查以及 Go 运行时利用类型信息进行的运行时检查之上的。在语法层面做了诸多限制:

  • 不支持隐式类型转换,所有类型必须显式进行
  • 不支持指针运算

在考虑类型安全的同时,为了兼顾性能以及如何实现与操作系统、C 代码等互操作的低级代码等问题。最终 Go 语言选择在类型系统上开一道后门,即标准库内置一个特殊的 unsafe 包。

使用 unsafe 包我们可以实现性能更高,与底层系统交互更容易的低级代码,但 unsafe 包的存在也让我们有了绕过 Go 类型安全的屏障的路径。 一旦使用不当,便可能会导致引入安全漏洞、引发程序崩溃等问题。

unsafe 包之所以被称为 unsafe,主要是因为该包定义了 unsafe.Pointer 类型。unsafe.Pointer 可用于表示任意类型的指针,并且它具备下面四条其他指针类型所不具备的特性:

  • 任意类型的指针值可以被转换为 unsafe.Pointer
  • unsafe.Pointer 可以被转换为任意类型的指针值
  • uintptr 可以被转换为 unsafe.Pointer
  • unsafe.Pointer 可以被转换为 uintptr

有了 unsafe.Pointer 的四个特性,我们发现通过 unsafe.Pointer,可以很容易穿透 Go 的类型安全保护:

func main() {
    var a uint32 = 0x12345678
    fmt.Printf("%x\n", a) // 12345678
 
    p := (unsafe.Pointer)(&a)
 
    b := (*[4]byte)(p)
    b[0] = 0x23
    b[1] = 0x45
    b[2] = 0x67
    b[3] = 0x8a
 
    fmt.Printf("%x\n", a) // 8a674523
}

unsafe 包的API

Go 标准库中的 unsafe 包非常简洁,下面几行代码就是 unsafe 包的全部内容:

func Alignof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Sizeof(x ArbitraryType) uintptr
type ArbitraryType int
type Pointer *ArbitraryType

虽然位于 unsafe 包,但 AlignofOffsetofSizeof 这三个函数的使用是绝对安全的。 这三个函数两个共同点:1、接收的参数是一个表达式,而不是一个类型,ArbitraryType 表示任意表达式的类型;2、返回值都是 uintptr 类型。 采用 uintptr 作为返回值类型可以减少指针运算表达式中的显式类型转换。

Sizeof

Sizeof 函数返回操作数在内存中的字节大小。操作数可以是任意类型的表达式,但是它并不会对操作数进行求值。

type Foo struct {
    a int
    b string
    c [10]byte
    d float64
}
 
var i int = 5
var a = [100]int{}
var s1 = a[:]
var f Foo
 
println(unsafe.Sizeof(i)) // 8
println(unsafe.Sizeof(a)) // 800
println(unsafe.Sizeof(s1)) // 24 返回的是切片描述符的大小
println(unsafe.Sizeof(f)) // 48
println(unsafe.Sizeof(f.c)) // 10
println(unsafe.Sizeof((*int)(nil))) // 8

Sizeof 函数不支持直接传入无类型信息的 nil 值,必须显式告知 Sizeof 函数 nil 的类型信息,就像上面的代码那样。

Alignof

Alignof 函数用于获取一个表达式的内存地址对齐系数。对于一个类型为 T 的表达式 x,Alignof(x) 的结果是 T 类型的值在内存中的对齐系数。

对齐系数是一个计算机体系架构层面的术语。不同的计算机体系结构的处理器对变量地址都有着对齐要求,即变量的地址必须可被该变量的对齐系数整除。

var x unsafe.ArbitraryType
b := uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0
println(b) // true

我们来看一下 unsafe.Alignof 的使用例子:

type Foo struct {
    a int
    b string
    c [10]byte
    d float64
}
 
var i int = 5
var a = [100]int{}
var s1 = a[:]
var f Foo
 
println(unsafe.Alignof(i)) // 8
println(unsafe.Alignof(a)) // 8
println(unsafe.Alignof(s1)) // 8
println(unsafe.Alignof(f)) // 8
println(unsafe.Alignof(f.a)) // 8
println(unsafe.Alignof(f.c)) // 1
println(unsafe.Alignof(structP{}{})) // 1
println(unsafe.Alignof([0]int{})) // 8

Offsetof

Offsetof 函数用于获取一个结构体成员在结构体中的偏移量。对于一个结构体类型为 T 的表达式 x 和一个字段 f,Offsetof(T.f) 的结果是 f 字段相对于结构体 T 起始地址的偏移量。

Offsetof 应用面较窄,仅用于求结构体中某字段的偏移值。

println(unsafe.Offsetof(f.a)) // 0
println(unsafe.Offsetof(f.b)) // 8
println(unsafe.Offsetof(f.c)) // 24
println(unsafe.Offsetof(f.d)) // 40

unsafe 包典型应用

unsafe 被广泛应用于 Go 标准库和 Go 运行时的实现当中,reflect 包、sync 包、syscall 包、runtime 包都是 unsafe 包的重度用户。 在开源项目中,Gopher 对 unsafe 包也是青睐有加。Go bingding 项目、网络领域项目和数据库领域项目是 unsafe 的重度用户。 其中 unsafe.Pointer 则是 unsafe 包中的被使用最多的特性,占据9成以上份额。

unsafe 包这些项目中主要被用于如下两个场景:

1、与操作系统以及非 Go 编写的代码的通信

与操作系统的通信主要通过系统调用进行,而与非 Go 编写的代码的通信主要通过 cgo 方式:

func SetIcon(iconBytes []byte) {
    cstr := (*C.char)(unsafe.Pointer(&iconBytes[0]))
    C.SetIcon(cstr, C.int(len(iconBytes)))
}

2、高效类型转换

使用 unsafe 包可以实现高效的类型转换,最常见的类型转换是 string 与 []byte 类型间的相互转换:

func BytesToString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}
 
func StringToBytes(s string) []byte {
    sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    bh := reflect.SliceHeader{
        Data: sh.Data,
        Len:  sh.Len,
        Cap:  sh.Len,
    }
    return *(*[]byte)(unsafe.Pointer(&bh))
}

通过上面的基于 unsafe 包实现的 StringToBytes 函数,这种转换不需要额外的内存复制。 转换后的 []byte 类型的切片与原 string 类型的字符串共享底层的内存,而 []byte 变量转 string 类型则更为简单, 通过 unsafe.Pointer []byte 的内部表示重新解释为 string 的内部表示,这就是 BytesToString 的原理

此外 unsafe 在自定义高性能序列化函数(marshal)、原子操作(atomic)及内存操作(指针运算)上都有一定程度的应用。

正确理解 Pointer 与 uintptr

Go 语言内存管理是基于垃圾回收的,垃圾回收例程会定期执行。如果一块内存没有被任何对象引用,它就会被垃圾回收器回收,而对象引用是通过指针来实现的。

unsafe.Pointer 和其他常规类型指针一样,可以作为对象引用。如果一个对象仍然被某个 unsafe.Pointer 引用,那么这个对象就不会被垃圾回收器回收。 但 uintptr 并不是指针,它仅仅是一个整型值,即便它存储的是某个对象的内存地址,它也不会被算作对该对象的引用。如果认为将对象地址存储在一个 uintptr 变量中, 该变量就不会被垃圾回收器回收,那就是对 uintptr 的误解。

使用 uintptr 类型变量保存栈上的变量的地址同样有风险的,因为 Go 使用的是连续栈的栈管理方案,每个 goroutine 的默认栈大小是 2KB(_StackMin = 2048)。 当 goroutine 的栈空间不足时,Go 会自动扩展栈空间,运行时会新分配一块更大的内存空间作为新的栈空间,然后将旧栈空间的内容复制到新的栈空间中。这样原栈上分配的变量地址就会发生变化。