go内存管理

这篇文章可以看作是内存管理这篇长文的学习总结吧,原文基于源码剖析了整个go的内存管理,非常详尽。

程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域 — 栈区(Stack)和堆区(Heap)。应用程序的内存一般会分成堆区和栈区两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收;函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理。
下面分别介绍 Go 语言堆内存管理和栈内存管理。其中堆内存管理分内存分配器和垃圾回收器两大部分。

堆内存管理

内存分配器之设计原理

内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域。
Go 语言的内存分配器实现非常复杂,在分析内存分配器的实现之前,我们需要了解内存分配的设计原理,帮助我们更快掌握内存的分配过程。这里将要详细介内存分配器的分配方法以及 Go 语言内存分配器的分级分配方法、虚拟内存布局和地址空间。

分配方法

编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator),这两种分配方法有着不同的实现机制和特性,下面依次介绍它们的分配过程。

线性分配器
线性分配(Bump Allocator)是在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置。
线性分配器有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存(需要垃圾回收器)。垃圾回收算法分标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能。

空闲链表分配器
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:

go内存管理_第1张图片
空闲链表分配器

因为不同的内存块以链表的方式连接,所以使用这种方式分配内存的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度就是O(n)。Go语言将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块。
Go 语言会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。

分级分配

线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的的机制,它比 glibc 中的 malloc 函数还要快很多。Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存根据将对象根据大小分类,并按照类别实施不同的分配策略。

对象大小
Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种:

类别 大小
微对象 (0, 16B)
小对象 [16B, 32KB]
大对象 (32KB, +∞)

因为程序中的绝大多数对象的大小都在 32KB 以下,而申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能。

多级缓存
内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存。

线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,就会使用中心缓存作为补充解决小对象的内存分配问题;在遇到 32KB 以上的对象时,内存分配器就会选择页堆直接分配大量的内存。

这种多层级的内存分配设计与计算机操作系统中的多级缓存也有些类似,因为多数的对象都是小对象,我们可以通过线程缓存和中心缓存提供足够的内存空间,发现资源不足时就从上一级组件中获取更多的内存资源。

虚拟内存布局

这里会介绍 Go 语言堆区内存地址空间的设计以及演进过程,在 Go 语言 1.10 以前的版本,堆区的内存空间都是连续的;但是在 1.11 版本,Go 团队使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制以及在特殊场景下可能出现的问题。

稀疏内存是 Go 语言在 1.11 中提出的方案,使用稀疏的内存布局能移除堆大小的上限。不过因为基于稀疏内存的内存管理失去了内存的连续性这一假设,这也使内存管理变得更加复杂:

go内存管理_第2张图片
二维稀疏内存

如上图所示,运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个单元都会管理 64MB 的内存空间:

type heapArena struct {
    bitmap [heapArenaBitmapBytes]byte
    spans [pagesPerArena]*mspan
    pageInUse [pagesPerArena / 8]uint8
    pageMarks [pagesPerArena / 8]uint8
    zeroedBase uintptr
}

这种设计将原有的连续大内存切分成稀疏的小内存,而用于管理这些内存的元信息也被切分成了小块。

  • spans 区域存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;

  • bitmap 用于标识 arena 区域中的那些地址保存了对象,位图中的每个字节都会表示堆区中的 32 字节是否包含空闲;

  • zeroedBase 字段指向了该结构体管理的内存的基地址。

数组最大长度是 4,194,304(2^32-1),因为每一个指针占用 8 字节的内存空间,所以元信息的总大小为 32MB。由于每个 runtime.heapArena 都会管理 64MB 的内存,整个堆区最多可以管理 256TB 的内存,这比之前的 512GB 多好几个数量级。

由于内存的管理变得更加复杂,上述改动对垃圾回收稍有影响,大约会增加 1% 的垃圾回收开销,不过这也是我们为了解决已有问题必须付出的成本。

内存分配器之管理组件

Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,下面介绍这几种最重要组件对应的数据结构 runtime.mspanruntime.mcacheruntime.mcentralruntime.mheap,我们会详细介绍它们在内存分配器中的作用以及实现。

go内存管理_第3张图片
Go 程序的内存布局

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会被分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan

每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存。

在 amd64 的 Linux 操作系统上,runtime.mheap 会持有 4,194,304 runtime.heapArena,每一个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。

内存管理单元

runtime.mspan 是 Go 语言内存管理的基本单元,该结构体中包含 nextprev 两个字段,它们分别指向了前一个和后一个 runtime.mspan

type mspan struct {
    next *mspan
    prev *mspan
    ...
}

串联后的上述结构体会构成如下双向链表,运行时会使用 runtime.mSpanList 存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。

go内存管理_第4张图片
内存管理单元与双向链表

因为相邻的管理单元会互相引用,所以我们可以从任意一个结构体访问双向链表中的其他节点。

页和内存
每个 runtime.mspan 都管理 npages 个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍,该结构体会使用下面的这些字段来管理内存页的分配和回收:

type mspan struct {
    startAddr uintptr // 起始地址
    npages    uintptr // 页数
    freeindex uintptr //扫描页中空闲对象的初始索引

    allocBits  *gcBits //标记内存的占用情况
    gcmarkBits *gcBits //标记内存的回收情况
    allocCache uint64 //`allocBits` 的补码,可以用于快速查找内存中未被使用的内存
    ...
}

runtime.mspan 会以两种不同的视角看待管理的内存,当结构体管理的内存不足时,运行时会以页为单位向堆申请内存:

go内存管理_第5张图片
内存管理单元与页

当用户程序或者线程向 runtime.mspan 申请内存时,该结构会使用 allocCache 字段以对象为单位在管理的内存中快速查找待分配的空间:

go内存管理_第6张图片
内存管理单元与对象

如果我们能在内存中找到空闲的内存单元,就会直接返回,当内存中不包含空闲的内存时,上一级的组件 runtime.mcache 会为调用 runtime.mcache.refill 更新内存管理单元以满足为更多对象分配内存的需求。

状态
运行时会使用 runtime.mSpanStateBox 结构体存储内存管理单元的状态 runtime.mSpanState

type mspan struct {
    ...
    state       mSpanStateBox
    ...
}

该状态可能处于 mSpanDeadmSpanInUsemSpanManualmSpanFree 四种情况。当 runtime.mspan 在空闲堆中,它会处于 mSpanFree 状态;当 runtime.mspan 已经被分配时,它会处于 mSpanInUsemSpanManual 状态,这些状态会在遵循以下规则发生转换:

  • 在垃圾回收的任意阶段,可能从 mSpanFree 转换到 mSpanInUsemSpanManual
  • 在垃圾回收的清除阶段,可能从 mSpanInUsemSpanManual 转换到 mSpanFree
  • 在垃圾回收的标记阶段,不能从 mSpanInUsemSpanManual 转换到 mSpanFree

设置 runtime.mspan 结构体状态的读写操作必须是原子性的避免垃圾回收造成的线程竞争问题。

跨度类
runtime.spanClassruntime.mspan 结构体的跨度类,它决定了内存管理单元中存储的对象大小和个数:

type mspan struct {
    ...
    spanclass   spanClass
    ...
}

Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_sizeruntime.class_to_allocnpages 等变量中:

class bytes/obj bytes/span objects tail waste max waste
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 32 8192 256 0 46.88%
4 48 8192 170 32 31.52%
5 64 8192 128 0 23.44%
6 80 8192 102 32 19.07%
66 32768 32768 1 0 12.50%

上表展示了对象大小从 8B 到 32KB,总共 66 种跨度类的大小、存储的对象数以及浪费的内存空间,以表中的第四个跨度类为例,跨度类为 4 的 runtime.mspan 中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时,最多会浪费 31.52% ((48−33)∗170+328192=0.31518)的资源。
除了上述 66 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象,我们会在后面详细介绍大对象的分配过程,在这里就不展开说明了。

跨度类中除了存储类别的 ID 之外,它还会存储一个 noscan 标记位,该标记位表示对象是否包含指针,垃圾回收会对包含指针的 runtime.mspan 结构体进行扫描。我们可以通过下面的几个函数和方法了解 ID 和标记位的底层存储方式:

func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

func (sc spanClass) sizeclass() int8 {
    return int8(sc >> 1)
}

func (sc spanClass) noscan() bool {
    return sc&1 != 0
}

