Golang empty struct 的底层原理和其使用

共 5351字,需浏览 11分钟

 ·

2024-06-29 19:30

在 Go 中,普通结构体通常占据一个内存块。但有一种特殊情况:如果是空结构体,其内存的占用大小就为零。为什么是这样呢?这样的空结构体有什么用?

type Test struct {
     A int
     B string
 }
 
 func main() {
     fmt.Println(unsafe.Sizeof(Test{}))
     fmt.Println(unsafe.Sizeof(struct{}{}))
 }
 
 /*
 24
 0
 */

空结构的秘密

特殊变量:零基数

空结构体是没有内存大小的结构体。这种说法是正确的,但更准确地说,它有一个特殊的起点:zerobase 变量。这是一个占 8 字节的 uintptr 全局变量。每当定义无数个 struct {} 变量时,编译器都会分配这个 zerobase 变量的地址。换句话说,在 Go 语言中,任何大小为 0 的内存分配都使用相同的地址 &zerobase。

Example[1]

package main
 
 import "fmt"
 
 type emptyStruct struct {}
 
 func main() {
     a := struct{}{}
     b := struct{}{}
     c := emptyStruct{}
 
     fmt.Printf("%p\n", &a)
     fmt.Printf("%p\n", &b)
     fmt.Printf("%p\n", &c)
 }
 
 // 0x58e360
 // 0x58e360
 // 0x58e360

空结构体变量的内存地址都是相同的。这是因为编译器在遇到这种特殊类型的内存分配时,会在编译过程中分配 &zerobase。这一逻辑存在于 mallocgc 函数中:

//go:linkname mallocgc  
 func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {  
     ...
     if size == 0 {  
        return unsafe.Pointer(&zerobase)  
     }
     ...

这就是 Empty struct 的秘密。利用这个特殊变量,我们可以实现许多功能。

空结构和内存对齐

通常情况下,如果空结构体是较大结构体的一部分,则不会占用内存。但是,当空结构体是最后一个字段时,就会触发内存对齐。

Example[2]

type A struct {
     x int
     y string
     z struct{}
 }
 type B struct {
     x int
     z struct{}
     y string
 }
 
 func main() {
     println(unsafe.Alignof(A{}))
     println(unsafe.Alignof(B{}))
     println(unsafe.Sizeof(A{}))
     println(unsafe.Sizeof(B{}))
 }
 
 /**
 8
 8
 32
 24
 **/

当存在指向字段的指针时,返回的地址可能在结构体之外,如果释放结构体时没有释放该内存,则可能导致内存泄漏。因此,当空结构体是另一个结构体的最后一个字段时,为了安全起见,会分配额外的内存。如果空结构体位于结构体的开头或中间,则其地址与下面的变量相同。

type A struct {  
     x int  
     y string  
     z struct{}  
 }  
 type B struct {  
     x int  
     z struct{}  
     y string  
 }  
   
 func main() {  
     a := A{}  
     b := B{}  
     fmt.Printf("%p\n", &a.y)  
     fmt.Printf("%p\n", &a.z)  
     fmt.Printf("%p\n", &b.y)  
     fmt.Printf("%p\n", &b.z)  
 }
 
 /**
 0x1400012c008
 0x1400012c018
 0x1400012e008
 0x1400012e008
 **/

空结构使用案例

空结构 struct struct{} 存在的核心原因是为了节省内存。当你需要一个结构但不关心其内容时,可以考虑使用空结构。Go 的核心复合结构,如 map、chan 和 slice,都可以使用 struct{}。

map & struct{}

// Create map
 m := make(map[int]struct{})
 // Assign value
 m[1] = struct{}{}
 // Check if key exists
 _, ok := m[1]

chan & struct{}

典型的情况是将 channel 和 struct{} 结合在一起,其中 struct{} 经常被用作信号,而不关心其内容。正如前几篇文章所分析的,通道的基本数据结构是一个管理结构加一个环形缓冲区。如果 struct{} 被用作元素,则环形缓冲区为零分配。

chan 和 struct{} 放在一起的唯一用途是信号传输,因为空结构体本身不能携带任何值。一般情况下,它不用于缓冲通道。

// Create a signal channel
 waitc := make(chan struct{})
 
 // ...
 goroutine 1:
     // Send signal: push element
     waitc <- struct{}{}
     // Send signal: close
     close(waitc)
 
 goroutine 2:
     select {
     // Receive signal and perform corresponding actions
     case <-waitc:
     }

在这种情况下,有必要使用 struct{} 吗?其实不然,节省的内存几乎可以忽略不计。关键在于,我们并不关心 chan 的元素值,因此使用了 struct{}。

总结

  • 空结构体仍然是大小为 0 的结构体。
  • 所有空结构体共享同一个地址:zerobase 的地址。
  • 我们可以利用 empty 结构不占用内存的特性来优化代码,例如使用映射来实现集合和通道。
参考资料
[1]

example-1: https://go.dev/play/p/WNxfXviET_i

[2]

example-2: https://go.dev/play/p/HcxlywljovS


浏览 78
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报