Go内存分配与GC
28 Mar 2025Go语言GMP介绍
Go
语言相比Java
,有更好的并发能力(GMP
模型),同时其占用的服务器资源也较少,了解一下GMP
的理念。从操作系统层面来看,线程是指内核级线程,是操作系统最小调度单元,创建、销毁、调度交由内核完成,可充分利用多核。协程(用户线程)与线程存在M:1
的映射关系,从属于同一个内存级线程,无法并行,并且,一个协程阻塞会导致从属同一线程的所有协程无法执行。
Goroutine
经Golang
优化后的协程,其有如下特点:1)与线程存在映射关系,为M:N
;2)创建、销毁、调度在用户态完成,对内核透明,足够轻便;3)可利用多个线程,实现并行;4)通过调度器的斡旋,实现和线程间的动态绑定和灵活调度;5)栈空间大小可动态扩缩,因地制宜;
在/runtime/proc.go
的代码注释中,有对GMP
的解释,其核心数据结构在/runtime/runtime2.go
:
- 其中
g
是Golang
中对协程的抽象,g
有自己的运行栈,状态及执行的任务函数,g
需要绑定到p
上才能执行,p
就是g
的cpu
; m
即machine
,是golang
中对线程的抽象,m
不直接执行g
,而是先和p
绑定,由其代理执行;借由p
的存在,m
无需和g
绑死,也无需记录g
的状态信息,因此g
在全生命周期中可以跨m
执行。p
也即processor
,是golang
中的调度器。对于g
而言,p
是其调度器,g
只有被p
调度,才得以执行;对m
而言,p
是其执行代理,为其提供必要信息的同时,隐藏了繁杂的调度细节;
// Goroutine scheduler, Design doc at https://golang.org/s/go11sched.
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
gmp
模型其要点和调度规则如下:
M
是线程的抽象;G
是goroutine
;P
是承上启下的调度器;M
调度G
前,需要和P
绑定;- 全局有多个
M
和多个P
,但同时并行的G
的最大数量等于P
的数量; G
的存放队列有三类:P
的本地队列、全局队列和wait
队列(图中未展示,为io阻塞就绪态goroutine
队列);M
调度G
时,优先取P
本地队列,其次取全局队列,最后取wait
队列;这样的好处是,取本地队列时,可以接近于无锁化,减少全局锁竞争;- 为防止不同P的闲忙差异过大,设立
work-stealing
机制,本地队列为空的P
可以尝试从其他P
本地队列偷取一半的G
补充到自身队列;
GMP调度
g0
是一种特殊的调度协程,不执行用户函数,负责执行g
之间的切换调度,与m
的关系为1:1
。goroutine
的类型可分为两类:
- 1)负责调度普通
g
的g0
,与m
的关系为一对一; - 2)负责执行用户函数的普通
g
,被调度执行的g
永远在g
和g0
的状态间切换;
当g0
找到可执行g
时,会调用gogo
方法,调度g
执行用户定义的任务。当g
需要主动让渡时,会触发mcall
方法,将执行权限重新交给g0
;
广义”调度”可分为几种类型:
- 主动调度:一种用户主动执行让渡过程,主要方式是在代码中执行
runtime.Gosched
方法(runtime/proc.go
),此时当前g
会当让出执行权,主动进行队列等待下次被调度执行。 - 被动调度:因不满足某执行条件,
g
可能陷入阻塞态无法被调度,直到关注的条件达成后,g
才从阻塞中被唤醒(对应runtime/proc.go#gopark
方法,恢复则是goready
方法)。 - 正常调度:
g
中的执行任务已完成,g0
会将当前g
置为死亡状态,发起新一轮调度; - 抢占调度:倘若
g
执行系统调用超过指定的时长,且全局p
资源比较短缺,此时将p
和g
接绑,用解绑的p
用于其他g
的调度;
值得一提的是,前3
种调度方式都由m
下的g0
完成。而抢占调用则是由一个全局监控协程monitor g
来监控,倘若发现满足抢占调度的条件,则会从第三方的角度出手干预,主动发起该动作。从宏观上:
- 以
g0
->g
->g0
的一轮循环为例进行串联; g0
执行schedule()
函数,寻找到用于执行的g
;g0
执行execute()
方法,更新当前g
、p
的状态信息,并调用gogo()
方法,将执行权交给g
;g
因主动让渡(gosche_m()
)、被动调度(park_m()
)、正常结束(goexit0()
)等原因,调用m_call
函数,执行权重新回到g0
手中;g0
执行schedule()
函数,开启新一轮循环.
p
每执行61
次,会从全局队列中获取一个goroutine
进行执行,同时会额外将全局队列中的一个goroutine
放到本地队列中。若本地队列已满,则会返回来将本地队列中一半的g
放回全局队列中,帮助当前p
缓解执行压力;
Go内存模型与分配机制
在操作系统中,存在
寄存器
、高速缓存
、内存
和磁盘
,越接近cpu
存储的容量越小,其对应的价格就越高昂。页表、分页管理等机制来减少内存碎片。
Golang
中的内存模型,以空间换时间,一次缓存,多次复用。堆mheap
正是基于该思想,产生的数据结构。依次细化粒度,建立了 mcentral、mcache 的模型,下面对三者作个梳理:
mheap
:全局的内存起源,访问要加全局锁;mcentral
:每种对象大小规格(全局共划分为68
种)对应的缓存,锁的粒度也仅限于同一种规格以内;mcache
:每个P
(正是GMP
中的P
)持有一份的内存缓存,访问时无锁,多级规格,提高利用率;