runtime.spanClass 是一个 uint8 类型的整数,它的前 7 位存储着跨度类的 ID,最后一位表示是否包含指针,该类型提供的两个方法能够帮我们快速获取对应的字段。

线程缓存

runtime.mcache 是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 67 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中:

go内存管理_第7张图片
线程缓存与内存管理单元

线程缓存在刚刚被初始化时是不包含 runtime.mspan 的,只有当用户程序申请内存时才会从上一级组件获取新的 runtime.mspan 满足内存分配的需求。

初始化
运行时在初始化处理器时会调用 runtime.allocmcache 初始化线程缓存,该函数会在系统栈中使用 runtime.mheap 中的线程缓存分配器初始化新的 runtime.mcache 结构体:

func allocmcache() *mcache {
    var c *mcache
    systemstack(func() {
        lock(&mheap_.lock)
        c = (*mcache)(mheap_.cachealloc.alloc())
        c.flushGen = mheap_.sweepgen
        unlock(&mheap_.lock)
    })
    for i := range c.alloc {
        c.alloc[i] = &emptymspan
    }
    return c
}

就像我们在上面提到的,初始化后的 runtime.mcache 中的所有 runtime.mspan 都是空的占位符 emptymspan

替换
runtime.mcache.refill 方法会为线程缓存获取一个指定跨度类的内存管理单元,被替换的单元不能包含空闲的内存空间,而获取的单元中需要至少包含一个空闲对象用于分配内存:

func (c *mcache) refill(spc spanClass) {
    s := c.alloc[spc]
    s = mheap_.central[spc].mcentral.cacheSpan()
    c.alloc[spc] = s
}

如上述代码所示,该函数会从中心缓存中申请新的 runtime.mspan 存储到线程缓存中,这也是向线程缓存中插入内存管理单元的唯一方法。

微分配器
线程缓存中还包含几个用于分配微对象的字段,下面的这三个字段组成了微对象分配器,专门为 16 字节以下的对象申请和管理内存:

type mcache struct {
    tiny             uintptr
    tinyoffset       uintptr
    local_tinyallocs uintptr
}

微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny 会指向堆中的一篇内存,tinyOffset 是下一个空闲内存所在的偏移量,最后的 local_tinyallocs 会记录内存分配器中分配的对象个数。

中心缓存

runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:

type mcentral struct {
    lock      mutex
    spanclass spanClass
    nonempty  mSpanList
    empty     mSpanList
    nmalloc uint64
}

每一个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.mSpanList,分别存储包含空闲对象的列表和不包含空闲对象的链表(共 67 * 2个链表):

go内存管理_第8张图片
中心缓存和内存管理单元

该结构体在初始化时,两个链表都不包含任何内存,程序运行时会扩容结构体持有的两个链表,nmalloc 字段也记录了该结构体中分配的对象个数。

如果中心缓存没有在 nonempty 中找到可用的内存管理单元,就会继续遍历其持有的 empty 链表,我们在这里的处理与包含空闲对象的链表几乎完全相同。当找到需要回收的内存单元时,我们也会触发 runtime.mspan.sweep 进行清理,如果清理后的内存单元仍然不包含空闲对象,就会重新执行相应的代码:

func (c *mcentral) cacheSpan() *mspan {
    ...
    for s = c.empty.first; s != nil; s = s.next {
        if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
            c.empty.remove(s)
            s.sweep(true)
            freeIndex := s.nextFreeIndex()
            if freeIndex != s.nelems {
                s.freeindex = freeIndex
                goto havespan
            }
            goto retry // 不包含空闲对象
        }
        if s.sweepgen == sg-1 {
            continue
        }
        break
    }
    ...
}

如果 runtime.mcentral 在两个链表中都没有找到可用的内存单元,它会调用 runtime.mcentral.grow 触发扩容操作从堆中申请新的内存:
中心缓存的扩容方法 runtime.mcentral.grow 会根据预先计算的 class_to_allocnpagesclass_to_size 获取待分配的页数以及跨度类并调用 runtime.mheap.alloc 获取新的 runtime.mspan 结构:

func (c *mcentral) grow() *mspan {
    npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
    size := uintptr(class_to_size[c.spanclass.sizeclass()])

    s := mheap_.alloc(npages, c.spanclass, true)
    if s == nil {
        return nil
    }

    n := (npages << _PageShift) >> s.divShift * uintptr(s.divMul) >> s.divShift2
    s.limit = s.base() + size*n
    heapBitsForAddr(s.base()).initSpan(s)
    return s
}

获取了 runtime.mspan 之后,我们会在上述方法中初始化 limit 字段并清除该结构在堆上对应的位图。

页堆

runtime.mheap 是内存分配的核心结构体,Go 语言程序只会存在一个全局的结构,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段。

页堆中包含一个长度为 134 的 runtime.mcentral 数组,其中 67 个为跨度类需要 scan (地址)的中心缓存,另外的 67 个是 noscan (非地址)的中心缓存:

go内存管理_第9张图片
页堆与中心缓存列表

我们在设计原理一节中已经介绍过 Go 语言所有的内存空间都由如下所示的二维矩阵 runtime.heapArena 管理的,这个二维矩阵管理的内存可以是不连续的:

go内存管理_第10张图片
页堆管理的内存区域

runtime.mheap 是内存分配器中的核心组件,运行时会通过它的 runtime.mheap.alloc 方法在系统栈中获取新的 runtime.mspan

func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
    var s *mspan
    systemstack(func() {
        if h.sweepdone == 0 {
            h.reclaim(npages)
        }
        s = h.allocSpan(npages, false, spanclass, &memstats.heap_inuse)
    })
    ...
    return s
}

为了阻止内存的大量占用和堆的增长,我们在分配对应页数的内存前需要先调用 runtime.mheap.reclaim 方法回收一部分内存,接下来我们将通过 runtime.mheap.allocSpan 分配新的内存管理单元,我们会将该方法的执行过程拆分成两个部分:

  1. 从堆上分配新的内存页和内存管理单元 runtime.mspan
  2. 初始化内存管理单元并将其加入 runtime.mheap 持有内存单元列表;

首先我们需要在堆上申请 npages 数量的内存页并初始化 runtime.mspan

func (h *mheap) allocSpan(npages uintptr, manual bool, spanclass spanClass, sysStat *uint64) (s *mspan) {
    gp := getg()
    base, scav := uintptr(0), uintptr(0)
    pp := gp.m.p.ptr()
    if pp != nil && npages < pageCachePages/4 {
        c := &pp.pcache
        base, scav = c.alloc(npages)
        if base != 0 {
            s = h.tryAllocMSpan()
            if s != nil && gcBlackenEnabled == 0 && (manual || spanclass.sizeclass() != 0) {
                goto HaveSpan
            }
        }
    }

    if base == 0 {
        base, scav = h.pages.alloc(npages)
        if base == 0 {
            h.grow(npages)
            base, scav = h.pages.alloc(npages)
            if base == 0 {
                throw("grew heap, but no adequate free space found")
            }
        }
    }
    if s == nil {
        s = h.allocMSpanLocked()
    }
    ...
}

上述方法会通过处理器的页缓存 runtime.pageCache 或者全局的页分配器 runtime.pageAlloc 两种途径从堆中申请内存:

  1. 如果申请的内存比较小,获取申请内存的处理器并尝试调用 runtime.pageCache.alloc 获取内存区域的基地址和大小;
  2. 如果申请的内存比较大或者线程的页缓存中内存不足,会通过 runtime.pageAlloc.alloc 在页堆上申请内存;
  3. 如果发现页堆上的内存不足,会尝试通过 runtime.mheap.grow 进行扩容并重新调用 runtime.pageAlloc.alloc 申请内存;
    1. 如果申请到内存,意味着扩容成功;
    2. 如果没有申请到内存,意味着扩容失败,宿主机可能不存在空闲内存,运行时会直接中止当前程序;

无论通过哪种方式获得内存页,我们都会在该函数中分配新的 runtime.mspan 结构体;该方法的剩余部分会通过页数、内存空间以及跨度类等参数初始化它的多个字段:

func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
    ...
HaveSpan:
    s.init(base, npages)

    ...

    s.freeindex = 0
    s.allocCache = ^uint64(0)
    s.gcmarkBits = newMarkBits(s.nelems)
    s.allocBits = newAllocBits(s.nelems)
    h.setSpans(s.base(), npages, s)
    return s
}

