掘金 后端 ( ) • 2024-04-29 13:54

theme: smartblue highlight: an-old-hope

1. 结构体大小

Go 结构体与内存对齐 的讨论中,我们已经了解了结构体如何进行内存对齐,并大致展示了结构体最终大小是如何推导出来的。然而,需要明确的一点是,结构体的大小并不是在内存分配时确定的。作为一门编译型的静态语言,Go 在程序运行之前需要经过编译器的处理。编译器会在编译期间解析整个程序的类型信息,并进行类型检查[1]。在这个过程中,Go 编译器会直接计算出结构体的大小,并非等到内存对齐阶段才确定。

在 Go 的编译器中,负责计算各个类型大小的相关代码路径为src/cmd/compile/internal/types/size.go,其中计算结构体大小的函数为 CalcStructSize ,详情如下:

(note:本文介绍的所有代码均来自 Go 1.22 版本;由于各个版本的代码或有不同,早期版本计算类型大小的函数为src/cmd/compile/internal/gc/align.go中的 dowidth 函数,而从 Go 1.17 到目前 Go 1.22 系列版本的相关计算基本都在src/cmd/compile/internal/types/size.goCalcSize 函数里。)

func CalcStructSize(t *Type) {
	var maxAlign uint8 = 1

	// 识别特殊类型
  // 若为"align64"并且来自原子操作标准库,将 maxAlign 设为 8。
	if sym := t.Sym(); sym != nil {
		switch {
		case sym.Name == "align64" && isAtomicStdPkg(sym.Pkg):
			maxAlign = 8
		...
	}

	fields := t.Fields()
    // 计算结构体字段偏移
    // 初始偏移量为 0
	size := calcStructOffset(t, fields, 0)

	if size > 0 && fields[len(fields)-1].Type.width == 0 {
		size++
	}

	for _, field := range fields {
		typ := field.Type

		// 遍历所有字段, 更新 maxAlign, 使其保持为当前字段类型的最大对齐值。
		if align := typ.align; align > maxAlign {
			maxAlign = align
		}

		...
	}

	// 根据当前 maxAlign 确定结构体最终大小
	size = RoundUp(size, int64(maxAlign))

    ...

	t.width = size
	t.align = maxAlign
	...
}

CalcStructSize函数的主要用于计算结构体的大小。它会计算结构体中各个字段的偏移量,并根据字段的对齐值进行调整。然后,通过遍历所有字段,确定结构体最终的maxAlign,确保结构体的对齐方式符合系统要求。最后,根据maxAlign对结构体的大小进行向上舍入,并将最终的大小和对齐值记录在结构体的类型信息中。在这个过程中,CalcStructSize函数调用了两个辅助函数:calcStructOffsetRoundUp

calcStructOffset的作用是计算结构体字段的偏移量。它根据字段的类型大小和对齐值,以及前一个字段的偏移量,来计算当前字段的偏移量,并确保结构体的内存布局符合要求。

func calcStructOffset(t *Type, fields []*Field, offset int64) int64 {
	for _, f := range fields {
        // 计算字段类型大小
		CalcSize(f.Type)

        // 使用 RoundUp 将当前偏移量 offset 四舍五入到当前字段类型对齐值 f.Type.align 的倍数
        // 确保字段按照其对齐要求放置
		offset = RoundUp(offset, int64(f.Type.align))

		...

        // 将当前偏移量offset 加上当前字段f的类型宽度 f.Type.width
        // 准备处理下一个字段。
		offset += f.Type.width

		// 此处省略
        // 此处为检查偏移量上限
        ...	
	}

    // 循环结束后,返回最终计算得到的偏移量offset
    // 即最后一个字段之后的偏移位置
	return offset
}

在上述代码中,calcStructOffset会遍历结构体的每个字段,通过调用CalcSize函数来计算每个字段的类型大小 (大小会被记录到f.Type.width)中,并使用RoundUp函数将当前偏移量offset向上舍入到当前字段类型的对齐值f.Type.alig的倍数。接着,它将当前偏移量加上当前字段的类型宽度f.Type.width,以准备处理下一个字段。在循环结束后,返回最后一个字段之后的偏移位置。

值得注意的是,calcStructOffsetCalcStructSize两个函数都调用了RoundUpRoundUp用于将给定的数值向上舍入到最接近指定对齐值的倍数,以确保结构体满足对齐要求。详情如下:

func RoundUp(o int64, r int64) int64 {
    // 保证对齐值 r 有效
    // 即:1 <= r <= 8. 且 r 为 2 的幂
	if r < 1 || r > 8 || r&(r-1) != 0 {
		base.Fatalf("Round %d", r)
	}
	return (o + r - 1) &^ (r - 1)
}

RoundUp主要是将整数o向上舍入到最接近的r的倍数,而r必须是 2 的幂。其核心为(o + r - 1) &^ (r - 1)这行表达式:

  • (o + r - 1):先将o加上r - 1,这是为了确保当o不是r的倍数时,最后结果向上取整。比如,如果o是 5,而r是 4,则这个步骤会使得结果变为 8,而不是 4。
  • &^ (r - 1):将(o + r - 1)的结果进行按位清零操作(Bit Clear),这会将结果向下调整到最接近的r的倍数。

详细的解释就是,当我们对一个数进行 (o + r - 1) &^ (r - 1)这样的操作时,实际上是清除了o + r - 1的二进制表示中的r的倍数位置上的比特位。这是因为r是 2 的幂,它的二进制表示只有一个比特位为 1,其余位为 0。例如,如果r = 4,其二进制表示为 100。那么r - 1 = 3的二进制表示为 011。而&^操作的效果是保留o + r - 1的二进制表示中,r的倍数位置上的比特位,并将r的倍数位置以外的比特位清零。这样做的结果是将o + r - 1向下舍入到最接近r的倍数。因为只有r的倍数位置上的比特位会被清零,其他位置上的比特位都保持不变,所以最终的结果就是最接近r的倍数的值。

如果简单的来说,(o + r - 1) &^ (r - 1)其实就等同于(o + r - 1) & (^(r - 1))。(note:Go 语言中,运算符^既是取反,也是异或。如:取反^3、异或1^2

因此,RoundUp函数可以用来计算结构体当前字段符合对齐要求(放置地址必须是对齐值的倍数)的偏移地址。

现在我们回到结构体大小计算的讨论上,来总结计算结构体大小的主要步骤:

  • 首先,在计算结构体大小之前,CalcStructSize函数会识别当前结构体类型是否来自原子操作,并根据需要初始化最大对齐值 maxAlign
  • 其次,CalcStructSize会计算当前结构体所有字段的大小以及填充(Padding),并返回最后一个字段之后的偏移位置。这个过程在calcStructOffset函数中实现,它确保了结构体内部字段的布局满足对齐要求,并且考虑了填充的情况。由于字段初始偏移为 0,因此calcStructOffset返回的偏移量可视为字段和填充总和的大小值;
  • 然后,CalcStructSize遍历所有字段,确定最大对齐值 maxAlign
  • 最后,根据maxAlign,函数计算结构体的最终大小,并将相关信息存入类型Type中。

最后字段为空结构体的处理。详情见:空结构体与内存对齐 | Go 结构体(其四)

CalcStructSize函数中,还有一个关键的处理步骤,用于处理结构体中最后一个字段为空结构体struct{}的情况。这个处理步骤是为了解决空结构体可能引发的内存对齐问题。在某些情况下,结构体的最后一个字段为空结构体时,可能会导致取该字段地址时指向下一个堆对象,从而产生内存溢出的问题。为了避免这种情况发生,CalcStructSize函数在计算结构体大小时会进行如下处理:

if size > 0 && fields[len(fields)-1].Type.width == 0 {
    size++
}

上述代码片段的作用是检查结构体的大小是否大于零且最后一个字段的宽度是否为零。如果满足这两个条件,则说明结构体的最后一个字段为空结构体,但结构体本身的大小不为零。为了防止内存溢出问题,函数会在结构体的末尾增加一个字节的填充,使得结构体的大小增加 1。这样就确保了结构体的内存布局是正确的,并且在取空结构体字段的地址时不会产生意外的结果。

2. 空结构体

在 Go 语言中,空结构体是一种独特的数据结构,其不含任何字段。结构体在 Go 中是用户自定义的数据类型,可以包含从零个到多个字段。然而,有时我们需要表达某种概念,而并非真正需要存储数据。这时,空结构体便派上了用场。空结构体通常被用作占位符或信号量,其不包含任何字段,且大小为 0。(空结构体使用场景见:空结构体的应用)

而在前述讨论中,我们了解到了 Go 结构体的大小是由其字段的大小和对齐值所决定的。每个字段的大小和对齐值都会对整个结构体的大小产生影响。在计算结构体大小时,函数CalcStructSize会遍历结构体的所有字段,考虑每个字段的大小和对齐值,并确保结构体的内存布局满足对齐要求。

然而,当结构体为空时,情况就略有不同。在这种情况下,由于空结构体不包含任何字段,因此在计算其大小时,并无需存储任何数据。这意味着调用辅助函数calcStructOffset时,其返回值必然为 0。进而,在使用RoundUp函数计算结构体的最终大小时,返回的结果也必然为 0。因此,空结构体的最终大小为 0。

虽然空结构体在 Go 中大小为 0,但它们也具有地址。对于像空结构体这种大小为 0 的对象,Go 语言对其进行了特殊处理,如下:

// 所有 0 字节分配的基址
var zerobase uintptr

// 分配对象内存
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size == 0 {
		return unsafe.Pointer(&zerobase)
	}

    ...
}

在 Go 的内存分配器中,存在一个名为 zerobase 的特殊变量,它用于表示所有大小为 0 的对象的基址。当函数 mallocgc 分配的对象大小为 0 时,就会直接返回这个零地址zerobase