- 认识空结构体
- 低层实现原理
- 空结构体之内存对齐
- 应用场景
在golang中,如果我们想实现一个set集合的话,一般会使用map来实现,其中将set的值作为map的键,对于map的值一般使用一个空结构体来实现,当然对map值也可以使用一个bool类型或者数字类型等,只要符合一个键值对应关系即可。但我们一般推荐使用struct{}来实现,为什么呢?
package main import "fmt" func main() { m := make(map[int]struct{}) m[1] = struct{}{} m[2] = struct{}{} if _, ok := m[1]; ok { fmt.Println("exists") } }
上面这段代码是一个很简单的使用map实现的set功能,这里是采用空结构体struct{}来实现。
在分析为什么使用struct{}以前,我看先认识一个struct。
认识空结构体 struct{}
我们先看一个这段代码
package main import ( "fmt" "unsafe" ) type emptyStruct struct{} func main() { a := struct{}{} b := struct{}{} c := emptyStruct{} fmt.Println(a) fmt.Printf("%pn", &a) fmt.Printf("%pn", &b) fmt.Printf("%pn", &c) fmt.Println(a == b) fmt.Println(unsafe.Sizeof(a)) }
{} // 值 0x586a00 // a 内存地址 0x586a00 // b 内存地址, 同a一样 0x586a00 // c 内存地址,别名类型变量,同a一样 true // 丙个结构体是否相等,很显示,上面打印的是同一个内存地址 0 // 占用内存大小
从打印结果里我们可以得出以下结论
1. 空结构体是一个无内容值的值,即空值
2. 空结构体占用0大小的内存,即不分配内存
3. 凡是空结构体他们都是一样的,即底层指向的是同一个内存地址,如何实现的呢?
对于高性能并发应用来说,内存占用大小一般都是我们关注重点对象,使用空struct{}根本不占用内存大小,相比使用其它类型的值,如bool(占用两个字节)int64(8个字节)性能要好的多,毕竟不用分配和回收内存了。
当然有人可能会说开发的应用map值不多的话,这点内存可以忽略不计。是的,确实是这样的,但这会带来一个另一个语义理解问题。如:
package main import "fmt" func main() { m := make(map[int]bool) m[1] = true m[2] = true if _, ok := m[1]; ok { fmt.Println("exists") } }
我们用bool代替了空结构体,至于值是 true 还是 false 是没有任何影响的,都是bool数据类型占用的内存大小也一样。那么如果另一位开发的同事查看review源码的时候,如果这个map出现在一个大型应用的时候,会大多处出现,就很有可能带来疑惑,对于值所表达的意图就有所担心怀疑,提高了理解代码的门槛。心里如果值为true 的话,会执行一个逻辑,为false的话会执行另一个逻辑。而相比使用一个空结构体strcut{}来理解起来容易提高心智,别人一看空结构体struct{}就知道要表达的意思是不需要关心值是什么,只需要关心键值即可。
要记住一点,我们写的代码虽然是让机器运行的,但却是让人看的,能让人一眼看明白就不要看两眼,这点不正是符合了golang 这门开发语言的特性吗,只需要记住25个关键字,简单易理解,性能还高效。
底层原理
那么在底层一个空结构体又是怎么一回事呢? 为什么多个空结构体会指向同一个内存地址呢? 这和一个很重要的 zerobase 变量有关(在runtime里多次使用到了这个变量),而zerobase 变量是一个 uintptr 的全局变量,占用8个字节 (https://github.com/golang/go/blob/master/src/runtime/malloc.go#L840-L841),只要你将struct{} 赋值给一个或者多个变量,它都返回这个 zerobase 的地址,这点我们上面已经证实过这一点了。
在golang中大量的地方使用到了这个 zerobase 变量,只要分配的内存为0,就返回这个变量地址。
// Allocate an object of size bytes. // Small objects are allocated from the per-P cache's free lists. // Large objects (> 32 kB) are allocated straight from the heap. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { if gcphase == _GCmarktermination { throw("mallocgc called with gcphase == _GCmarktermination") } if size == 0 { return unsafe.Pointer(&zerobase) } ....... }
结论:只要分配的内存大小为 0 字节,就返回 &zerobase 的指针,不仅仅是空结构体。
内存对齐
如果您对内存对齐不太了解的话,可以参考这篇或Memory Layouts,注意下32位和64位系统之间的差异。有一点需要保证在 32 位系统上想要原子操作 64 位字(如 uint64)的话,需要由调用方保证其数据地址是 64 位对齐的,否则原子访问会有异常。
uint64
在 64 位系统上,8bytes 刚好和其字长相同,所以可以一次完成原子的访问,不被其他操作影响或打断。
uint64
如果两次操作中间有可能别其他操作修改,不能保证原子性。
这样的访问方式也是不安全的。
这一点issue-6404[5]中也有提到:
This is because the int64 is not aligned following the bool. It is 32-bit aligned but not 64-bit aligned, because we’re on a 32-bit system so it’s really just two 32-bit values side by side.
For the numeric types, the following sizes are guaranteed:
type size in bytes byte, uint8, int8 1 uint16, int16 2 uint32, int32, float32 4 uint64, int64, float64, complex64 8 complex128 16
空结构体作为一个占用0字节的数据类型与其它基本类型对比的话,确实有些特殊。
package main import ( "fmt" "unsafe" ) // 放在第1个字段,共需要24字节 type T0 struct { s struct{} // 0 f2 int32 // 4 f3 int32 // 4 f1 bool // 1 f4 int64 // 8 } // 放在第1个字段,共需要16字节 // f2为 int16 类型 type T1 struct { s struct{} // 0 f1 bool // 1 f2 int16 // 2 f3 int32 // 4 f4 int64 // 8 } // 中间字段,共需要16字节 type T2 struct { f1 bool // 1 s struct{} // 0 f2 int16 // 2 f3 int32 // 4 f4 int64 // 8 } // 最后一个字段, 共需要24字节 type T3 struct { f1 bool // 1 f2 int16 // 2 f3 int32 // 4 f4 int64 // 8 s struct{} // 0 } // 最后一个字段, 共需要24字节 type T4 struct { f1 bool // 1 f2 int16 // 2 f4 int64 // 8 f3 int32 // 4 s struct{} // 0 } // 最后一个字段, 共需要24字节 // 这里f1数据类型由bool变成了int16 type T5 struct { f4 int64 // 8 f3 int32 // 4 f1 int16 // 2 f2 int16 // 2 s struct{} // 0 } func main() { bit := 32 << (^uint(0) >> 63) fmt.Printf("当前系统为 %d 位n", bit) var t0 T0 var t1 T1 var t2 T2 var t3 T3 var t4 T4 var t5 T5 fmt.Println("t0:", unsafe.Sizeof(t0)) // (0+4)+(4)+ (8) 共占用24字节对齐 fmt.Println("t1:", unsafe.Sizeof(t1)) // (0+1+2+4)+ (8) 共占用16字节对齐 fmt.Println("t2:", unsafe.Sizeof(t2)) // (1+0+2+4)+ (8) 共占用16字节对齐 fmt.Println("t3:", unsafe.Sizeof(t3)) // (1+2+4)+(8)+(0)共占用24字节 fmt.Println("t4:", unsafe.Sizeof(t4)) // (1+2)+(8)+(4+0)共占用24字节 fmt.Println("t5:", unsafe.Sizeof(t5)) // (8)+(4+2+2)+ (0) 共占用24字节 }
结果
当前系统为 64 位 t0: 24 t1: 16 t2: 16 t3: 24 t4: 24 t5: 24
这里运行环境为64位系统,所以按8字节对齐。
T0T1T2T3T4T5
T5T4
总结:上面结果我们可以看出,虽然空结构体占用0字节,但在进行内存对齐的时候是需要考虑这个字段,可以理解为将其视为需占用1个字节,不然这个结构体就没有任何意义了 。
对于Golang为什么要内存对齐, 有没有必要使用,可以看看这篇文章 Golang 是否有必要内存对齐?
空结构体struct{}使用场景
一般我们用在用户不关注值内容的情况下,只是作为一个信号或一个占位符来使用。
- 基于map实现集合功能
就是我们上面提到的情况 - 与channel组合使用,实现一个信号
package main import ( "fmt" "sync" "time" ) func main() { conLimit := make(chan struct{}, 2) var wg sync.WaitGroup for i := 0; i < 10; i++ { conLimit <- struct{}{} wg.Add(1) go func(i int) { defer wg.Done() // doing... fmt.Println(i) time.Sleep(time.Second) <-conLimit }(i) } wg.Wait() close(conLimit) fmt.Println("ok") }
基于有缓冲的channel是实现并发限速。另外一种限速用法
下面的例子来自《Go 语言高级编程》, 为里使用空结构体进行了调整
var limit = make(chan struct{}, 3) func main() { // ………… for _, w := range work { go func() { limit <- struct{}{} w() <-limit }() } // ………… }
那么这两种写法是否有差异呢?
limit <- 1
如果在外层,就是控制系统 goroutine 的数量,可能会阻塞 for 循环,影响业务逻辑。
limit 其实和逻辑无关,只是性能调优,放在内层和外层的语义不太一样。
还有一点要注意的是,如果 w() 发生 panic,那“许可证”可能就还不回去了,因此需要使用 defer 来保证。