Go是如何精致得进行内存管理?

2023年 9月 23日 23.3k 0

前言

Go语言抛弃C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和垃圾回收,将开发者从内存管理中释放出来。
所以我们在日常编写代码的时候不需要精通内存的管理,它确实很复杂。但是另一方面,如果你掌握了Go内存管理的基本概念和知识点,可以让你写出更高质量的,更压榨机器性能的代码;另外,还能帮助你更快更精准得定位Bug,快速解决问题。所以,作为进阶的Go开发,了解掌握Go的内存管理还是很有必要的。

相关背景

存储金字塔

冯·诺依曼计算机体系中的存储器,是用于存储程序和数据的。现代计算机系统中,一般都是采用“CPU寄存器-CPU高速缓存-内存-硬盘”的存储器结构。自上而下容量逐渐增大,速度逐渐减慢,单位价格逐渐降低。

图片-1.jpg
(图片来自于网络)

1、CPU寄存器:存储CPU正在使用的数据或指令。
2、CPU高速缓存:存储CPU近期要用到的数据和指令。
3、内存:存储正在运行或者将要运行的程序和数据。
4、硬盘:存储暂时不使用或者不能直接使用的程序和数据

虚拟内存

图片-2.jpg
(图片来自于网络)

物理内存:是指实际通过物理内存而获得的内存空间。
虚拟内存:与物理内存相反,是指根据系统需要从硬盘中虚拟的划出一部分存储空间。
虚拟内存技术就是对内存的一种抽象,有了这层抽象之后,程序运行进程的总大小可以超过实际可用的物理内存大小。每个进程都有自己的独立虚拟地址空间,然后通过CPU和MMU把虚拟内存地址转换为实际物理地址。

TCMalloc

TCMalloc全称是Thread Cache Malloc,是google为C语言开发的内存分配算法,是Go内存分配的起源。
TCMalloc内存分配算法的核心思想是把内存分为多级管理,从而降低锁的粒度,它将可用的堆内存采用二级分配的方式进行管理,每个线程都会自行维护一个独立的线程内存池,进行内存分配时优先从该线程内存池中分配, 当线程内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争 ,进一步的降低了内存并发访问的粒度。
Go的内存分配算法是基于TCMalloc内存分配算法实现的,借鉴了TCmalloc的思想。

图片-3.jpg

(图片来自于网络)

几个重要概念
Page: 操作系统对内存的管理同样是以页为单位,但TCMalloc中的Page和操作系统的中页是倍数关系,x64下Page大小为8KB。

Span:一组连续的Page被叫做Span,是TCMalloc内存管理的基本单位,有不同大小的Span,比如2个Page大的Span,16个Page大的Span。

ThreadCache:每个线程各自的Cache,每个ThreadCache包含多个不同规格的Span链表,叫做SpanList,内存分配的时候,可以根据要分配的内存大小,快速选择不同大小的SpanList,在SpanList上选择合适的Span,每个线程都有自己的ThreadCache,所以ThreadCache是无锁访问的。

CentralCache:中心Cache,所有线程共享的Cache,也是保存的SpanList,数量和ThreadCache中数量相同,当ThreadCache中内存不足时,可以从CentralCache中获取,当ThreadCache中内存太多时,可以放回CentralCache,由于CentralCache是线程共享的,所以它的访问需要加锁。

PageHeap:堆内存的抽象,同样当CentealCache中内存太多或太少时,都可从PageHeap中放回或获取,同样,PageHeap的访问也是需要加锁的。

管理分配

核心思想

Go在程序启动的时候,会分配一块连续的内存(虚拟的地址空间,还没有真正地分配内存),切成小块后自己进行管理,对内存的分配遵循以下思想。
1.每次从操作系统申请一大块内存, 以减少系统调用。
2.将申请到的大块内存按照特定大小预先切分成小块, 构成链表。
3.为对象分配内存时, 只需从大小合适的链表提取一个小块即可。
4.回收对象内存时, 将该小块内存重新归还到原链表, 以便复用。
5.如闲置内存过多, 则尝试归还部分内存给操作系统, 降低整体开销。