在上述代码中,我们通过调用 runtime.mspan.init 方法以及设置参数初始化刚刚分配的 runtime.mspan 结构并通过 runtime.mheaps.setSpans 方法建立页堆与内存单元的联系。

扩容
runtime.mheap.grow 方法会向操作系统申请更多的内存空间,传入的页数经过对齐可以得到期望的内存大小,我们可以将该方法的执行过程分成以下几个部分:

  1. 通过传入的页数获取期望分配的内存空间大小以及内存的基地址;
  2. 如果 arena 区域没有足够的空间,调用 runtime.mheap.sysAlloc 从操作系统中申请更多的内存;
  3. 扩容 runtime.mheap 持有的 arena 区域并更新页分配器的元信息;
  4. 在某些场景下,调用 runtime.pageAlloc.scavenge 回收不再使用的空闲内存页。

内存分配器之内存分配

堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    mp := acquirem()
    mp.mallocing = 1

    c := gomcache()
    var x unsafe.Pointer
    noscan := typ == nil || typ.ptrdata == 0
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            // 微对象分配
        } else {
            // 小对象分配
        }
    } else {
        // 大对象分配
    }

    publicationBarrier()
    mp.mallocing = 0
    releasem(mp)

    return x
}

上述代码使用 runtime.gomcache 获取了线程缓存并通过类型判断类型是否为指针类型。我们从这个代码片段可以看出 runtime.mallocgc 会根据对象的大小执行不同的分配逻辑,在前面的章节也曾经介绍过运行时根据对象大小将它们分成微对象、小对象和大对象,这里会根据大小选择不同的分配逻辑:

  • 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
  • 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存;
  • 大对象 (32KB, +∞) — 直接在堆上分配内存;

我们会依次介绍运行时分配微对象、小对象和大对象的过程,梳理内存分配的核心执行流程。

微对象

Go 语言运行时将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。

微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的,在默认情况下,内存块的大小为 16 字节。maxTinySize 的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize 越小,内存浪费就会越少,不过无论如何调整,8 的倍数都是一个很好的选择。

go内存管理_第11张图片
微分配器的工作原理

如上图所示,微分配器已经在 16 字节的内存块中分配了 12 字节的对象,如果下一个待分配的对象小于 4 字节,它就会直接使用上述内存块的剩余部分,减少内存碎片,不过该内存块只有在 3 个对象都被标记为垃圾时才会被回收。

线程缓存 runtime.mcache 中的 tiny 字段指向了 maxTinySize 大小的块,如果当前块中还包含大小合适的空闲内存,运行时会通过基地址和偏移量获取并返回这块内存:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            off := c.tinyoffset
            if off+size <= maxTinySize && c.tiny != 0 {
                x = unsafe.Pointer(c.tiny + off)
                c.tinyoffset = off + size
                c.local_tinyallocs++
                releasem(mp)
                return x
            }
            ...
        }
        ...
    }
    ...
}

当内存块中不包含空闲的内存时,下面的这段代码会从先线程缓存找到跨度类对应的内存管理单元 runtime.mspan,调用 runtime.nextFreeFast 获取空闲的内存;当不存在空闲内存时,我们会调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取可分配的内存块:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            ...
            span := c.alloc[tinySpanClass]
            v := nextFreeFast(span)
            if v == 0 {
                v, _, _ = c.nextFree(tinySpanClass)
            }
            x = unsafe.Pointer(v)
            (*[2]uint64)(x)[0] = 0
            (*[2]uint64)(x)[1] = 0
            if size < c.tinyoffset || c.tiny == 0 {
                c.tiny = uintptr(x)
                c.tinyoffset = size
            }
            size = maxTinySize
        }
        ...
    }
    ...
    return x
}

获取新的空闲内存块之后,上述代码会清空空闲内存中的数据、更新构成微对象分配器的几个字段 tinytinyoffset 并返回新的空闲内存。

小对象

小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象,小对象的分配可以被分成以下的三个步骤:

  1. 确定分配对象的大小以及跨度类 runtime.spanClass
  2. 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;
  3. 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据;

确定待分配的对象大小以及跨度类需要使用预先计算好的 size_to_class8size_to_class128 以及 class_to_size 字典,这些字典能够帮助我们快速获取对应的值并构建 runtime.spanClass

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        ...
        } else {
            var sizeclass uint8
            if size <= smallSizeMax-8 {
                sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
            } else {
                sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
            }
            size = uintptr(class_to_size[sizeclass])
            spc := makeSpanClass(sizeclass, noscan)
            span := c.alloc[spc]
            v := nextFreeFast(span)
            if v == 0 {
                v, span, _ = c.nextFree(spc)
            }
            x = unsafe.Pointer(v)
            if needzero && span.needzero != 0 {
                memclrNoHeapPointers(unsafe.Pointer(v), size)
            }
        }
    } else {
        ...
    }
    ...
    return x
}

在上述代码片段中,我们会重点分析两个函数和方法的实现原理,它们分别是 runtime.nextFreeFastruntime.mcache.nextFree,这两个函数会帮助我们获取空闲的内存空间。runtime.nextFreeFast 会利用内存管理单元中的 allocCache 字段,快速找到该字段中位 1 的位数,我们在上面介绍过 1 表示该位对应的内存空间是空闲的:

func nextFreeFast(s *mspan) gclinkptr {
    theBit := sys.Ctz64(s.allocCache)
    if theBit < 64 {
        result := s.freeindex + uintptr(theBit)
        if result < s.nelems {
            freeidx := result + 1
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }
            s.allocCache >>= uint(theBit + 1)
            s.freeindex = freeidx
            s.allocCount++
            return gclinkptr(result*s.elemsize + s.base())
        }
    }
    return 0
}

找到了空闲的对象后,我们就可以更新内存管理单元的 allocCachefreeindex 等字段并返回该片内存了;如果我们没有找到空闲的内存,运行时会通过 runtime.mcache.nextFree 找到新的内存管理单元:

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
    s = c.alloc[spc]
    freeIndex := s.nextFreeIndex()
    if freeIndex == s.nelems {
        c.refill(spc)
        s = c.alloc[spc]
        freeIndex = s.nextFreeIndex()
    }

    v = gclinkptr(freeIndex*s.elemsize + s.base())
    s.allocCount++
    return
}

在上述方法中,如果我们在线程缓存中没有找到可用的内存管理单元,会通过前面介绍的 runtime.mcache.refill 使用中心缓存中的内存管理单元替换已经不存在可用对象的结构体,该方法会调用新结构体的 runtime.mspan.nextFreeIndex 获取空闲的内存并返回。

大对象

运行时对于大于 32KB 的大对象会单独处理,我们不会从线程缓存或者中心缓存中获取内存管理单元,而是直接在系统的栈中调用 runtime.largeAlloc 函数分配大片的内存:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        ...
    } else {
        var s *mspan
        systemstack(func() {
            s = largeAlloc(size, needzero, noscan)
        })
        s.freeindex = 1
        s.allocCount = 1
        x = unsafe.Pointer(s.base())
        size = s.elemsize
    }

    publicationBarrier()
    mp.mallocing = 0
    releasem(mp)

    return x
}

runtime.largeAlloc 函数会计算分配该对象所需要的页数,它会按照 8KB 的倍数为对象在堆上申请内存:

func largeAlloc(size uintptr, needzero bool, noscan bool) *mspan {
    npages := size >> _PageShift
    if size&_PageMask != 0 {
        npages++
    }
    ...
    s := mheap_.alloc(npages, makeSpanClass(0, noscan), needzero)
    s.limit = s.base() + size
    heapBitsForAddr(s.base()).initSpan(s)
    return s
}

申请内存时会创建一个跨度类为 0 的 runtime.spanClass 并调用 runtime.mheap.alloc 分配一个管理对应内存的管理单元。

垃圾收集器之设计原理

为了在不影响用户程序的情况下回收废弃的内存需要付出非常多的努力,Java 的垃圾收集机制是一个很好的例子,Java 8 中包含线性、并发、并行标记清除和 G1 四个垃圾收集器,想要理解它们的工作原理和实现细节需要花费很多的精力。
相信很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行,Go 语言在早期也使用这种策略实现垃圾收集,但是今天的实现已经复杂了很多。
用户程序(Mutator)会通过内存分配器(Allocator)在堆上申请内存,而垃圾收集器(Collector)负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。

