重新认识Golang中的空结构体
By admin
- 4 minutes read - 707 words- 认识空结构体
- 低层实现原理
- 空结构体之内存对齐
- 应用场景
在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 // 占用内存大小
从打印结果里我们可以得出以下结论
- 空结构体是一个无内容值的值,即空值
- 空结构体占用0大小的内存,即不分配内存
- 凡是空结构体他们都是一样的,即底层指向的是同一个内存地址,如何实现的呢?
对于高性能并发应用来说,内存占用大小一般都是我们关注重点对象,使用空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
来说,大小为 8bytes,32 位系统上按 4bytes 对齐,64 位系统上按 8bytes 对齐。
在 64 位系统上,8bytes 刚好和其字长相同,所以可以一次完成原子的访问,不被其他操作影响或打断。
而 32 位系统,4byte 对齐,字长也为 4bytes,可能出现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字节的数据类型与其它基本类型对比的话,确实有些特殊。
代码 https://play.golang.org/p/0S0excFMfbA
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字节对齐。
T0
和 T1
是将struct{}放在首字段的情况;
T2
是放在中间位置的情况;
T3
、T4
、T5
是作为最后一个字段的情况;
当作为最后一个字段的时候,如果它前面的字段放在一起的话等于8的话,则空结构体单独占用8字节(T5
),否则可以与前面的共占8字节(T4
)。
总结:上面结果我们可以看出,虽然空结构体占用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
放在 func 内部而不是外部,原因是:
如果在外层,就是控制系统 goroutine 的数量,可能会阻塞 for 循环,影响业务逻辑。
limit 其实和逻辑无关,只是性能调优,放在内层和外层的语义不太一样。
还有一点要注意的是,如果 w() 发生 panic,那“许可证”可能就还不回去了,因此需要使用 defer 来保证。