《GO语言圣经》读书笔记(十二):底层编程

文章目录

  • ch13:底层编程
    • 1.unsafe.Sizeof,Alignof和Offsetof
    • 2.unsafe.Pointer
    • 3.应用
      • 1. string和slice之间的相互转换
        • 踩坑指南
      • 2.最佳实践举例:fasthttp
    • 4.小结

ch13:底层编程

1.unsafe.Sizeof,Alignof和Offsetof

unsafe.Sizeof():返回操作数在内存中的字节大小

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

​ 计算机在加载和保存数据的时候,如果内存地址合理的对齐会比较有效率。为什么这么说呢?因为操作系统的CPU是按照2/4/8这样的字长来访问的,32-bit的系统访问的粒度是4字节,64-bit的系统访问粒度是8字节。

​ 如果我们需要访问一个长度为n字节而且地址为n字节对齐的数据,那么操作系统就可以一步到位锁定这个数据,所以我们说内存地址合理的对齐会比较高效,这就省去了多次读取和处理对齐运算的操作。

​ 理解了为什么要对齐,那么就不难理解为什么会存在内存空洞这个概念了:编译器会自动添加一些没有被使用的内存空间,这些内存就是内存空洞,他们可以保证元素或者字段的地址相对于数组/结构体的起始地址能够对齐。

​ 对于一些结构体和数组来说,他们的大小至少是所有字段或者元素大小的总和,为啥强调至少呢?因为可能会存在内存空洞。

​ 虽然没有要求字段的声明顺序和内存中的顺序一致,但是理论上一个编译器可以随意重新排列每个字段的内存位置,通过程序打印出的结果来看:现在编译器还没有实现重排的功能,内存中的顺序和字段的声明的顺序一致。

type struct1 struct {
     
	args1 bool
	args2 float64
	args3 int16
}
type struct2 struct {
     
	args1 float64
	args2 int16
	args3 bool
}
type struct3 struct {
     
	args1 bool
	args2 int16
	args3 float64
}

func main() {
     
	var t1 struct1
	var t2 struct2
	var t3 struct3
	fmt.Println(unsafe.Sizeof(t1))
	fmt.Println(unsafe.Sizeof(t2))
	fmt.Println(unsafe.Sizeof(t3))
}

控制台打印结果(64-bit):

24
16
16

​ 可以发现第一种写法比其他两种需要多50%的内存。这是为什么呢?揭晓谜底之前,要介绍案例中的结构体中出现的几种类型各自需要多少内存(书上对于每种类型对应多少内存进行了详细的描述,这里不照搬了):

  • bool类型的大小为1个字节,int16float64分别是2字节和8字节。

​ 基于自己目前的理解,画了下三种情况的内存布局(64-bit),内存地址对齐算法并不是这里阐述的重点,所以实际内存布局细节可能和我画的有些差异。其中灰色的代表内存空洞,自上而下分别对应上述代码的三种情况。

​ 之前我们提到过,64-bit的机器字长是8字节,也就是说访问粒度为8字节。float64所需要的大小正好是8字节,单独占一个机器字,所以bool/int16float64的类型无论如何都属于两个不同的机器字。

​ 那么第一种情况,bool类型单独占1字节,剩下的通过内存空洞补齐第一个机器字,又因为float64单独占满了一个机器字,所以int16在第三个机器字上,剩下的空间通过内存空洞补齐。最终需要三个机器字。

​ 第二种情况和第三种情况,boolint16一共占据一个机器字,空余的地方利用内存空洞补齐,float64单独占满一个机器字,不需要补齐,所以一共2个机器字。

《GO语言圣经》读书笔记(十二):底层编程_第1张图片

unsafe.Alignof():返回对应参数的类型需要对齐的倍数

​ 第二个要介绍的函数返回对应参数需要对齐的倍数。通常情况下,布尔和数字类型需要对齐到它们本身的大小(最多8个字节),其它的类型对齐到机器字大小 。

​ 此外,获取对齐值还可以使用反射包的函数,也就是说:unsafe.Alignof(x)等价于reflect.TypeOf(x).Align()

unsafe.Offsetof():返回对应参数的偏移量

