Go语言专题: unsafe包初探

267 阅读6分钟

Go语言专题: unsafe包初探

Package unsafe contains operations that step around the type safety of Go programs. -- golang.org

unsafe包 包含了绕过Go类型安全的一些操作, 一般情况下不推荐用, 除非是知道具体的原理, 并且知道是在做什么才可以用

事实上, unsafe包的功能非常强大, 主要包含3个函数和通用类型ArbitraryType以及通用指针Pointer

 - func Alignof(x ArbitraryType) uintptr
 - func Offsetof(x ArbitraryType) uintptr
 - func Sizeof(x ArbitraryType) uintptr
 - type ArbitraryType
 - type Pointer

Align和Sizeof

Align指的是struct中field的对齐padding补位, 在struct中, 不同类型的字段field占据不用的空间, 比如说bool占1个字节, int32占4个字节, float64占8个字节等

但是不同的排列方式会导致struct大小不一致, 如何节省内存才能保证高效的程序设计, 举个例子:

// good source: https://medium.com/@felipedutratine/how-to-organize-
// the-go-struct-in-order-to-save-memory-c78afcf59ec2

// 我们有两个结构体PingClient和PingClientOptimize用来表示发ping包的struct
// 字段和类型是一样的, 区别只是排列顺序不一样, 但是我们打印发现, 这两个struct
// size大小竟然相差8个字节(最大的struct才24个字节)
type PingClient struct {
	continuous bool   // true表示持续的ping 1 byte
	timeout    uint32 // 超时时间 4 bytes
	recordRtts bool   // 是否记录rtt 1 byte
	sequence   int64  // seq 8 bytes
	// ...
}

// ping client 以下是PingClient为什么是24bytes, 0表示padding补齐
// 因为uint32是4字节, 所以它的位置要以4的倍数开始, 同理第二行是recordRtts只占1个字节
// 但是sequence占8个字节, 所以要以8的倍数开始, 导致浪费大量的padding补齐来align
// 1 0 0 0 1 1 1 1
// 1 0 0 0 0 0 0 0
// 1 1 1 1 1 1 1 1
//
type PingClientOptimize struct {
	sequence   int64  // seq
	timeout    uint32 // 超时时间
	continuous bool   // true表示持续的ping
	recordRtts bool   // 是否记录rtt
	// ...
}

// 1 + 4 + 1 + 8 = 14 bytes

func main() {
	p := PingClient{true, 0, true, 0}
	fmt.Println(unsafe.Sizeof(p)) // 24 bytes
	po := PingClientOptimize{0, 0, true, true}
	fmt.Println(unsafe.Sizeof(po)) // 16 bytes
}

这是为什么呢? 原因是CPU在获取内存时是通过word获取的, 一般一个word是2个字节, doubleword是4个字节, 但是CPU由于一些原因并不会按字来对齐这些data(当然之后可能还有cache line优化)

这个padding机制在c中也有, 写过c和数据结构的应该清楚, 所以写struct时最好还是对它优化一下 Wiki-Data_structure_alignment

Sizeof记录了struct或者是一些数据类型的大小, 上面已经有用例了

接下来介绍Pointer和Offsetof

unsafe包中提供了一个类型叫ArbitraryType, 字面含义就是任意的类型, Pointer表示一个指针来指向这个任意类型
关于Pointer的四句真言:

  1. 任何类型的指针都可以转化为Pointer
  2. Pointer可以转化为任何类型的指针
  3. uintptr可以转化为Pointer
  4. Pointer可以转化为uintptr

也就是说Pointer可以理解为万能指针, 可以指向任何类型,而不像*int或者是*string一样, 那么Pointer的转化如下所示:

// 其中传入参数s的地址, 如果s是指针, 则无需&
unsafe.Pointer(&s)

将指针转化为Pointer后, 我们可以将Pointer再转化为uintptr, 同时可以进行相关的计算, 如直接修改struct内部变量的值(通过内存操作)等,参见以下代码:

// 根据unsafe.Pointer修改结构体内部变量的值
func changeValueByPointer() {
    // 假如有一个student的struct, 我们要修改它的age
	type student struct {
		name string
		age  int
	}

	stua := &student{"xiao ming", 21}
	fmt.Println(stua)

	fmt.Println(unsafe.Sizeof(stua.name))
	fmt.Println(unsafe.Sizeof(stua.age))
	fmt.Println(unsafe.Offsetof(stua.age))

	// unsafe.Pointer(&stua) 内部取地址, 如果是&初始化, 则不需要加取地址
    // 其中1, 2, 3的操作是等价的, 选其一就好, 推荐用1, 2
    1. p := (*int)(unsafe.Pointer(&stua.age))
	2. p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(stua)) + unsafe.Offsetof(stua.age)))
    // 在不存在padding补齐的情况下可以用3
    3. p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(stua)) + unsafe.Sizeof(stua.name)))
	*p = 24
	fmt.Println(stua)
}

