1. 认识空结构体
  2. 低层实现原理
  3. 空结构体之内存对齐
  4. 应用场景

在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{}使用场景

一般我们用在用户不关注值内容的情况下,只是作为一个信号或一个占位符来使用。

  1. 基于map实现集合功能
    就是我们上面提到的情况
  2. 与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 来保证。

参考