标记清除

标记清除(Mark-Sweep)算法是最常见的垃圾收集算法,同java的标记清除算法。标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  1. 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  2. 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表;

垃圾收集器从垃圾收集的根对象出发,递归遍历这些对象指向的子对象并将所有可达的对象标记成存活;标记阶段结束后,垃圾收集器会依次遍历堆中的对象并清除其中的垃圾,整个过程需要标记对象的存活状态,用户程序在垃圾收集的过程中也不能执行,我们需要用到更复杂的机制来解决 STW 的问题。

三色抽象

为了解决原始标记清除算法带来的长时间 STW,多数现代的追踪式垃圾收集器都会实现三色标记算法的变种以缩短 STW 的时间。三色标记算法将程序中的对象分成白色、黑色和灰色三类:

  • 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
  • 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
go内存管理_第12张图片
三色的对象

在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。

go内存管理_第13张图片
三色标记垃圾收集器的执行过程

三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤:

  1. 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
  2. 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
  3. 重复上述两个步骤直到对象图中不存在灰色对象;

当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾,下面是使用三色标记垃圾收集器执行标记后的堆内存,堆中只有对象 D 为待回收的垃圾:

go内存管理_第14张图片
三色标记后的堆

因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW,在如下所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。

go内存管理_第15张图片
三色标记与用户程序

本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性,想要并发或者增量地标记对象还是需要使用屏障技术。

屏障技术

内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前的多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证代码对内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种:

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象(标记过程中)或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;
go内存管理_第16张图片
三色不变性

上图分别展示了遵循强三色不变性和弱三色不变性的堆内存,遵循上述两个不变性中的任意一个,我们都能保证垃圾收集算法的正确性,而屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。

Go 语言中使用的两种写屏障技术,分别是 Dijkstra 提出的插入写屏障和 Yuasa 提出的删除写屏障,屏障技术保证三色不变性和垃圾收集器的正确性。

  • 插入写屏障。当执行类似 *slot = ptr 的表达式时,通过 shade 函数尝试改变指针的颜色。如果 ptr 指针是白色的,那么该函数会将该对象设置成灰色,其他情况则保持不变。Dijkstra 的插入写屏障是一种相对保守的屏障技术,它会将有存活可能的对象都标记成灰色以满足强三色不变性。

*删除写屏障。在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。

增量和并发

传统的垃圾收集算法会在垃圾收集的执行期间暂停应用程序,一旦触发垃圾收集,垃圾收集器就会抢占 CPU 的使用权占据大量的计算资源以完成标记和清除工作,然而很多追求实时的应用程序无法接受长时间的 STW。
垃圾收集器一旦开始执行就会浪费大量的计算资源,为了减少应用程序暂停的最长时间和垃圾收集的总暂停时间,我们会使用下面的策略优化现代的垃圾收集器:

  • 增量垃圾收集 — 增量地标记和清除垃圾,降低应用程序暂停的最长时间;
  • 并发垃圾收集 — 利用多核的计算资源,在用户程序执行时并发标记和清除垃圾;

因为增量和并发两种方式都可以与用户程序交替运行,所以我们需要使用屏障技术保证垃圾收集的正确性;与此同时,应用程序也不能等到内存溢出时触发垃圾收集,因为当内存不足时,应用程序已经无法分配内存,这与直接暂停程序没有什么区别,增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停。

垃圾收集器之实现

Go 语言的垃圾收集可以分成清除终止、标记、标记终止和清除四个不同阶段,它们分别完成了不同的工作。

  1. 清理终止阶段;
    1. 暂停程序,所有的处理器在这时会进入安全点(Safe point);
    2. 如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
  2. 标记阶段;
    1. 将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队;
    2. 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;
    3. 开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;
    4. 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
    5. 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;
  3. 标记终止阶段;
    1. 暂停程序、将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序;
    2. 清理处理器上的线程缓存;
  4. 清理阶段;
    1. 将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
    2. 恢复用户程序,所有新创建的对象会标记成白色;
    3. 后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理;

运行时虽然只会使用 _GCoff_GCmark_GCmarktermination 三个状态表示垃圾收集的全部阶段,但是在实现上却复杂很多,本节将按照垃圾收集的不同阶段详细分析其实现原理。

全局变量
在垃圾收集中有一些比较重要的全局变量,在分析其过程之前,我们会先逐一介绍这些重要的变量,这些变量在垃圾收集的各个阶段中会反复出现,所以理解他们的功能是非常重要的,我们先介绍一些比较简单的变量:

  • runtime.gcphase 是垃圾收集器当前处于的阶段,可能处于 _GCoff_GCmark_GCmarktermination,Goroutine 在读取或者修改该阶段时需要保证原子性;
  • runtime.gcBlackenEnabled 是一个布尔值,当垃圾收集处于标记阶段时,该变量会被置为 1,在这里辅助垃圾收集的用户程序和后台标记的任务可以将对象涂黑;
  • runtime.gcController 实现了垃圾收集的调步算法,它能够决定触发并行垃圾收集的时间和待处理的工作;
  • runtime.gcpercent 是触发垃圾收集的内存增长百分比,默认情况下为 100,即堆内存相比上次垃圾收集增长 100% 时应该触发 GC,并行的垃圾收集器会在到达该目标前完成垃圾收集;
  • runtime.writeBarrier 是一个包含写屏障状态的结构体,其中的 enabled 字段表示写屏障的开启与关闭;
  • runtime.worldsema 是全局的信号量,获取该信号量的线程有权利暂停当前应用程序;

触发时机

运行时会通过如下所示的 runtime.gcTrigger.test 方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件时 — 允许垃圾收集、程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同的方式触发进行不同的检查:

  • runtime.sysmonruntime.forcegchelper — 后台运行定时检查和垃圾收集;
  • runtime.GC — 用户程序手动触发垃圾收集;
  • runtime.mallocgc — 申请内存时根据堆大小触发垃圾收集;
go内存管理_第17张图片
垃圾收集的触发

除了使用后台运行的系统监控器和强制垃圾收集助手触发垃圾收集之外,另外两个方法会从任意处理器上触发垃圾收集,这种不需要中心组件协调的方式就是在 v1.6 版本中引入的,接下来我们将展开介绍这三种不同的触发时机。

垃圾收集启动

垃圾收集在启动过程一定会调用 runtime.gcStart 函数,虽然该函数的实现比较复杂,但是它的主要职责就是修改全局的垃圾收集状态到 _GCmark 并做一些准备工作,我们会分以下几个阶段介绍该函数的实现:

  1. 两次调用 runtime.gcTrigger.test 方法检查是否满足垃圾收集条件;
  2. 暂停程序、在后台启动用于处理标记任务的工作 Goroutine、确定所有内存管理单元都被清理以及其他标记阶段开始前的准备工作;
  3. 进入标记阶段、准备后台的标记工作、根对象的标记工作以及微对象、恢复用户程序,进入并发扫描和标记阶段;

验证垃圾收集条件的同时,该方法还会在循环中不断调用 runtime.sweepone 清理已经被标记的内存单元,完成上一个垃圾收集循环的收尾工作:

func gcStart(trigger gcTrigger) {
    for trigger.test() && sweepone() != ^uintptr(0) {
        sweep.nbgsweep++
    }

    semacquire(&work.startSema)
    if !trigger.test() {
        semrelease(&work.startSema)
        return
    }
    ...
}

在验证了垃圾收集的条件并完成了收尾工作后,该方法会通过 semacquire 获取全局的 worldsema 信号量、调用 runtime.gcBgMarkStartWorkers 启动后台标记任务、在系统栈中调用 runtime.stopTheWorldWithSema 暂停程序并调用 runtime.finishsweep_m 保证上一个内存单元的正常回收:

func gcStart(trigger gcTrigger) {
    ...
    semacquire(&worldsema)
    gcBgMarkStartWorkers()
    work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
    ...

    systemstack(stopTheWorldWithSema)
    systemstack(func() {
        finishsweep_m()
    })

    work.cycles++
    gcController.startCycle()
    ...
}

除此之外,上述过程还会修改全局变量 runtime.work 持有的状态,包括垃圾收集需要的 Goroutine 数量以及已完成的循环数。

