首页
学习
活动
专区
工具
TVP
发布

golang学习之路--内存分配器

一.前言

笔者在经过了前期基础学习后,用 go 语言来实现自己面临的业务问题已经不再是问题,所以拥有了另一方面的求知欲--go 语言自身的各种包,各种机制是如何实现的,本章主要在探究 go 语言的内存分配器,希望能用本文讲清楚 go 语言内存分配器的机制,帮助大家更好地理解 go 语言的运行机制。

二.简介

不同于 c 语言使用 malloc 和 free 来主动管理内存,golang 让程序员避免了这一切繁杂的操作,它通过 escape analysis 来分配内存,通过 garbage collection(gc)来回收内存,本文主要介绍内存分配部分的工作。

三.详细解释

3.1 golang 内存分配时机

程序有两种内存,一种是堆栈(stack),一种是堆(heap),所有的堆内数据都被 GC 管理。

我们要明白什么时候程序会分配内存,在某些语言中是程序员主动申请的,在 go 语言中则依赖 escape analysis,越多的值在堆栈,程序运行越快(存取速度比堆要快,仅次于直接位于 CPU 中的寄存器,以下是内存分配的一些时机

1. goloang 只会把函数中确定不在函数结束后使用的变量放到堆栈,否则就会放到堆:一个值可能在构造该值的函数之后被引用-->变量上传

package main

func main(){	n:=answer()	println(*n/2)}
func answer() *int{	x:=42	return &x}

复制代码

使用命令 go build -gcflags="-m -l"得到结果

./main.go:10:2: moved to heap: x

复制代码

2.编译器确定值太大而无法放入堆栈

3.编译器在编译的时候无法得知这个值的具体大小

ps:将变量下传,变量还会留在堆栈中

type Reader interface{   Read(p []byte) (n int,err error)}//better than type Reader interface{   Read(n int) (b []byte,err error)}//因为从上面传参数下去用的是堆栈,从下面往上传,则会escape到堆,导致程序更慢

复制代码

3.2 golang 内存分配方式

3.2.1 TCMalloc

学习 go 语言的内存分配方式之前,我们先来看看另一个内存分配器-->TCMalloc,全称Thread-Caching Malloc

TCMalloc 有两个重要组成部分:线程内存(thread cache)页堆(page heap)

3.2.1.1 线程内存

每一个内存页都被分为多个固定分配大小规格的空闲列表(free list) 用于减少碎片化。这样每一个线程都可以获得一个用于无锁分配小对象的缓存,这样可以让并行程序分配小对象(<=32KB)非常高效。

如图所示,第一行就是长度为 8 字节的内存块,在 thread cache 内最大的为 256 字节的内存块

3.2.1.2 页堆

TCMalloc 管理的堆由一组页组成(page 一般大小为 4kb),一组连续的页面被表示为 span。当分配的对象大于 32KB,将使用页堆(Page Heap)进行内存分配,分配对象时,大的对象直接分配 Span,小的对象从 Span 中分配。

当没有足够的空间分配小对象则会到页堆获取内存。如果页堆页没有足够的内存,则页堆会向操作系统申请更多的内存。

3.2.1.3 内存分配器

将基于 Page 的对象分配,和 Page 本身的管理串联

每种规格的对象,都从不同的 Span 进行分配;每种规则的对象都有一个独立的内存分配单元:CentralCache。在一个 CentralCache 内存,我们用链表把所有 Span 组织起来,每次需要分配时就找一个 Span 从中分配一个 Object;当没有空闲的 Span 时,就从 PageHeap 申请 Span。

3.2.1.3 总结

最终我们得到结构图如下:

TCMalloc 针对不同的对象分配采用了不同的形式

每个线程都一个线程局部的 ThreadCache,按照不同的规格,维护了对象的链表;如果 ThreadCache 的对象不够了,就从 CentralCache 进行批量分配;如果 CentralCache 依然没有,就从 PageHeap 申请 Span;如果 PageHeap 没有合适的 Page,就只能从操作系统申请了。

在释放内存的时候,ThreadCache 依然遵循批量释放的策略,对象积累到一定程度就释放给 CentralCache;CentralCache 发现一个 Span 的内存完全释放了,就可以把这个 Span 归还给 PageHeap;PageHeap 发现一批连续的 Page 都释放了,就可以归还给操作系统。

由此,TCMalloc 的核心思路即:

把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

3.2.2 Go 内存分配器结构

3.2.2.1 初始化

Go 在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。

申请到的内存块被分配了三个区域,在 X64 上分别是 512MB,16GB,512GB 大小。

arena区域就是我们所谓的堆区,Go 动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan

bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中 4 个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB

spans区域存放mspan(也就是一些arena分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB。除以 8KB 是计算arena区域的页数,而最后乘以 8 是计算spans区域所有指针的大小。创建mspan的时候,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan

spans区域存放mspan(也就是一些arena分割的页组合起来的内存管理基本单元,后文会再讲)的指针,每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB。除以 8KB 是计算arena区域的页数,而最后乘以 8 是计算spans区域所有指针的大小。创建mspan的时候,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan

go 初始化的时候会将内存页分为如下 67 个不同大小的内存块,最大到 32kb

3.2.2.2 结构及流程总览

Go 的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于 16B)、一般对象(大于 16B,小于等于 32KB)、大对象(大于 32KB)。

大体上的分配流程:

1.32KB 的对象,直接从 mheap 上分配;

2.<=16B 的对象使用 mcache 的 tiny 分配器分配;

3.(16B,32KB] 的对象,首先计算对象的规格大小,然后使用 mcache 中相应规格大小的 mspan 分配;

4.如果 mcache 没有相应规格大小的 mspan,则向 mcentral 申请

5.如果 mcentral 没有相应规格大小的 mspan,则向 mheap 申请

6.如果 mheap 中也没有合适大小的 mspan,则向操作系统申请

3.2.2.3 自底向上名词解释

3.2.2.3.1 内存管理单元

mspan:Go 中内存管理的基本单元,是由一片连续的 8KB 的页组成的大块内存。是一个包含起始地址、mspan 规格、页的数量等内容的双端链表,mspan 由一组连续的页组成,按照一定大小划分成object

结构图

3.2.2.3.2 内存管理元件

mcache:Go 像 TCMalloc 一样为每一个 逻辑处理器(P)(Logical Processors) 提供一个本地线程缓存(Local Thread Cache)称作 mcache,所以如果 Goroutine 需要内存可以直接从 mcache 中获取,由于在同一时间只有一个 Goroutine 运行在 逻辑处理器(P)(Logical Processors) 上,所以中间不需要任何锁的参与。

对于每一种大小规格都有两个类型:

  1. scan -- 包含指针的对象。
  2. noscan -- 不包含指针的对象。

采用这种方法的好处之一就是进行垃圾回收时 noscan 对象无需进一步扫描是否引用其他活跃的对象。

(<=16B 的对象使用 mcache 的 tiny 分配器分配)

结构体

//path: /usr/local/go/src/runtime/mcache.go
type mcache struct {    alloc [numSpanClasses]*mspan}
numSpanClasses = _NumSizeClasses << 1

复制代码

结构图

central(mcentral):为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取。mcentral被所有的工作线程共同享有,存在多个 Goroutine 竞争的情况,因此会消耗锁资源。

//path: /usr/local/go/src/runtime/mcentral.go
type mcentral struct {    // 互斥锁    lock mutex     // 规格    sizeclass int32     // 尚有空闲object的mspan链表    nonempty mSpanList     // 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表    empty mSpanList     // 已累计分配的对象个数    nmalloc uint64 }

复制代码

结构图

mheap:代表 Go 程序持有的所有堆空间,Go 程序使用一个mheap的全局对象_mheap来管理堆内存。

mcentral没有空闲的mspan时,会向mheap申请。而mheap没有资源时,会向操作系统申请新内存。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。

同时我们也看到,mheap中含有所有规格的mcentral,所以,当一个mcachemcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan

//path: /usr/local/go/src/runtime/mheap.go
type mheap struct {    lock mutex    // spans: 指向mspans区域,用于映射mspan和page的关系    spans []*mspan     // 指向bitmap首地址,bitmap是从高地址向低地址增长的    bitmap uintptr 
    // 指示arena区首地址    arena_start uintptr     // 指示arena区已使用地址位置    arena_used  uintptr     // 指示arena区末地址    arena_end   uintptr 
    central [67*2]struct {        mcentral mcentral        pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte    }}

复制代码

结构图

arena:golang 中所有堆区的统称,以 x64 为例子就是 512GB 的虚拟地址空间。

四.之后目标

1.go 语言的垃圾回收

2.进程调度,线程调度,协程调度

3.虚拟内存

五.参考学习

https://www.youtube.com/watch?v=ZMZpH4yT7M0

https://www.linuxzen.com/go-memory-allocator-visual-guide.html

https://zhuanlan.zhihu.com/p/29216091

https://zhuanlan.zhihu.com/p/59125443

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/e760c46349bd7b443e38ac332
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券