Offsetof函数只适用于struct结构体中的字段相对于结构体的内存位置偏移量。结构体的第一个字段的偏移量都是0。根据字段的偏移量,甚至还可以读到一个私有字段。

​ 此外,unsafe.Offsetof(u1.i)等价于reflect.TypeOf(u1).Field(i).Offset

​ 大概介绍完了三个函数,下面结合例子实际分析下,这里仅以64-bit为例。

var x struct {
     
	a bool
	b int16
	c []int
}

​ 对结构体变量x的各个成员调用SizeOfAlignofOffsetof的计算结果如下所示:

//sizeof(x)包含了内存空洞的大小,x.a和x.b可以使用同一个机器字来表示,数组c需要占据3个机器字(data/len/cap),所以一个需要4个机器字,共32字节。
//x的对齐倍数和机器字大小相同
Sizeof(x) = 32 Alignof(x) = 8
Sizeof(x.a) = 1 Alignof(x.a) = 1 Offsetof(x.a) = 0
Sizeof(x.b) = 2 Alignof(x.b) = 2 Offsetof(x.b) = 2
Sizeof(x.c) = 24 Alignof(x.c) = 8 Offsetof(x.c) = 8

​ 因为从三个方面(len/data/cap)去描述切片,所以加起来一共需要三个机器字(64-bit是24字节),但对齐倍数是一个机器字,64-bit机器的对齐倍数就是8,如果是32-bit机器对齐倍数就是4(此时切片加起来需要3个机器字,一共12字节)。

​ 偏移量应该比较好理解了,这里不多写了。

​ 另外还想说的是,如果看书,可以发现64-bit的内存空洞图中,x.b填充对齐实际上应该是4字节,虽然Alignof(x.b)结果是2字节,除了用于自身对齐的2字节之外,剩下的2字节应该是为了补齐机器字的。

2.unsafe.Pointer

T类型指针和unsafe.Pointer指针可以相互转化,从unsafe.Pointer指针转回普通指针类型并不需要和原始的*T类型相同。

​ 下面的例子中,首先将一个普通类型指针(*int)转化为unsafe.Pointer类型,接着又将它转化为*float64类型,ifp的地址虽然不同,但是通过fp指针修改值这一操作,不仅变量fp的值改变了,变量i的值也变成了30。

func main() {
     
	i:= 10
	ip:=&i

	var fp *float64 = (*float64)(unsafe.Pointer(ip))
	
	*fp = *fp * 3

    fmt.Println(i)	//output:30
}

​ 如果去查看相关的源码,会发现其实它是一个*int类型的,但他却实现了*T之间的类型转换,其实不仅如此,unsafe.Pointer是个万能指针,怎么去理解这个万能呢?

  • 首先,所有指针都可以转化为unsafe.Pointer

  • 其次,礼尚往来,unsafe.Pointer必然可以转换为其他任何类型的指针

  • 最后,go内置类型uintprt(用于指针运算但不持有对象)和unsafe.Pointer(不可以做指针运算,单纯只能作为两个指针相互转化的桥梁)可以相互转化。

    //TO DO:关于uintprt目前还不是很了解,都以后有时间和unsafe.Pointer对比着总结下。

​ 通过上面的例子,我们已经领略了unsafe.Pointer是怎么作为中间桥梁,帮着两个不同的指针类型完成互相转化工作的。接着,再来尝试理解最后一条。

​ 我们都知道*T是不能计算偏移量的,也不能进行计算,但是uintptr可以(支持指针运算噢),所以我们可以把指针转为uintptr再进行偏移计算,这样我们就可以访问特定的内存了,达到对不同的内存读写的目的。

​ 下面我们以通过指针偏移修改Struct结构体内的字段为例,来演示uintptr的用法。

type person struct {
     
	name string
	age  int
}

func main() {
     
	p := new(person)
	fmt.Println(*p)
	
	pName := (*string)(unsafe.Pointer(p))
	*pName = "哭唧唧哇"

	pAge := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(p)) + unsafe.Offsetof(p.age)))
	*pAge = 20

	fmt.Println(*p)
}
/*
output:
{ 0}
{哭唧唧哇 20}
*/