内存单元mspan
page
和mspan
的2
个概念,page:最小的存储单元,默认大小为8KB
,mspan
大小为page
的整数倍,且从8B
到80
KB 被划分为67
种不同的规格,对应源代码在runtime/sizeclasses.go
,mspan
具有如下特点:
- 根据规格大小,产生了等级的制度,
mspan
是Golang
内存管理的最小单元,runtime/mheap.go
; - 消除了外部碎片,但不可避免会有内部碎片;
- 宏观上能提高整体空间利用率,同等级的
mspan
会从属同一个mcentral
,最终会被组织成链表,因此带有前后指针(prev
、next
); - 正是因为有了规格等级的概念,才支持
mcentral
实现细锁化,全局总览,留个印象; mspan
会基于bitMap
辅助快速找到空闲内存块(块大小为对应等级下的object
大小),此时需要使用到Ctz64
算法.

线程缓存mcache
mcache
是每个P
独有的缓存,因此交互无锁;mcache
将每种spanClass
等级的mspan
各缓存了一个,总数为2
(nocan
维度) *68
(大小维度)=136
;mcache
中还有一个为对象分配器tiny allocator
,用于处理小于16B
对象的内存分配;

中心缓存mcentral
要点:
- 每个
mcentral
对应一种spanClass
; - 每个
mcentral
下聚合了该spanClass
下的mspan
; mcentral
下的mspan
分为两个链表,分别为有空间mspan
链表partial
和满空间mspan
链表full
`;- 每个
mcentral
一把锁;

全局堆缓存mheap
- 对于
Golang
上层应用而言,堆是操作系统虚拟内存的抽象,以页(8KB)为单位,作为最小内存存储单元; - 负责将连续页组装成
mspan
,全局内存基于bitMap
标识其使用情况,每个bit
对应一页,为0
则自由,为1
则已被mspan
组装; - 通过
heapArena
聚合页,记录了页到mspan
的映射信息,建立空闲页基数树索引radix tree index
,辅助快速寻找空闲页; - 是
mcentral
的持有者,持有所有spanClass
下的mcentral
,作为自身的缓存,内存不够时,向操作系统申请,申请单位为 heapArena(64M);
对象分配流程
不论是以下哪种方式,最终都会殊途同归步入mallocgc
方法中,例如:new(T)
、&T{}
、make(xxxx)
,Golang
中,依据object
的大小,会将其分为下述三类:tiny
微对象(0, 16B)、small
小对象(16B,32KB)、large
大对象(32KB,正无穷).
对于微对象的分配流程:
- 从
P
专属mcache
的tiny
分配器取内存(无锁) - 根据所属的
spanClass
,从P
专属mcache
缓存的mspan
中取内存(无锁) - 根据所属的
spanClass
从对应的mcentral
中取mspan
填充到mcache
,然后从mspan
中取内存(spanClass
粒度锁); - 根据所属的
spanClass
,从mheap
的页分配器pageAlloc
取得足够数量空闲页组装成mspan
填充到mcache
,然后从mspan
中取内存(全局锁); mheap
`向操作系统申请内存,更新页分配器的索引信息,然后重复(4);
对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步; 对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.
Go垃圾回收原理
做
Java
的都对GC
比较熟悉,在JVM
中常见的GC
算法有:标记整理(Mark-Sweep)、标记压缩(Mark-Compact)、半空间复制(类似于G1
),通过引用计数寻找不可达对象,便于垃圾回收。
Go
中三色标记法
Golang GC
中用到的三色标记法属于标记清扫-算法下的一种实现,由荷兰的计算机科学家Dijkstra
提出,下面阐述要点:
- 对象分为三种颜色标记:黑、灰、白,黑对象代表,对象自身存活,且其指向对象都已标记完成;
- 灰对象代表,对象自身存活,但其指向对象还未标记完成;白对象代表,对象尙未被标记到,可能是垃圾对象
- 标记开始前,将根对象(全局对象、栈上局部变量等)置黑,将其所指向的对象置灰;
- 标记规则是,从灰对象出发,将其所指向的对象都置灰. 所有指向对象都置灰后,当前灰对象置黑;
- 标记结束后,白色对象就是不可达的垃圾对象,需要进行清扫;

为了应对并发情况下,对象标记出现漏标、多标的情况,可使用屏障机制。漏标问题的本质就是,一个已经扫描完成的黑对象指向了一个被灰\白对象删除引用的白色对象. 一套用于解决漏标问题的方法论称之为强弱三色不变式:
- 强三色不变式:白色对象不能被黑色对象直接引用;
- 弱三色不变式:白色对象可以被黑色对象引用,但要从某个灰对象出发仍然可达该白对象(间接破坏了(1)、(2)的联动);