在完成全部的准备工作后,该方法就进入了执行的最后阶段。在该阶段中,我们会修改全局的垃圾收集状态到 _GCmark 并依次执行下面的步骤:

  1. 调用 runtime.gcBgMarkPrepare 函数初始化后台扫描需要的状态;
  2. 调用 runtime.gcMarkRootPrepare 函数扫描栈上、全局变量等根对象并将它们加入队列;
  3. 设置全局变量 runtime.gcBlackenEnabled,用户程序和标记任务可以将对象涂黑;
  4. 调用 runtime.startTheWorldWithSema 启动程序,后台任务也会开始标记堆中的对象;

并发扫描与标记辅助

runtime.gcBgMarkWorker 是后台的标记任务执行的函数,该函数的循环中执行了对内存中对象图的扫描和标记,我们分三个部分介绍该函数的实现原理:

  1. 获取当前处理器以及 Goroutine 打包成 parkInfo 类型的结构体并主动陷入休眠等待唤醒;
  2. 根据处理器上的 gcMarkWorkerMode 模式决定扫描任务的策略;
  3. 所有标记任务都完成后,调用 runtime.gcMarkDone 方法完成标记阶段;

工作池
在调用 runtime.gcDrain 函数时,运行时会传入处理器上的 runtime.gcWork,这个结构体是垃圾收集器中工作池的抽象,它实现了一个生产者和消费者的模型,我们可以以该结构体为起点从整体理解标记工作:

go内存管理_第18张图片
垃圾收集器工作池

写屏障、根对象扫描和栈扫描都会向工作池中增加额外的灰色对象等待处理,而对象的扫描过程会将灰色对象标记成黑色,同时也可能发现新的灰色对象,当工作队列中不包含灰色对象时,整个扫描过程就会结束。

为了减少锁竞争,运行时在每个处理器上会保存独立的待扫描工作,然而这会遇到与调度器一样的问题 — 不同处理器的资源不平均,导致部分处理器无事可做,调度器引入了工作窃取来解决这个问题,垃圾收集器也使用了差不多的机制平衡不同处理器上的待处理任务。

runtime.gcWork.balance 方法会将处理器本地一部分工作放回全局队列中,让其他的处理器处理,保证不同处理器负载的平衡。

runtime.gcWork 为垃圾收集器提供了生产和消费任务的抽象,该结构体持有了两个重要的工作缓冲区 wbuf1wbuf2,这两个缓冲区分别是主缓冲区和备缓冲区:

type gcWork struct {
    wbuf1, wbuf2 *workbuf
    ...
}

type workbufhdr struct {
    node lfnode // must be first
    nobj int
}

type workbuf struct {
    workbufhdr
    obj [(_WorkbufSize - unsafe.Sizeof(workbufhdr{})) / sys.PtrSize]uintptr
}

当我们向该结构体中增加或者删除对象时,它总会先操作主缓冲区,一旦主缓冲区空间不足或者没有对象,就会触发主备缓冲区的切换;而当两个缓冲区空间都不足或者都为空时,会从全局的工作缓冲区中插入或者获取对象,该结构体相关方法的实现都非常简单,这里就不展开分析了。

扫描对象
运行时会使用 runtime.gcDrain 函数扫描工作缓冲区中的灰色对象,它会根据传入 gcDrainFlags 的不同选择不同的策略:

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    gp := getg().m.curg
    preemptible := flags&gcDrainUntilPreempt != 0
    flushBgCredit := flags&gcDrainFlushBgCredit != 0
    idle := flags&gcDrainIdle != 0

    initScanWork := gcw.scanWork
    checkWork := int64(1<<63 - 1)
    var check func() bool
    if flags&(gcDrainIdle|gcDrainFractional) != 0 {
        checkWork = initScanWork + drainCheckThreshold
        if idle {
            check = pollWork
        } else if flags&gcDrainFractional != 0 {
            check = pollFractionalWorkerExit
        }
    }
    ...
}

  • gcDrainUntilPreempt — 当 Goroutine 的 preempt 字段被设置成 true 时返回;
  • gcDrainIdle — 调用 runtime.pollWork 函数,当处理器上包含其他待执行 Goroutine 时返回;
  • gcDrainFractional — 调用 runtime.pollFractionalWorkerExit 函数,当 CPU 的占用率超过 fractionalUtilizationGoal 的 20% 时返回;
  • gcDrainFlushBgCredit — 调用 runtime.gcFlushBgCredit 计算后台完成的标记任务量以减少并发标记期间的辅助垃圾收集的用户程序的工作量;

运行时会使用本地变量中的 check 函数检查当前是否应该退出标记任务并让出该处理器。当我们做完准备工作后,就可以开始扫描全局变量中的根对象了,这也是标记阶段中需要最先被执行的任务:

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    ...
    if work.markrootNext < work.markrootJobs {
        for !(preemptible && gp.preempt) {
            job := atomic.Xadd(&work.markrootNext, +1) - 1
            if job >= work.markrootJobs {
                break
            }
            markroot(gcw, job)
            if check != nil && check() {
                goto done
            }
        }
    }
    ...
}

扫描根对象需要使用 runtime.markroot 函数,该函数会扫描缓存、数据段、存放全局变量和静态变量的 BSS 段以及 Goroutine 的栈内存;一旦完成了对根对象的扫描,当前 Goroutine 会开始从本地和全局的工作缓存池中获取待执行的任务:

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    ...
    for !(preemptible && gp.preempt) {
        if work.full == 0 {
            gcw.balance()
        }

        b := gcw.tryGetFast()
        if b == 0 {
            b = gcw.tryGet()
            if b == 0 {
                wbBufFlush(nil, 0)
                b = gcw.tryGet()
            }
        }
        if b == 0 {
            break
        }
        scanobject(b, gcw)

        if gcw.scanWork >= gcCreditSlack {
            atomic.Xaddint64(&gcController.scanWork, gcw.scanWork)
            if flushBgCredit {
                gcFlushBgCredit(gcw.scanWork - initScanWork)
                initScanWork = 0
            }
            checkWork -= gcw.scanWork
            gcw.scanWork = 0

            if checkWork <= 0 {
                checkWork += drainCheckThreshold
                if check != nil && check() {
                    break
                }
            }
        }
    }
    ...
}

扫描对象会使用 runtime.scanobject,该函数会从传入的位置开始扫描,扫描期间会调用 runtime.greyobject 为找到的活跃对象上色。

func gcDrain(gcw *gcWork, flags gcDrainFlags) {
    ...
done:
    if gcw.scanWork > 0 {
        atomic.Xaddint64(&gcController.scanWork, gcw.scanWork)
        if flushBgCredit {
            gcFlushBgCredit(gcw.scanWork - initScanWork)
        }
        gcw.scanWork = 0
    }
}

当本轮的扫描因为外部条件变化而中断时,该函数会通过 runtime.gcFlushBgCredit 记录这次扫描的内存字节数用于减少辅助标记的工作量。

内存中对象的扫描和标记过程涉及很多位操作和指针操作,相关代码实现比较复杂,我们在这里就不展开介绍相关的内容了,感兴趣的读者可以将 runtime.gcDrain 作为入口研究三色标记的具体过程。

写屏障
写屏障是保证 Go 语言并发标记安全不可获取的技术,我们需要使用混合写屏障维护对象图的弱三色不变性,然而写屏障的实现需要编译器和运行时的共同协作。在 SSA 中间代码生成阶段,编译器会使用 cmd/compile/internal/ssa.writebarrier 函数在 StoreMoveZero 操作中加入写屏障,生成如下所示的代码:

if writeBarrier.enabled {
  gcWriteBarrier(ptr, val)
} else {
  *ptr = val
}

当 Go 语言进入垃圾收集阶段时,全局变量 runtime.writeBarrier 中的 enabled 字段会被置成开启,所有的写操作都会调用 runtime.gcWriteBarrier

TEXT runtime·gcWriteBarrier(SB),NOSPLIT,$28
    ...
    get_tls(BX)
    MOVL    g(BX), BX
    MOVL    g_m(BX), BX
    MOVL    m_p(BX), BX
    MOVL    (p_wbBuf+wbBuf_next)(BX), CX
    LEAL    8(CX), CX
    MOVL    CX, (p_wbBuf+wbBuf_next)(BX)
    CMPL    CX, (p_wbBuf+wbBuf_end)(BX)
    MOVL    AX, -8(CX)  // 记录值
    MOVL    (DI), BX
    MOVL    BX, -4(CX)  // 记录 *slot
    JEQ flush