​ 以上我们通过内存偏移的方式,定位到我们需要操作的字段,然后改变他们的值。

​ 第一个修改username值的时候,因为name是第一个字段,所以不用偏移,我们获取user的指针,然后通过unsafe.Pointer转为*string进行赋值操作即可。

​ 第二个修改userage值的时候,因为age不是第一个字段,所以需要内存偏移,又因为内存偏移牵涉到的计算只能通过uintptr,所以接下来需要把user的指针地址转为uintptr,最后再通过unsafe.Offsetof(u.age)获取需要偏移的值,进行地址运算(+)偏移即可。

​ 现在偏移后,地址已经是userage字段了,接下来进行赋值操作,首先将uintptr转为*int,这中间需要unsafe.Pointer来作为中间桥梁实现。

​ 上面的表达式很长,或许脑海中曾经闪过一个想法,引入一个uintptr临时变量,增强代码的易读性。但这里,要注意必须避免这种做法,否则就要喜提debug一次。下面就是错误做法:

temp:=uintptr(unsafe.Pointer(u))+unsafe.Offsetof(u.age)
pAge:=(*int)(unsafe.Pointer(temp))
*pAge = 20

​ 这三行代码看起来很正常,但实际上垃圾回收器可能会回收掉临时变量的空间,这一操作会导致最后一条语句给一个无效地址空间赋值,显然这是不对的。

3.应用

1. string和slice之间的相互转换

​ 实现字符串和字节切片之间的转换,应该是非常高频的一个操作了,我们之前是怎样实现的呢?

var str string = "test"
var data []byte = []byte(str)

​ 这种做法会遍历字符串或者字节切片,然后挨个赋值,可以通过gdb调试去验证(如果转化前后string[]byte地址不同,说明存在复制过程)。

​ 这里对于string[]byte类型转化为什么需要付出一定代价,进行一点探究。首先介绍下string和slice是如何实现的。

​ 和其他语言一样,go中字符串的值也是不能改变的,如果我们查看go源码中字符串的实现,可以发现string就是个结构体,结构体的成员有两个,首先是指针(指向某个byte数组的首地址),另外一个是len长度。

//src/runtime/string.go
type stringStruct struct {
     
    str unsafe.Pointer
    len int
}

​ 切片的定义如下,数组指针array,len表示长度,cap表示容量。

//src/runtime/slice.go
type slice struct {
     
	array unsafe.Pointer
	len   int
	cap   int
}

​ 前面我们说字符串的值是不能改变,但其实呢,它可以被替换。这话怎么讲?字符串的底层是一个结构体stringStruct,str指针指向的地址所对应的内容是不可以被改变的,但是这个指针可以指向其他的地址。

s := "A1"  // 分配存储"A1"的内存空间,s结构体里的str指针指向这块内存
s = "A2"   // 重新给"A2"的分配内存空间,s结构体里的str指针指向这块内存

​ 但是如果给[]byte类型重新赋值,就不一样了:

s := []byte{
     1} // 分配存储1数组的内存空间,s结构体的array指针指向这个数组。
s = []byte{
     2}  // 将array的内容改为2

​ 再来看本节开头var data []byte = []byte(str)的具体实现是怎样的:

//这里可以发现,变量b作为函数的返回值,实际上是新分配出来的,由s复制给b,这里存在一个copy过程
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
     
	var b []byte
	if buf != nil && len(s) <= len(buf) {
     
		*buf = tmpBuf{
     }
		b = buf[:len(s)]
	} else {
     
		b = rawbyteslice(len(s))
	}
    //这里实际上是把一个字符串赋值给了[]byte,至于为啥能这样可以看看该函数的具体实现,这里不延申了。
	copy(b, s)
	return b
}

​ 同样的,将[]byte转化为string,也是存在类似的过程,这里不重复赘述。

​ 正是因为如此,所以在gdb调试的时候我们会发现转化前后内存地址是不同的,而且当我们需要大量转换的时候,由于涉及复制的过程,效率也是极低的。

​ 那么这里是不是可以使用unsafe.Pointer万能指针来实现两个不同类型的相互转化?必然是可以的,而且这种方式是不涉及copy过程的,具体实现如下:

func BytesToString(b []byte) string {
     
	return *(*string)(unsafe.Pointer(&b))
}
func StringToBytes(s string) []byte {
     
	return *(*[]byte)(unsafe.Pointer(&s))
}

​ 通过benchmark测试多次,选取其中一次的测试结果对比:

go test -test.bench=.* -benchmem -run=none 
goos: windows
goarch: amd64
pkg: checkunsafe
BenchmarkCast-8         	 5000000	       281 ns/op	     448 B/op	       4 allocs/op
BenchmarkUnsafeCast-8   	200000000	         8.81 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	checkunsafe	4.996s

​ 类型强制转化和使用unsafe优化后的性能差异非常大,比如说使用前者的方法执行1000次转化耗时131.816µs,使用unsafe包的话只需要872ns(1µs=1000ns),当测试的字符串越来越长的时候,可以发现执行时间差距越来越大。

PS:关于zero-copy的验证,也可以通过gdb调试查看类型转化前后,地址是否相同。

踩坑指南

​ 在阅读这部分资料的时候,发现有的博客提到如果原先的string内存区域是只读的,一但更改将会导致整个进程down掉,而且这个错误是runtime没法恢复的。

​ 这话的意思是说,通过unsafe对动态生成的字符串进行类型转化得到的byte切片,是可以修改的;如果使用字面量常量,那么修改byte切片会出现报错的情况。下面是两种情况的复现:

//1.对动态生成的字符串使用unsafe进行类型转换,可以修改byte切片
var data []byte
for i := 0; i < 3; i++ {
     
    data = string2bytes(string(i))
}
fmt.Println(data, string(data)) //[2]
data[0] = 0x3
fmt.Println(data, string(data)) //[3]
//2.对字面量常量使用unsafe进行类型转换,不可以修改byte切片
s := "a"
b := StringToBytes(s)
fmt.Println(b, string(b))
b[0] = 0x42 // ascii for B
fmt.Println(b, string(b))

报错信息:

[97] a
unexpected fault address 0x4c209b
fatal error: fault

​ 那这个问题怎么解决呢?既然第一种情况(动态生成的字符串)不会报错,那么我们就把字面量常量改为生成的字符串。这个转化过程可以通过strings.Builder实现。

sb := strings.Builder{
     }
sb.WriteString("a")
s := sb.String()
b := StringToBytes(s)
fmt.Println(b, string(b))
b[0] = 0x42 // ascii for B
fmt.Println(b, string(b))

打印结果:

[97] a
[66] B

​ 在进行字符串->byte切片转换的时候,需要注意,这个字符串可能是动态分配得到的,也可能是字符串常量,这个时候,进行转换的时候就需要注意了,很有可能我们正在操作一个只读的内存地址。这里列举几种可能出现的情况:

  • 通过运算符+拼接字符串。这种方式做了内存复制,所以连接前的字符串无论是动态分配得到的还是字符串常量,连接之后的结果都是动态分配的。所以不会出现修改失败的情况。
  • 通过strings.Split()。通过查看源码,可以发现其实是在原字符串的基础上做了个切片,所以这种情况还是要看原字符串到底是字符串常量还是动态分配得到的。
  • 通过fmt拼接。通过动态分配得到一个拼接后的新字符串,所以也不会出现修改byte切片失败的情况。但是fmt拼接字符串的方式效率非常低,不建议使用。

​ 为什么使用unsafe效率高,是因为我们通过unsafe.Pointer实现了zero copy,两个不同类型的遍历实际上共享同一块地址,当我们修改[]byte数组的时候,会发现原来的字符串也会发生修改,所以在使用unsafe实现类型转换时,在不确定是否是唯一引用的情况下,应该尽量避免修改转化后得到的[]byte,否则其他指向同一地址的字符串也会出现被修改的情况。

s := "a"
s += "b"
b := string2bytes(s)
fmt.Println("未修改的[]byte:", b, string(b))
b[0] = 0x42 // ascii for B
fmt.Println("修改后的[]byte:", b, string(b))
fmt.Println("原字符串:", s)

打印结果:

未修改的[]byte: [97 98] ab
修改后的[]byte: [66 98] Bb
原字符串: Bb

