不知道大家在写 Go 时有没有注意过, 一个 struct 所占的空间不见得等于各个 field 加起来的空间 ,甚至有时把 field 申明的顺序调换一下,又会得到不同的结果。
今天的文章就是要从 CPU 抓资料的原理开始介绍,然后再讲到 Data Structure Alignment (数据结构对齐),希望大家在看完之后能对 CPU 跟记忆体有更多认识~
直接上例子
以 T1 为例,整个 struct 共有三个栏位,类型分别是 int8 、 int64 跟 int32 ,所以变数 t1 应该需要 1+8+4=13 bytes 的空间。但实际在 Go Playground 上跑,会发现 t1 竟然需要 24 bytes ,真奇怪是吧?
type T1 struct {
f1 int8 // 1 byte
f2 int64 // 8 bytes
f3 int32 // 4 bytes
}
func main() {
t1 := T1{}
fmt.Println(unsafe.Sizeof(t1)) // 24 bytes
}
如果尝试把栏位的顺序调整一下,改成 int8 、 int32 、 int64 再跑一次,就只需要 16 bytes,但跟原本预期的 13 bytes 还是有差,那究竟为什么会这样的差异呢?
type T2 struct {
f1 int8 // 1 byte
f3 int32 // 4 bytes
f2 int64 // 8 bytes
}
func main() {
t2 := T2{}
fmt.Println(unsafe.Sizeof(t2)) // 16 bytes
}
从 CPU 如何抓资料开始讲起
如果买电脑时有在留意 CPU 规格的话(身为工程师一定要的吧XD),应该会发现近几年的 CPU 几乎都是 64 bit 的。而这边的 64 bit ,指的就是 CPU 一次可以从记忆体里面抓 64 bits 的资料,换算一下也就是 8 bytes 。
虽说是一次抓 8 bytes ,但也不是想抓哪就抓哪,因为记忆体也会以 8 bytes 分成一个一个 word (如下图),而 CPU 只能一次拿某一个 word。所以如果所需的资料刚好横跨两个 word,那就得花两个 CPU cycle 的时间去拿。
注:在 64 bit 的系统中一个 word 是 8 bytes , 32 bit 中则是 4 bytes
所以为什么 struct 会变肥
了解 CPU 后我们再看一次 T1,他的栏位顺序是 int8 、 int64 、 int32 ,所以把 t1 的资料连续放在记忆体里面就长得像下图:因为第二个栏位 f2( int64 ) 需要 8 个 bytes,所以 会有一个 byte 会被挤到第二个 word (第二排)
那这样有什么坏处呢?如果我的程式需要用到 t1.f2 ,譬如说把他 print 出来,那 CPU 就得花两个 cycle 的时间把 f2 从记忆体抓出来,** 因为 f2 分散在两个 word 里面**
所以为了让 CPU 可以更快存取到各个栏位,Go 编译器会帮你的 struct 做 Data Structure Align ,也就是在 T1 的栏位间加上一些 padding,
struct T1 {
f1 i8
_ [7]byte // 7 bytes padding
f2 i64
f3 i32
_ [4]byte // 4 bytes padding
}
画成图就长下面这样,有 13 bytes 用来储存 struct 的资料,而深色的 11 个 bytes 则是用来当 padding, 确保每个 field 的所有内容都落在同一个 word 里面 ,所以 struct 才会从 13 bytes 肥到 24 bytes
Padding 可以不要那么肥吗?
虽说 padding 是为了把每个 field 放到更好的位置,但 padding 的空间实际上就是浪费掉了。以 T1 来说,24 bytes 里面就浪费了将近一半,那有什么方法可以兼顾 Alignment 但又不浪费太多空间吗?
再看一次 T1 的记忆体分佈,就会发现最下面 4 bytes 的 f3 其实可以挪到上面的 padding,反正第一排的 padding 空间超大的,不用白不用, 而且挪上去之后每个栏位都还是在同一个 word 里面 。
一旦把 f3 移上去,就可以省掉最下面一整个 word(8 bytes) 的空间,所以 T2 整个 struct 就只需要 16 bytes,是原本 T1 24 bytes 的三分之二
写成程式码的话,因为 Go 会按照栏位的顺序来安排记忆体中的位置,所以要把 f2 跟 f3 的顺序交换,宣告的顺序变成 int8 、 int32 、 int64 ,这样才会顺利排成上面那个图哦~
type T2 struct {
f1 int8 // 1 byte
f3 int32 // 4 bytes
f2 int64 // 8 bytes
}
func main() {
t2 := T2{}
fmt.Println(unsafe.Sizeof(t2)) // 16 bytes
}
编译器没办法自动最佳化吗?
看到这你一定觉得很麻烦,难不成每次用 Struct 都要自己拼拼凑凑、算算看怎么样的顺序最省空间?这种底层的鸟事应该 由编译器来最佳化 才对啊!
遗憾的是,目前 Go 编译器不会自动做这些最佳化(但 Rust 三年前就支援了 ,希望 Go 也能赶快跟进XD),所以如果很在意 struct 有没有充分利用记忆体空间,可以自己画图排排看,或是用 structslop 进行分析。
structslop
structslop 是一个用 Go 写成的开源工具,他的功能就是帮你调整 struct 的栏位顺序, 以达到最好的空间利用率 。像下面的例子 Student 里面包含了学号、姓名、班级、成绩等等资讯。
type Student struct {
id int8 // 1 byte
name string // 16 bytes
classID int8 // 1 byte
phone [10]byte // 10 bytes
address string // 16 bytes
grade int32 // 4 bytes
}
如果画成图就长这样,可以看到里面还有很多深色的 padding,算一算总共浪费了 16 bytes,感觉不是那么优。
这时就可以用 structslop 帮你分析并且算出一个最佳解,只要把栏位顺序改成他建议的,Student 占用的空间就可以从 64 bytes 最佳化到 48 bytes,共 省下 25% 的空间 。
如果把 structslop 推荐的 field 顺序画成图就长这样,全部排得满满的,没有任何一点 padding,看了心情都好了起来XD
有必要省空间省成这样吗
讲完怎么省空间后,接著我们来想想,虽然重新排列栏位可以让 struct 更省空间,但真的有必要这样吗?
以 Student 的例子来说,经过重新排列后,一个 struct 可以省下 16 bytes。
如果你要写个程式来排序全校同学的成绩,需要宣告长度十万的 Student array ,那省下的记忆体也不过 16 MB,跟现在个人电脑配备的 4GB 到 8GB 比起来根本是零头。
而且笔者我觉得栏位在经过重新排序之后,可读性可能会稍微降低,像 Student 原本的栏位依序是学号、姓名、班级…,满符合直觉的。
type Student struct {
id int8
name string
classID int8
phone [10]byte
address string
grade int32
}
但重新排序后顺序就变成姓名、地址、成绩…一直到最后才是学号跟班级,总觉得越重要的栏位应该要放在越前面才是(我自己觉得啦XD)。
所以我的观点是不需要太早进行最佳化,除非你一开始就知道你的程式瓶颈会卡在这(也许程式要跑在嵌入式装置),否则就照平常的方式写 Go 就好,也不用去算这些有的没的,也许 Go 在哪一次更新之后就像 Rust 默默支援 struct field reordering 了
总结
最后,我想跟大家分享一个忘记在哪看到的句子: 「Understanding the Hardware Makes You a Better Developer」 ,这边的 Hardware 我认为不一定是指硬体,而是泛指你所依赖的底层工具。
譬如说我完全不懂浏览器的 Reflow 跟 Repaint 还是可以写前端,但要做动画可能就会遇到效能瓶颈;不懂 Go 的 GC 机制还是可以把 Go 写得不错,但流量大起来时可能就会花太多时间在 GC。
所以虽然这篇文的结论是不需要特别去注意 Data Structure Alignment ,只要知道程式内部是这样运作的,并且顺其自然即可,但如果有一天真的因为这样记忆体不够了,那记得要想到调整一下栏位顺序哦~。