ret:
    MOVL    20(SP), CX
    MOVL    24(SP), BX
    MOVL    AX, (DI) // 触发写操作
    RET

flush:
  ...
    CALL    runtime·wbBufFlush(SB)
  ...
    JMP ret

在上述汇编函数中,DI 寄存器是写操作的目的地址,AX 寄存器中存储了被覆盖的值,该函数会覆盖原来的值并通过 runtime.wbBufFlush 通知垃圾收集器将原值和新值加入当前处理器的工作队列,因为该写屏障的实现比较复杂,所以写屏障对程序的性能还是有比较大的影响,之前只需要一条指令完成的工作,现在需要几十条指令。

我们在上面提到过 Dijkstra 和 Yuasa 写屏障组成的混合写屏障在开启后,所有新创建的对象都需要被直接涂成黑色,这里的标记过程是由 runtime.gcmarknewobject 完成的:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if gcphase != _GCoff {
        gcmarknewobject(uintptr(x), size, scanSize)
    }
    ...
}

func gcmarknewobject(obj, size, scanSize uintptr) {
    markBitsForAddr(obj).setMarked()
    gcw := &getg().m.p.ptr().gcw
    gcw.bytesMarked += uint64(size)
    gcw.scanWork += int64(scanSize)
}

runtime.mallocgc 会在垃圾收集开始后调用该函数,获取对象对应的内存单元以及标记位 runtime.markBits 并调用 runtime.markBits.setMarked 直接将新的对象涂成黑色。

标记辅助
为了保证用户程序分配内存的速度不会超出后台任务的标记速度,运行时还引入了标记辅助技术,它遵循一条非常简单并且朴实的原则,分配多少内存就需要完成多少标记任务。每一个 Goroutine 都持有 gcAssistBytes 字段,这个字段存储了当前 Goroutine 辅助标记的对象字节数。在并发标记阶段期间,当 Goroutine 调用 runtime.mallocgc 分配新的对象时,该函数会检查申请内存的 Goroutine 是否处于入不敷出的状态:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    var assistG *g
    if gcBlackenEnabled != 0 {
        assistG = getg()
        if assistG.m.curg != nil {
            assistG = assistG.m.curg
        }
        assistG.gcAssistBytes -= int64(size)

        if assistG.gcAssistBytes < 0 {
            gcAssistAlloc(assistG)
        }
    }
    ...
    return x
}

申请内存时调用的 runtime.gcAssistAlloc 和扫描内存时调用的 runtime.gcFlushBgCredit 分别负责『借债』和『还债』,通过这套债务管理系统,我们能够保证 Goroutine 在正常运行的同时不会为垃圾收集造成太多的压力,保证在达到堆大小目标时完成标记阶段。

每个 Goroutine 持有的 gcAssistBytes 表示当前协程辅助标记的字节数,全局垃圾收集控制器持有的 bgScanCredit 表示后台协程辅助标记的字节数,当本地 Goroutine 分配了较多的对象时,可以使用公用的信用 bgScanCredit 偿还。

用户程序辅助标记的核心目的就是避免用户程序分配内存影响垃圾收集器完成标记工作的期望时间,它通过维护账户体系保证用户程序不会对垃圾收集造成过多的负担,一旦用户程序分配了大量的内存,该用户程序就会通过辅助标记的方式平衡账本,这个过程会在最后达到相对平衡,保证标记任务在到达期望堆大小时完成。

标记终止

当所有处理器的本地任务都完成并且不存在剩余的工作 Goroutine 时,后台并发任务或者辅助标记的用户程序会调用 runtime.gcMarkDone 通知垃圾收集器。当所有可达对象都被标记后,该函数会将垃圾收集的状态切换至 _GCmarktermination;如果本地队列中仍然存在待处理的任务,当前方法会将所有的任务加入全局队列并等待其他 Goroutine 完成处理:

func gcMarkDone() {
    ...
top:
    if !(gcphase == _GCmark && work.nwait == work.nproc && !gcMarkWorkAvailable(nil)) {
        return
    }

    gcMarkDoneFlushed = 0
    systemstack(func() {
        gp := getg().m.curg
        casgstatus(gp, _Grunning, _Gwaiting)
        forEachP(func(_p_ *p) {
            wbBufFlush1(_p_)
            _p_.gcw.dispose()
            if _p_.gcw.flushedWork {
                atomic.Xadd(&gcMarkDoneFlushed, 1)
                _p_.gcw.flushedWork = false
            }
        })
        casgstatus(gp, _Gwaiting, _Grunning)
    })

    if gcMarkDoneFlushed != 0 {
        goto top
    }
    ...
}

如果运行时中不包含全局任务、处理器中也不存在本地任务,那么当前垃圾收集循环中的灰色对象也就都标记成了黑色,我们就可以开始触发垃圾收集的阶段迁移了:

func gcMarkDone() {
    ...
    getg().m.preemptoff = "gcing"
    systemstack(stopTheWorldWithSema)

    ...

    atomic.Store(&gcBlackenEnabled, 0)
    gcWakeAllAssists()
    schedEnableUser(true)
    nextTriggerRatio := gcController.endCycle()
    gcMarkTermination(nextTriggerRatio)
}

上述函数在最后会关闭混合写屏障、唤醒所有协助垃圾收集的用户程序、恢复用户 Goroutine 的调度并调用 runtime.gcMarkTermination 进入标记终止阶段:

func gcMarkTermination(nextTriggerRatio float64) {
    atomic.Store(&gcBlackenEnabled, 0)
    setGCPhase(_GCmarktermination)

    _g_ := getg()
    gp := _g_.m.curg
    casgstatus(gp, _Grunning, _Gwaiting)

    systemstack(func() {
        gcMark(startTime)
    })
    systemstack(func() {
        setGCPhase(_GCoff)
        gcSweep(work.mode)
    })
    casgstatus(gp, _Gwaiting, _Grunning)
    gcSetTriggerRatio(nextTriggerRatio)
    wakeScavenger()

    ...

    injectglist(&work.sweepWaiters.list)
    systemstack(func() { startTheWorldWithSema(true) })
    prepareFreeWorkbufs()
    systemstack(freeStackSpans)
    systemstack(func() {
        forEachP(func(_p_ *p) {
            _p_.mcache.prepareForSweep()
        })
    })
    ...
}

我们省略了撒行数函数中很多数据统计的代码,包括正在使用的内存大小、本轮垃圾收集的暂停时间、CPU 的利用率等数据,这些数据能够帮助控制器决定下一轮触发垃圾收集的堆大小,除了数据统计之外,该函数还会调用 runtime.gcSweep 重置清理阶段的相关状态并在需要时阻塞清理所有的内存管理单元;_GCmarktermination 状态在垃圾收集中并不会持续太久,它会迅速转换至 _GCoff 并恢复应用程序,到这里垃圾收集的全过程基本上就结束了,用户程序在申请内存时才会惰性回收内存。

内存清理

垃圾收集的清理中包含对象回收器(Reclaimer)和内存单元回收器,这两种回收器使用不同的算法清理堆内存:

  • 对象回收器在内存管理单元中查找并释放未被标记的对象,但是如果 runtime.mspan 中的所有对象都没有被标记,整个单元就会被直接回收,该过程会被 runtime.mcentral.cacheSpan 或者 runtime.sweepone 异步触发;
  • 内存单元回收器会在内存中查找所有的对象都未被标记的 runtime.mspan,该过程会被 runtime.mheap.reclaim 触发;

runtime.sweepone 是我们在垃圾收集过程中经常会见到的函数,该函数会在堆内存中查找待清理的内存管理单元:

func sweepone() uintptr {
    ...
    var s *mspan
    sg := mheap_.sweepgen
    for {
        s = mheap_.sweepSpans[1-sg/2%2].pop()
        if s == nil {
            break
        }
        if state := s.state.get(); state != mSpanInUse {
            continue
        }
        if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
            break
        }
    }

    npages := ^uintptr(0)
    if s != nil {
        npages = s.npages
        if s.sweep(false) {
            atomic.Xadduintptr(&mheap_.reclaimCredit, npages)
        } else {
            npages = 0
        }
    }

    _g_.m.locks--
    return npages
}