我们发现可以用uintptr和unsafe.Pointer来修改内存(篡改[Doge])的于是就引入了以下话题:

Go中string和[]byte的高效转化

注意高效两字, 首先我们来看正常转化:

b := string(s)
_ = []byte(b)

但是这种方法牵涉到了底层数组的分配, 不是很高效, 可以通过gdb调试打印地址来看或者是通过benchmark

var s, _ = ioutil.ReadFile("some.data")

func test() {
	b := string(s)
	_ = []byte(b)
}

func BenchmarkTest(b *testing.B) {
	t1 := time.Now()
	for i := 0; i < b.N; i++ {
		test()
	}
	fmt.Println("test", time.Now().Sub(t1), b.N)
}
// 运行结果如下, 我们可以看到产生了内存的分配malloc:
go test -v -bench . -benchmem
goos: darwin
goarch: amd64
BenchmarkTest
test 17.54µs 1
test 457.039µs 100
test 24.849315ms 10000
test 723.477223ms 482178
test 1.169422963s 799707
BenchmarkTest-4           799707              1462 ns/op            5376 B/op          2 allocs/op

通过Pointer和uintptr我们可以更高效的进行[]byte和string的转换

string to []byte

  • 由于string内部是一个指向底层数组的指针和一个len表示长度
  • []byte的内部是一个指向底层数组的指针和一个len表示长度以及cap表示容量
  • 所以string转[]byte, 需要x[0]提取出底层数组的指针, x[1]提取出len, 然后还用x[1]表示cap来构造*[]byte
func strToBytes(s string) []byte {
	x := (*[2]uintptr)(unsafe.Pointer(&s))
	h := [3]uintptr{x[0], x[1], x[1]}
	return *(*[]byte)(unsafe.Pointer(&h))
}

[]byte to string

  • 可以直接转换(通过unsafe.Pointer作为中间指针互转), 因为前两个uintptr代表都一样
func bytesToStr(b []byte) string {
	return *(*string)(unsafe.Pointer(&b))
}

benchmark对比:

// 参考https://www.cnblogs.com/shuiyuejiangnan/p/9707066.html 运行
var s, _ = ioutil.ReadFile("some.data")

func test() {
	b := string(s)
	_ = []byte(b)
}

// 高效转化
func test2() {
	b := bytesToStr(s)
	_ = strToBytes(b)
}

func BenchmarkTest(b *testing.B) {
	t1 := time.Now()
	for i := 0; i < b.N; i++ {
		test()
	}
	fmt.Println("normal", time.Now().Sub(t1), b.N)
}

func BenchmarkTest2(b *testing.B) {
	t1 := time.Now()
	for i := 0; i < b.N; i++ {
		test2()
	}
	fmt.Println("efficient", time.Now().Sub(t1), b.N)
}

得到结果:

goos: darwin
goarch: amd64
BenchmarkTest
normal 18.852µs 1
normal 396.516µs 100
normal 21.258157ms 10000
normal 784.976741ms 563280
normal 1.226263408s 861057
BenchmarkTest-4           861057              1425 ns/op            5376 B/op          2 allocs/op
BenchmarkTest2
efficient 183ns 1
efficient 336ns 100
efficient 5.115µs 10000
efficient 417.084µs 1000000
efficient 42.96153ms 100000000
efficient 419.608335ms 1000000000
BenchmarkTest2-4        1000000000               0.420 ns/op           0 B/op          0 allocs/op

通过unsafe包的Pointer这种作为中间指针来进行string和[]byte之间的转化, 我们发现效率高了n多层, 而且不涉及到内存的分配, 墙裂推荐

PS

虽然unsafe.Pointer和uintptr这么好, 但用的时候还是要注意, 尤其是不要用uintptr作为中间变量, 可能会产生移动GC的问题, 可以参考www.cnblogs.com/sunsky303/p…

本文章首发于scientiacoder io, 更多后端(操作系统,数据库,中间件等)深度知识, golang原理等请访问https scientiacoder io