[奇怪] Golang中缓冲通道内存消耗异常问题探讨

[奇怪] Golang中缓冲通道内存消耗异常问题探讨 大家好,

这里有一个小基准测试,用于展示带缓冲通道消耗的内存:

func getChan1() chan int {
    return make(chan int, 101)
}

var c chan int

func BenchmarkChan1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        c = getChan1()
    }
}

这段代码根据带缓冲通道的缓冲区大小S给出了不同的数值,以下是一些示例值: 1 -> 112 B/op 2 -> 112 B/op 3 -> 128 B/op 4 -> 128 B/op 5 -> 144 B/op 6 -> 144 B/op 7 -> 160 B/op

由此我推断出带缓冲整型通道的实际占用大小可通过以下公式计算:size(容量为S的整型通道) = 96 + 整型大小 * 2 * [(N+1) / 2]

为什么当S为奇数时,容量为S的通道与容量为S+1的通道会占用相同的内存?

这种情况虽然奇怪但还不算特别异常。接下来才是真正令人困惑的部分: 100 -> 896 B/op

101 -> 1024 B/op 102 -> 1024 B/op 103 -> 1024 B/op 104 -> 1024 B/op

199 -> 1792 B/op 200 -> 1792 B/op 201 -> 1792 B/op 202 -> 1792 B/op

300 -> 2688 B/op 301 -> 2688 B/op

998 -> 8192 B/op 999 -> 8192 B/op 1000 -> 8192 B/op 1001 -> 8192 B/op

这到底是怎么回事?当带缓冲通道的容量大于100时会出现额外开销,这些开销是从哪里来的?

这是与扩展机制有关,还是为了优化而对数据结构进行填充?或者可能是我的基准测试在处理较大数值时出现了问题?该如何解释这种行为?


更多关于[奇怪] Golang中缓冲通道内存消耗异常问题探讨的实战教程也可以访问 https://www.itying.com/category-94-b0.html

6 回复

大型对象会被向上取整到最接近的页面大小,并使用一系列该尺寸的堆分配。

更多关于[奇怪] Golang中缓冲通道内存消耗异常问题探讨的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我在你的另一个主题中链接了一个这样的内容。

你所说的最大页面大小是什么意思?如果一个对象比一个页面大怎么办? 能否请你解释一下,或者提供一个简单易懂的教程链接?

vincent-163:

runtime.ReadMemStats

这是个很好的回答。不过有个问题:如果我分配一个大于19072字节(最大堆大小)的对象,并且根据逃逸分析该对象应该进入堆,那么堆会发生什么变化,其机制是怎样的?

// 示例代码请在此处添加

这是由于Go语言采用大小隔离堆模型所致。为了高效管理内存并避免碎片化,该模型根据对象大小将其分配到不同的堆中,因此每个堆中的所有对象大小都相等。每次分配内存时,运行时会将对象大小向上取整到最接近的堆规格,并从对应堆中分配项目。这种机制让运行时能够轻松管理内存,代价是每个对象会产生一定的开销。

可以通过调用runtime.ReadMemStats并检查BySize结构中的Size字段来获取每个堆的规格。在本地机器上调用该函数得到以下数值:

0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072

由此可以观察到968、1024、1792、2688和8192这些数值都包含在列表中。

这个现象确实反映了Go语言通道内存分配的底层机制。让我通过分析Go运行时源码来解释这种行为。

首先,通道的内存分配遵循特定的对齐规则。在Go的运行时中,通道缓冲区的大小会根据元素类型和容量进行对齐处理。以下是关键代码片段:

// 来自Go运行时/runtime/chan.go
func makechan(t *chantype, size int) *hchan {
    // 计算元素大小
    elem := t.elem
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    
    // 缓冲区内存分配对齐
    if elem.size == 0 {
        mem = uintptr(size) // 零大小元素特殊处理
    } else {
        // 内存对齐计算
        mem = roundupsize(mem)
    }
    
    // 通道头结构 + 缓冲区内存
    total := unsafe.Sizeof(hchan{}) + mem
    // ... 实际分配逻辑
}

具体到你的测试用例,问题出现在roundupsize函数中。这个函数负责将请求的内存大小向上对齐到特定的尺寸类别:

// 内存大小对齐表(部分)
var class_to_size = [_NumSizeClasses]uint16{
    0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 
    240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 
    832, 896, 960, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 
    3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 7168, 8192, 
    // ... 更多尺寸
}

对于你的具体数值:

  • 容量100:101 * 8 = 808字节,向上对齐到896字节
  • 容量101-104:(101-104) * 8 = 808-832字节,全部对齐到1024字节
  • 容量199-202:(199-202) * 8 = 1592-1616字节,对齐到1792字节
  • 容量300-301:2400-2408字节,对齐到2688字节
  • 容量998-1001:7984-8008字节,对齐到8192字节

这种内存对齐机制解释了为什么相邻容量会分配到相同的内存大小。Go的内存分配器使用尺寸类别来优化内存分配和减少碎片,这就是你观察到的"阶梯式"内存消耗模式。

要验证这个理论,可以创建一个更详细的基准测试:

func BenchmarkChanMemoryDetail(b *testing.B) {
    sizes := []int{1, 2, 100, 101, 102, 199, 200, 300, 301, 998, 999, 1000, 1001}
    
    for _, size := range sizes {
        b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
            var c chan int
            for i := 0; i < b.N; i++ {
                c = make(chan int, size)
                _ = c
            }
        })
    }
}

这种行为是Go运行时内存管理的正常特性,不是bug。内存对齐确保了分配效率,虽然在某些边界情况下会"浪费"一些内存,但整体上提高了系统的内存管理性能。

回到顶部