查找内存管理单元时会通过 statesweepgen 两个字段判断当前单元是否需要处理。如果内存单元的 sweepgen 等于 mheap.sweepgen - 2,那么就意味着当前单元需要被清理,如果等于 mheap.sweepgen - 1,那么当前管理单元就正在被清理。

所有的回收工作最终都是靠 runtime.mspan.sweep 完成的,该函数会根据并发标记阶段回收内存单元中的垃圾并清除标记以免影响下一轮垃圾收集。

栈空间管理

应用程序的内存一般会分成堆区和栈区两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收,我们在上面已经详细分析了堆内存的申请和释放过程,下面介绍 Go 语言栈内存的实现原理。

设计原理

栈区的内存一般由编译器自动进行分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而消亡,一般不会在程序中长期存在,这种线性的内存分配策略有着极高地效率,但是工程师也往往不能控制栈内存的分配,这部分工作基本都是由编译器自动完成的。

寄存器

寄存器是中央处理器(CPU)中的稀缺资源,它的存储能力非常有限,但是能提供最快的读写速度,充分利用寄存器的效率可以构建高性能的应用程序。寄存器在物理机上非常有限,然而栈区的操作就会使用到两个以上的寄存器,这足以说明栈内存在应用程序的重要性。
栈寄存器在是 CPU 寄存器中的一种,它的主要作用是跟踪函数的调用栈,Go 语言的汇编代码中包含 BP 和 SP 两个栈寄存器,它们分别存储了栈的基址指针和栈顶的地址,栈内存与函数调用的关系非常紧密,BP 和 SP 之间的内存就是当前函数的调用栈。

由于历史的设计问题,目前的栈区内存都是从高地址向低地址扩展的,当应用程序申请或者释放栈内存时只需要修改 SP 寄存器的值,这种线性的内存分配方式与堆内存相比更加快速,占用极少的额外开销。

线程栈

如果我们在 Linux 操作系统中执行 pthread_create 系统调用,进程会启动一个新的线程,如果用户没有通过软资源限制 RLIMIT_STACK 指定线程栈的大小,那么操作系统会根据架构选择不同的默认栈大小。

架构 默认栈大小
i386 2 MB
IA-64 32 MB
PowerPC 4 MB
x86_64 2 MB

多数架构上默认栈大小都在 2 ~ 4 MB 左右,极少数架构会使用 32 MB 作为默认大小,用户程序可以在分配的栈上存储函数参数和局部变量。然而这个固定的栈大小在某些场景下可能不是一个合适的值,如果一个程序需要同时运行几百个甚至上千个线程,那么这些线程中的绝大部分都只会用到很少的栈空间,而如果函数的调用栈非常深,固定的栈大小也无法满足用户程序的需求。

线程和进程都是代码执行的上下文(Context of Execution),但是如果一个应用程序中包含成百上千个执行上下文并且每个上下文都是线程,就会占用大量的内存空间并带来其他的额外开销,Go 语言在设计时认为执行上下文应该是轻量级的,所以在它实现了用户级的 Goroutine 作为执行上下文。

栈内存空间

Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多。Goroutine 的初始栈大小降低到了 2KB,进一步减少了 Goroutine 占用的内存空间。

分段栈
分段栈是 Go 语言在 v1.3 版本之前的实现,所有 Goroutine 在初始化时都会调用 runtime.stackalloc#go1.2 分配一块固定大小的内存空间,这块内存的大小由 StackMin#go1.2 表示,在 v1.2 版本中为 8KB:

void* runtime·stackalloc(uint32 n) {
    uint32 pos;
    void *v;
    if(n == FixedStack || m->mallocing || m->gcing) {
        if(m->stackcachecnt == 0)
            stackcacherefill();
        pos = m->stackcachepos;
        pos = (pos - 1) % StackCacheSize;
        v = m->stackcache[pos];
        m->stackcachepos = pos;
        m->stackcachecnt--;
        m->stackinuse++;
        return v;
    }
    return runtime·mallocgc(n, 0, FlagNoProfiling|FlagNoGC|FlagNoZero|FlagNoInvokeGC);
}

如果通过该方法申请的内存大小为固定的 8KB 或者满足其他的条件,运行时会在全局的栈缓存链表中找到空闲的内存块并作为新 Goroutine 的栈空间返回;在其余情况下,栈内存空间会从堆上申请一块合适的内存。

当 Goroutine 调用的函数层级或者局部变量需要的越来越多时,运行时会调用 runtime.morestack#go1.2runtime.newstack#go1.2 创建一个新的栈空间,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段。
一旦 Goroutine 申请的栈空间不在被需要,运行时会调用 runtime.lessstack#go1.2runtime.oldstack#go1.2 释放不再使用的内存空间。

分段栈机制虽然能够按需为当前 Goroutine 分配内存并且及时减少内存的占用,但是它也存在两个比较大的问题:

  1. 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split);
  2. 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时就会触发栈的扩容和缩容,带来额外的工作量;

连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  1. 在内存空间中分配更大的栈内存空间;
  2. 将旧栈中的所有内容复制到新的栈中;
  3. 将指向旧栈对应变量的指针重新指向新栈
  4. 销毁并回收旧栈的内存空间;

在扩容的过程中,最重要的是调整指针的第三步,这一步能够保证指向栈的指针的正确性,因为栈中的所有变量内存都会发生变化,所以原本指向栈中变量的指针也需要调整。指向栈中变量的指针只能在栈上,我们只需要调整栈中的所有变量就可以保证内存的安全了。

因为需要拷贝变量和调整指针,连续栈增加了栈扩容时的额外开销,但是通过合理栈缩容机制就能避免热分裂带来的性能问题,在 GC 期间如果 Goroutine 使用了栈内存的四分之一,那就将其内存减少一半,这样在栈内存几乎充满时也只会扩容一次,不会因为函数调用频繁扩缩容。

栈操作

Go 语言中的执行栈由 runtime.stack 结构体表示,该结构体中只包含两个字段,分别表示栈的顶部和栈的底部,每个栈结构体都表示范围 [lo, hi) 的内存空间:

type stack struct {
    lo uintptr
    hi uintptr
}

栈的结构虽然非常简单,但是想要理解 Goroutine 栈的实现原理,还是需要我们从编译期间和运行时两个阶段入手:

  1. 编译器会在编译阶段会通过 cmd/internal/obj/x86.stacksplit 在调用函数前插入 runtime.morestack 或者 runtime.morestack_noctxt 函数;
  2. 运行时在创建新的 Goroutine 时会在 runtime.malg 函数中调用 runtime.stackalloc 申请新的栈内存,并在编译器插入的 runtime.morestack 中检查栈空间是否充足;

需要注意的是,Go 语言的编译器不会为所有的函数插入 runtime.morestack,它只会在必要时插入指令以减少运行时的额外开销,编译指令 nosplit 可以跳过栈溢出的检查,虽然这能降低一些开销,不过固定大小的栈也存在溢出的风险。本节将分别分析栈的初始化、创建 Goroutine 时栈的分配、编译器和运行时协作完成的栈扩容以及当栈空间利用率不足时的缩容过程。

栈初始化

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpoolruntime.stackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间:

var stackpool [_NumStackOrders]struct {
    item stackpoolItem
    _    [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}

type stackpoolItem struct {
    mu   mutex
    span mSpanList
}

var stackLarge struct {
    lock mutex
    free [heapAddrBits - pageShift]mSpanList
}

这两个用于分配空间的全局变量都与内存管理单元 runtime.mspan 有关,我们可以认为 Go 语言的栈内存都是分配在堆上的,运行时初始化时调用的 runtime.stackinit 函数会在初始化这些全局变量:

func stackinit() {
    for i := range stackpool {
        stackpool[i].item.span.init()
    }
    for i := range stackLarge.free {
        stackLarge.free[i].init()
    }
}

从调度器和内存分配的经验来看,如果运行时只使用全局变量来分配内存的话,势必会造成线程之间的锁竞争进而影响程序的执行效率,栈内存由于与线程关系比较密切,所以我们在每一个线程缓存 runtime.mcache 中都加入了栈缓存减少锁竞争影响。

type mcache struct {
    stackcache [_NumStackOrders]stackfreelist
}

type stackfreelist struct {
    list gclinkptr
    size uintptr
}

运行时使用全局的 runtime.stackpool 和线程缓存中的空闲链表分配 32KB 以下的栈内存,使用全局的 runtime.stackLarge 和堆内存分配 32KB 以上的栈内存,提高本地分配栈内存的性能。

栈分配

运行时会在 Goroutine 的初始化函数 runtime.malg 中调用 runtime.stackalloc 分配一个大小足够栈内存空间,根据线程缓存和申请栈的大小,该函数会通过三种不同的方法分配栈空间:

  1. 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;
  2. 如果栈空间较大,从全局的大栈缓存 runtime.stackLarge 中获取内存空间;
  3. 如果栈空间较大并且 runtime.stackLarge 空间不足,在堆上申请一片大小足够内存空间;

我们在这里会按照栈的大小分两部分介绍运行时对栈空间的分配。在 Linux 上,_FixedStack = 2048_NumStackOrders = 4_StackCacheSize = 32768,也就是如果申请的栈空间小于 32KB 时,我们会在全局栈缓存池或者线程的栈缓存中初始化内存:

func stackalloc(n uint32) stack {
    thisg := getg()
    var v unsafe.Pointer
    if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
        order := uint8(0)
        n2 := n
        for n2 > _FixedStack {
            order++
            n2 >>= 1
        }
        var x gclinkptr
        c := thisg.m.mcache
        if stackNoCache != 0 || c == nil || thisg.m.preemptoff != "" {
            x = stackpoolalloc(order)
        } else {
            x = c.stackcache[order].list
            if x.ptr() == nil {
                stackcacherefill(c, order)
                x = c.stackcache[order].list
            }
            c.stackcache[order].list = x.ptr().next
            c.stackcache[order].size -= uintptr(n)
        }
        v = unsafe.Pointer(x)
    } else {
        ...
    }
    ...
}

runtime.stackpoolalloc 函数会在全局的栈缓存池 runtime.stackpool 中获取新的内存,如果栈缓存池中不包含剩余的内存,运行时会从堆上申请一片内存空间;如果线程缓存中包含足够的空间,我们可以从线程本地的缓存中获取内存,一旦发现空间不足就会调用 runtime.stackcacherefill 从堆上获取新的内存。

如果 Goroutine 申请的内存空间过大,运行时会查看 runtime.stackLarge 中是否有剩余的空间,如果不存在剩余空间,它也会从堆上申请新的内存:

func stackalloc(n uint32) stack {
    ...
    if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
        ...
    } else {
        var s *mspan
        npage := uintptr(n) >> _PageShift
        log2npage := stacklog2(npage)

        if !stackLarge.free[log2npage].isEmpty() {
            s = stackLarge.free[log2npage].first
            stackLarge.free[log2npage].remove(s)
        }

        if s == nil {
            s = mheap_.allocManual(npage, &memstats.stacks_inuse)
            osStackAlloc(s)
            s.elemsize = uintptr(n)
        }
        v = unsafe.Pointer(s.base())
    }

    return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

需要注意的是,因为 OpenBSD 6.4+ 对栈内存有特殊的需求,所以只要我们从堆上申请栈内存,就需要调用 runtime.osStackAlloc 函数做一些额外的处理,然而其他的操作系统就没有这种限制了。

栈扩容

编译器会在 cmd/internal/obj/x86.stacksplit 函数中为函数调用插入 runtime.morestack 运行时检查,它会在几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,我们会保存一些栈的相关信息并调用 runtime.newstack 创建新的栈:

func newstack() {
    thisg := getg()
    gp := thisg.m.curg
    ...
    preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
    if preempt {
        if !canPreemptM(thisg.m) {
            gp.stackguard0 = gp.stack.lo + _StackGuard
            gogo(&gp.sched)
        }
    }

    sp := gp.sched.sp
    if preempt {
        if gp.preemptShrink {
            gp.preemptShrink = false
            shrinkstack(gp)
        }

        if gp.preemptStop {
            preemptPark(gp)
        }

        gopreempt_m(gp)
    }
    ...
}

runtime.newstack 会先做一些准备工作并检查当前 Goroutine 是否发出了抢占请求,如果发出了抢占请求:

  1. 当前线程可以被抢占时,直接调用 runtime.gogo 触发调度器的调度;
  2. 如果当前 Goroutine 在垃圾回收被 runtime.scanstack 函数标记成了需要收缩栈,调用 runtime.shrinkstack
  3. 如果当前 Goroutine 被 runtime.suspendG 函数挂起,调用 runtime.preemptPark 被动让出当前处理器的控制权并将 Goroutine 的状态修改至 _Gpreempted
  4. 调用 runtime.gopreempt_m 主动让出当前处理器的控制权;

如果当前 Goroutine 不需要被抢占,也就意味着我们需要新的栈空间来支持函数调用和本地变量的初始化,运行时会先检查目标大小的栈是否会溢出:

func newstack() {
    ...
    oldsize := gp.stack.hi - gp.stack.lo
    newsize := oldsize * 2
    if newsize > maxstacksize {
        print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
        print("runtime: sp=", hex(sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
        throw("stack overflow")
    }

    casgstatus(gp, _Grunning, _Gcopystack)
    copystack(gp, newsize)
    casgstatus(gp, _Gcopystack, _Grunning)
    gogo(&gp.sched)
}

如果目标栈的大小没有超出程序的限制,我们会将 Goroutine 切换至 _Gcopystack 状态并调用 runtime.copystack 开始栈的拷贝,在拷贝栈的内存之前,运行时会通过 runtime.stackalloc 函数分配新的栈空间:

func copystack(gp *g, newsize uintptr) {
    old := gp.stack
    used := old.hi - gp.sched.sp

    new := stackalloc(uint32(newsize))
    ...
}

新栈的初始化和数据的复制是一个比较简单的过程,不过这不是整个过程中最复杂的地方,我们还需要将指向源栈中内存指向新的栈,在这期间我们需要分别调整以下的指针:

  1. 调用 runtime.adjustsudogs 或者 runtime.syncadjustsudogs 调整 runtime.sudog 结构体的指针;
  2. 调用 runtime.memmove 将源栈中的整片内存拷贝到新的栈中;
  3. 调用 runtime.adjustctxtruntime.adjustdefersruntime.adjustpanics 调整剩余 Goroutine 相关数据结构的指针;
func copystack(gp *g, newsize uintptr) {
    ...
    var adjinfo adjustinfo
    adjinfo.old = old
    adjinfo.delta = new.hi - old.hi // 计算新栈和旧栈之间内存地址差

    ncopy := used
    if !gp.activeStackChans {
        adjustsudogs(gp, &adjinfo)
    } else {
        adjinfo.sghi = findsghi(gp, old)
        ncopy -= syncadjustsudogs(gp, used, &adjinfo)
    }

    memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)

    adjustctxt(gp, &adjinfo)
    adjustdefers(gp, &adjinfo)
    adjustpanics(gp, &adjinfo)

    gp.stack = new
    gp.stackguard0 = new.lo + _StackGuard
    gp.sched.sp = new.hi - used
    gp.stktopsp += adjinfo.delta
    ...
    stackfree(old)
}

调整指向栈内存的指针都会调用 runtime.adjustpointer,该函数会利用 runtime.adjustinfo 计算的新栈和旧栈之间的内存地址差来调整指针。所有的指针都被调整后,我们就可以更新 Goroutine 的几个变量并通过 runtime.stackfree 释放原始栈的内存空间了。

栈缩容

runtime.shrinkstack 是用于栈缩容的函数,该函数的实现原理非常简单,其中大部分都是检查是否满足缩容前置条件的代码,核心逻辑只有以下这几行:

func shrinkstack(gp *g) {
    ...
    oldsize := gp.stack.hi - gp.stack.lo
    newsize := oldsize / 2
    if newsize < _FixedStack {
        return
    }
    avail := gp.stack.hi - gp.stack.lo
    if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
        return
    }

    copystack(gp, newsize)
}

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制 2KB,那么缩容的过程就会停止。

运行时只会在栈内存使用不足 1/4 时进行缩容,缩容也会调用扩容时使用的 runtime.copystack 函数开辟新的栈空间。

你可能感兴趣的:(go内存管理)