Sirius
Sirius

目录

Golang 内存分配

Go 的内存分配器是其高性能并发能力的核心基石之一。它并非直接向操作系统申请和释放每一次内存,而是实现了一套高效、分层的内存管理机制。其设计深受 Google 自家的 TCMalloc (Thread-Caching Malloc) 影响,核心思想是通过多级缓存来减少锁的竞争,从而提高并发分配的性能

Go 的内存管理可以看作一个金字塔形的层次结构,从上到下分别是 mcachemcentralmheap

  1. mspan (Memory Span - 内存块)

    在深入了解三层结构之前,必须先理解最基本的内存管理单元 mspan。

    • 定义mspan 是 Go 内存管理的基本单位,它是由一个或多个连续的物理页 (Page) 组成的内存块。Go 中一页大小为 8KB。

    • 用途mspan 用于管理一组相同大小的对象(例如,一个 mspan 只负责分配 32 字节大小的对象)。这种按“规格”管理的方式称为 size class (spanClass)。

    • 状态mspan 可以在不同的空闲链表(freelist)中,也可以被某个 mcache 持有。

  2. mcache (Memory Cache - Per-P 缓存)

    • 定义mcache 是每个 P (Processor) 私有的内存缓存。回顾 GMP 模型,每个 P 绑定一个操作系统线程 M 来执行 Goroutine。因此,mcache 也可以理解为线程本地缓存

    • 核心特点无锁分配 (Lock-Free)。当一个 Goroutine 需要分配小对象时,它会直接从其当前运行的 P 所绑定的 mcache 中获取。因为每个 P 只有一个 M 在执行,所以这个过程完全不需要加锁,速度极快。

    • 结构mcache 内部有一个包含约 70 个 mspan 指针的数组。每个数组元素对应一个 size class。例如,mcache.alloc[3] 可能指向一个专门用来分配 32 字节对象的 mspan

  3. mcentral (Central Freelist - 中心空闲列表)

    • 定义mcentral 是一个全局的、供所有 P 共享的资源。每种 size class 都有一个对应的 mcentral

    • 用途:当某个 P 的 mcache 中缺少某种规格的 mspan 时,它会向对应的 mcentral 申请。

    • 锁机制:因为 mcentral 是全局共享的,所以对它的访问需要加锁,以避免多个 P 同时来申请资源导致竞态。

    • 结构:每个 mcentral 维护着两个 mspan 链表:nonempty (包含有空闲空间的 mspan) 和 empty (包含完全空闲或已被归还的 mspan)。

  4. mheap (Heap Arena - 堆区)

    • 定义mheap 是内存分配器的最高层,它代表了 Go 程序持有的所有堆内存。它统一管理着所有从操作系统申请来的大块内存(称为 Arena,在 64 位系统上一个 Arena 是 64MB)。

    • 用途:当 mcentral 中没有可用的 mspan 时,mcentral 会向 mheap 申请。mheap 会从其管理的 Arena 中切出一块连续的页分配给 mcentral

    • 锁机制mheap 是全局唯一的,对它的访问需要加全局锁 (mheap_.lock)。

    • 大对象分配:超过 32KB 的大对象会绕过 mcachemcentral,直接由 mheap 进行分配。

下图展示了 mcache, mcentral, mheap 之间的层次关系和内存流动方向。

graph TD
    subgraph OS [操作系统]
        os_mem[物理内存]
    end

    subgraph Go_Runtime_Heap [Go 运行时堆区]
        mheap["mheap (全局唯一, 管理所有 Arenas)"]
        
        subgraph Centrals ["mcentral (全局共享, 按 Size Class 分类)"]
            mc0["mcentral (8B)"]
            mc1["mcentral (16B)"]
            mc_etc["..."]
            mcN["mcentral (32KB)"]
        end

        subgraph P1 ["Processor 1 (P)"]
             g1[Goroutine] --> cache1["mcache (P1私有, 无锁)"]
        end

        subgraph P2 ["Processor 2 (P)"]
             g2[Goroutine] --> cache2["mcache (P2私有, 无锁)"]
        end
        
        mheap -- "分配大对象 (>32KB)" --> g1
        mheap -- "分配大对象 (>32KB)" --> g2

        mheap -- "申请 pages" --> mc0
        mheap -- "申请 pages" --> mc1
        mheap -- "申请 pages" --> mc_etc
        mheap -- "申请 pages" --> mcN
        
        os_mem -- "申请大块内存 (Arenas)" --> mheap
        
        cache1 -- "申请 mspan (加锁)" --> mc1
        cache2 -- "申请 mspan (加锁)" --> mc0
    end
    
    style OS fill:#e6e6fa,stroke:#333,stroke-width:2px
    style Go_Runtime_Heap fill:#f0f8ff,stroke:#333,stroke-width:2px
    style P1 fill:#fff0f5,stroke:#333,stroke-width:2px
    style P2 fill:#fff0f5,stroke:#333,stroke-width:2px