2.最佳实践举例:fasthttp

fasthttp是一个很好的开源包,效率比net/http要高很多。

fasthttp 是 Go 的一款不同于标准库net/http的 HTTP 实现。fasthttp 的性能可以达到标准库的 10 倍,

fasthttp效率高的一个原因在于:使用unsafe.Pointer避免string类型和[]byte类型之间产生的转换开销,能复用绝对不分配使用。在这里可以看一下该开源包的相关实现源码:

type StringHeader struct {
     
	Data uintptr
	Len  int
}

type SliceHeader struct {
     
	Data uintptr
	Len  int
	Cap  int
}

// []byte -> string
func b2s(b []byte) string {
     
    //根据前面介绍的string和slice底层结构,在这里可以直接转换指针类型,忽略掉切片中的cap即可
	return *(*string)(unsafe.Pointer(&b))
}

// string -> []byte
func s2b(s string) []byte {
     
	sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    //这里转化时需要构造,string的结构体只包括指针和len,而slice包括指针、len和cap
	bh := reflect.SliceHeader{
     
		Data: sh.Data,
		Len:  sh.Len,
		Cap:  sh.Len,
	}
	return *(*[]byte)(unsafe.Pointer(&bh))
}

​ 上面这种方式是借助unsafe包和reflect包去构造slice headerstring header,共享底层 []byte 数组实现 zero-copy

​ 这里说明一下,除了上面这种方式还有一种类似的方式,借助uintptr实现的。此时,string可以看作[2]uintptr,而[]byte可以看作[3]uintptr,所以[]byte -> string可以直接转换指针类型,忽略掉cap就完事了,但是string -> []byte需要构造[3]uintptr{ptr, len, len}

package main

import (
    "fmt"
    "strings"
    "unsafe"
)

func str2bytes(s string) []byte {
     
    x := (*[2]uintptr)(unsafe.Pointer(&s))
    h := [3]uintptr{
     x[0], x[1], x[1]}
    return *(*[]byte)(unsafe.Pointer(&h))
}

func bytes2str(b []byte) string {
     
    return *(*string)(unsafe.Pointer(&b))
}

func main() {
     
    s := strings.Repeat("abc", 3)
    b := str2bytes(s)
    s2 := bytes2str(b)
    fmt.Println(b, s2)
}

​ 这两种方式都大同小异,但是使用unsafe替代传统的[]bytestring转换,会带来一些问题:

  • 可能无法对转换出的[]byte进行修改操作(可以参考前面一小节的内容);
  • 上面展示的转换方式其实依赖了StringHeaderSliceHeader结构,更改这两个结构会受到一定的影响;
  • 如果unsafe.Pointer作用被更改,那么也会受到影响。

fasthttp中还有很多很好的最佳实践,戳我查看。

4.小结

​ unsafe包都是由Go编译器实现的,并不用于运行时。unsafe是不安全的,但是它也有自己的好处,比如说:

  • 可以绕过Go的内存安全机制,直接对内存进行读写;

  • 底层类型相同的数组之间的转换;

  • 使用sync/atomic包中的一些函数时;

  • 访问结构体的私有字段

​ 在一些runtime/os/syscall/net等包中,我们可以看到使用unsafe的身影,从这些包名来看不难发现,这些包都和操作系统密切相关。

​ 举一个使用unsafe.Pointer的常见场景:涉及到反复在string和[]byte之间来回转换时,可以使用unsafe包绕过底层数据赋值的过程,实现二者的互相转化,这样可以大大提高性能。但是要注意,如果修改转换出的[]byte,那么其他引用相同地址的变量也会被受到”牵连“,所以要慎重修改。其次并不是每一个转换得到的[]byte都是可被修改的,它有可能是只读的。

​ 尽管unsafe提供了一些好处,但还是应该尽可能少的使用它,操作内存时如果使用不当,可能会破坏内存,从而排错会很艰难。

参考资料:

1.golang无复制高效实现string与[]byte转换

2.fasthttp源码&最佳实践分析

3.了解unsafe在map源码中的使用

4.string和[]byte的区别

你可能感兴趣的:(Golang)