图片-4.jpg

(图片来自于网络)
内存管理由mcache、mcentral、mheap组成一个三级管理结构,本质上都是对mspan的管理,三者用于不同的目的来共同配合管理所有mspan。

mspan

mspan是Go中内存管理的基本单元,是由一片连续的8kB的page组成的内存块。但是小对象和大对象分配的位置不用,大对象在mheap上分配,mheap向操作系统申请新内存时,是向虚拟内存申请;小对象使用mcache的tiny分配器分配。
一组连续的Page组成1个Span,go把内存分为67个大小不同的span,并且大小是不固定的。

图片-5.png

图片-6.png
源码文件src/runtime/sizeclasses.go对67种span的定义(源码版本为go-1.17.1,本文下所有源码展示均为此版本)

延伸扩展:67种定义列表里面有一列的名称叫做"max waste",代表的是这个span下可能出现的最大内存浪费比例。举个例子解释,看第4个规格的情况:

class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
    4         32        8192      256           0     21.88%         32

4的对象最小内存长度为25字节(因为小于25的只会申请3或3以下的,要不到4),所以如果每个object都被25字节的对象申请,此时内存浪费最大,对应浪费率为:(32-25)/32 = 21.88%。

再通过观察整个列表可以看到,"max waste"一列并非线性递减的,熟悉Linux的同学应该猜到原因了,没错,这个设计跟大名鼎鼎的伙伴算法是非常相似的。

伙伴算法(buddy算法),就是将内存分成若干块,然后以最适合的方式满足程序内存需求的一种内存管理算法,伙伴算法是尽可能地在提高内存利用率的同时减少内存碎片。但是算法中,一个很小的块往往会阻碍一个大块的合并,一个系统中,对内存块的分配,大小是随机的,一片内存中仅一个小的内存块没有释放,旁边两个大的就不能合并,这也是造成上面现象的根因。(完整解读伙伴算法需要非常大的篇幅和难度,本文就不展开了,文章最后有参考链接,读者可自行研究)

回来主题,上面说到的Spans有3种类型:
空闲-span,没有对象,可以释放回操作系统,或重用于堆分配,或重用于堆栈内存。
正在使用-span,至少有一个堆对象,可能有更多的空间。
栈-span,用于 goroutine 堆栈。此跨度可以存在于堆栈中或堆中,但不能同时存在。

图片-7.png
源码文件src/runtime/mheap.go对mspan结构体的定义

type mspan struct {
	next *mspan     // 链表后向指针
	prev *mspan     // 链表前向指针
	list *mSpanList // 双端队列的head(已无实际用途)
	startAddr uintptr // span起始位置的地址指针
	npages    uintptr // 可供分配的页数
	...
	manualFreeList gclinkptr // 在mSpanManual的空闲对象
	allocCache uint64  // 在freeindex处的allocBits的缓存
	...
	allocBits  *gcBits // 标记span中的elem哪些是被使用的,哪些是未被使用的
	gcmarkBits *gcBits // 标记span中的elem哪些是被标记的,哪些是未被标记的
	speciallock mutex  // 互斥锁
}

管理组件说明

内存管理器由mcache, mcentral, mheap3种组件构成: 三级管理结构是为了方便对span进行管理,加速对span对象的访问和分配,这三个结构在runtime中分别有对应的mcache.go、mcentral.go、mheap.go文件。

mcache:保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问Go中是每个P拥有1个mcache,因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问。
mcentral:是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。
mheap:是堆内存的抽象,把从OS(系统)申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。

图片-8.png
熟悉的金字塔,熟悉的结构(图片来自于网络)

通俗的理解:mcache, mcentral, mheap就是对ThreadCache, CentralCache, PageHeap的继承沿用和基于go体系的优化处理版本。

分配流程

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

图片-9.png
源码文件src/runtime/malloc.go根据分配对象的大小选择对应的空间申请

大体上的分配流程:
1.>32KB 的对象,直接从mheap上分配。
2.16B &&

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论