核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.

对于微对象的分配流程:

(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)

(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)

(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)

(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)

(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).

  对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;

对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.


根据要分配的对象大小,流程分为三类:

  • 为了节省空间和对齐,小于 16 字节的对象会先被分配到一个 16 字节的块中,然后在这个块里进行指针偏移来分配。这个 16 字节的块本身还是来自于 mcache 的 16B size class。

这是最常见、也是 Go 内存分配器优化的核心路径。

  1. 尝试 mcache (无锁)

    • Goroutine 需要分配内存,它当前正在 P 上运行。

    • Go 运行时将请求的 size 向上取整到最接近的 size class

    • 它直接访问 P 的 mcache 中对应 size class 的 mspan 链表。

    • 如果链表上的 mspan 有空闲的 object slot,分配成功,返回指针。这个过程完全无锁,速度极快。

  2. mcache 缺货,求助 mcentral (加锁)

    • 如果 mcache 中对应 size class 的 mspan 已经用完,mcache 会向对应的全局 mcentral 申请一个新的 mspan

    • 这个过程需要mcentral 加锁

    • mcentral 从它的 nonempty 链表中取出一个 mspan 交给 mcache

  3. mcentral 缺货,求助 mheap (全局锁)

    • 如果 mcentralnonempty 链表也为空,mcentral 必须向 mheap 申请内存。

    • 它会调用 mheap.alloc,这个过程需要mheap 全局锁

    • mheap 会从其管理的 Arena 中找到一片连续的空闲页,切割成一个 mspan,然后交给 mcentral

    • 如果 mheap 也没有足够的空闲页,它会通过 mmap (在 Unix-like 系统上) 等系统调用向操作系统申请一大块新的内存 (Arena)

  4. 内存回流:

    内存最终从 mheap -> mcentral -> mcache -> Goroutine。

  • 大对象不会通过 mcachemcentral 进行分配,因为缓存小对象才有意义。

  • 大对象的分配请求会直接交给 mheap

  • mheap 会找到能容纳这个大对象的最小的连续页数,并直接分配。这个过程需要加 mheap 全局锁。

  • Go 语言中,内存的释放是自动的,由垃圾回收器 (GC) 完成。

  • 当 GC 的清理 (Sweeping) 阶段执行时,它会扫描 mspan,找出其中哪些 object 是不再使用的“垃圾”。

  • 这些垃圾 object 所占用的 slot 会被标记为空闲,并被回收到 mspan 自己的 freelist 中。

  • 如果一个 mspan 中的所有 object 都被回收了,这个 mspan 就变成了完全空闲状态。它会被从 mcache 归还到 mcentral

  • 如果 mcentral 发现一个 mspan 长时间空闲,它可能会将其归还给 mheapmheap 可以将这些连续的空闲页合并成更大的内存块,或者在适当的时候通过 madvise 等系统调用将物理内存归还给操作系统。

Go 的内存分配器是一个为高并发而生的精密系统。其核心优势在于:

  1. 分层缓存:通过 mcache, mcentral, mheap 的三层结构,将内存请求逐级处理。

  2. 无锁分配:绝大多数的小对象分配都可以在无锁的 mcache 中快速完成,这是 Go 并发性能的关键。

  3. 按规格管理 (Size Class):避免了内存碎片,提高了内存利用率和分配效率。

  4. 与 GC 协同:分配器和垃圾回收器紧密配合,高效地重用已回收的内存。

https://golang.design/under-the-hood/zh-cn/part2runtime/ch07alloc/basic/ https://goog-perftools.sourceforge.net/doc/tcmalloc.html https://mp.weixin.qq.com/s/2TBwpQT5-zU4Gy7-i0LZmQ