本文主要参考大佬分析内存的系列文章,参杂自己的思考,谨以记录和传播知识
Go语言的内存管理是建立在OS的内存管理之上的.
设计的目的是最大化的发挥OS内存管理层面的优势,避开导致低效情况.
现在计算机内存管理的方式都是一步步演变来的,最开始是非常简单的,后来为了满足各种需求而增加了各种各样的机制,越来越复杂
最原始的方式
我们可以把内存看成一个数组,每个数组元素的大小是 1B
(也就是 8 位bit)。CPU 通过内存地址来获取内存中的数据,内存地址可以看做成数组的游标(index)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y80zarft-1632489279656)(https://i.loli.net/2021/09/23/HJk2Q4O6injcbCx.png)]
CPU 在执行指令的时候,就是通过内存地址,将物理内存上的数据载入到寄存器,然后执行机器指令。但随着发展,出现了多任务的需求,也就是希望多个任务能同时在系统上运行。这就出现了一些问题:
举个例子,假设有一个程序,当代码运行到某处时,需要使用 100M
内存,其他时候 1M
内存就够;为了避免和其他程序冲突,程序初始化时,就必须申请独立 100M
内存以保证正常运行,这就是一种很大的浪费,因为这 100M
它大多数时候用不上,其他程序还不能用。
虚拟内存
虚拟内存的出现,很好的解决了上述的一系列问题.用户程序只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址.
所有程序统一使用一套连续虚拟地址,比如0x0000~0xffff. 从程序的角度来看,它觉得自己独享了一整块内存.不用考虑访问冲突的问题.系统会将虚拟地址翻译成物理地址,加载数据.
对于内存不够用的问题,虚拟内存本质上是将磁盘当成最终存储,而主存作为一个程序可以从虚拟内存申请很大的空间使用,比如1G; 但OS不会真的在物理内存上开辟1G的空间,它只是开辟了很小一块,比如1M给程序使用. 这样,程序在访问内存时,OS查看访问的地址是否能够转换成物理内存地址. 能则正常访问,不能则再开辟.这使得内存得到了更高效的利用.
如下图所示,每个进程所使用的虚拟地址空间都是一样的,但他们的虚拟地址会被映射到主存上的不同区域,甚至映射到磁盘上(当内存不够用时).
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CoZVHMgR-1632489279657)(https://i.loli.net/2021/09/23/VP2Uf9kIvxwzLTm.png)]
其实本质上很简单,就是OS将程序常用的数据放到内存里加速访问,不常用的数据放在磁盘上. 这一切对用户程序来说完全是透明的,用户程序可以假装所有数据都在内存里,然后通过内存地址去访问数据.在这背后,OS系统会自动将数据在主存和磁盘之间进行交换.
虚拟地址翻译
虚拟内存的实现方式,大多数都是通过 page
来实现的. OS虚拟内存空间分成一页一页的管理,每页的大小为 4K (当然这是可以配置的,不同OS不一样). 磁盘和主存之间的置换也是以 page
为单位来操作的. 4K 算是通过实践折中出来的通用值,太小会出现频繁的置换,太大了又浪费内存.
虚拟地址 —> 物理地址 的映射关系由 page table
记录,它其实就是一个数组,数组中每个元素叫做 page table entry
(简称PTE), PTE由一个有效位和n位地址字段构成,有效位标识这个虚拟地址是否分配了物理内存.
page 被操作系统放在物理内存的指定位置,CPU上有个 Memory Management Unit(MMU)
单元,CPU 把虚拟地址给 MMU, MMU 去物理内存中查询页表,得到实际的物理地址.当然,MMU 不会每次都去查的,它自己也有一份缓存叫Translation Lookaside Buffer(TLB)
,是为了加速地址翻译.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2xtzZiHs-1632489279658)(https://i.loli.net/2021/09/23/RJTwvuhBXMiPGn3.png)]
哲理:
你慢慢会发现整个计算机体系里面,缓存是无处不在的.
整个计算机体系就是建立在一级级的缓存之上的,无论软硬件.
让我们来看一下CPU内存访问的完整过程:
内存命中率
你可能已经发现, 上述的访问步骤中, 从第 4 步开始都是一些很繁琐的操作, 频繁的执行对性能影响很大. 毕竟访问磁盘是非常慢的, 它会引发程序性能的急剧下降. 如果内存访问到第3步成功结束了, 我们就说 page 命中了; 反之就是未命中, 或者说缺页, 表示它开始执行第4步了.
假设在 n 此内存访问中, 出现命中的次数是m, 那么 m / n * 100% 就是命中率, 这是衡量内存管理程序好快的一个很重要的指标.
如果物理内存不足了, 数据会在主存和磁盘之间频繁交换, 命中率很低, 性能出现急剧下降, 我们称这种现象叫内存颠簸. 这时你会出现系统的 swap 空间利用率开始增高, CPU 利用率中 iowait 占比开始增高.
大多数情况下,只要物理内存够用, 页命中率不会非常低, 不会出现内存颠簸的情况. 因为大多数程序都有一个特点, 就是局部性.
哲理:
局部性就是说被引用过一次的存储器位置, 很可能在后续再被引用多次; 而且在该位置附近的其他位置, 也很可能会在后续一段时间内被引用.
前面说过计算机到处使用一级级的缓存来提升性能, 归根到底就是利用局部性的特征, 如果没有这个特性, 一级级的缓存不会有那么大的作用. 所以一个局部性很好的程序运行速度会更快.
CPU Cache
随着技术发展, CPU 的运算速度越来越快, 但内存访问的速度却一直没什么突破. 最终导致了 CPU 访问主存就成了整个机器的性能瓶颈. CPU Cache 的出现就是为了解决这个问题, 在 CPU 和 主存之间再加了 Cache, 用来缓存一块内存中的数据, 而且还不止一个, 现在计算机一般都有3级Cache, 其中 L1 Cache 的访问速度和寄存器差不多.
现在访问数据的大致顺序是 CPU —> L1 Cache —> L2 Cache —> 主存 —> 磁盘. 从左到右访问速度越来越慢, 空间越来越大, 单位空间/byte 的价格越来越低.
现代存储器的整体层次结构大致如下图:
在这种架构下, 缓存的命中率就更加重要了, 因为系统会假定所有程序都是有局部性特征的. 如果某一级出现了未命中, 他就会将该级存储的数据更新最近使用的数据.
主存与存储器之间以 page (通常是4K) 为单位进行交换
cache 与 主存之间是以 cache line (通常是 64 Byte) 为单位交换的
eg: 一个验证命中率的问题, 循环一个数组为每个元素赋值
func Loop(nums []int, step int) {
l := len(nums)
for i := 0; i < step; i++ {
for j := i; j < l; j += step {
nums[j] = 4
}
}
}
参数step 为 1
时, 和普通一层循环一样. 假设 step 为 2, 则效果就是跳跃式遍历数组, step 越大, 访问跨度也就越大, 程序的局部性越不好.
下面是 nums 长度为 10000
, step = 1
和 step = 16
时的压测结果:
goos: darwin
goarch: amd64
BenchmarkLoopStep1-4 300000 5241 ns/op
BenchmarkLoopStep16-4 100000 22670 ns/op
可以看出, 两种遍历方式会出现 3 倍的性能差距. 这种问题最容易出现在多维数组的处理上, 比如遍历一个二维数组很容易出现局部性很差的代码.
程序的内存布局
最后看一下程序的内存布局. 现在我们知道了每个程序都有自己一套独立的地址空间可以使用, 比如 0x0000 ~ 0xffff, 但我们在用高级语言(C/Go) 写程序的时候, 很少直接使用这些地址. 我们都是通过变量名来访问数据的, 编译器会自动将我们的变量名转换成真正的虚拟地址.
那最终编译出来的二进制文件, 是如何被OS加载到内存中并执行的呢?
其实, OS 已经将一整块内存划分好了区域, 每个区域用来做不同的事情. 如图:
malloc
和 free
操作的内存就在这里; Go 主要靠GC 自动管理这部分其实, 现代的OS进程的内存区域没这么简单, 要比这复杂多了, 比如 内核区域, 共享库区域. 因为我们不是要开发一套操作系统, 细节可以忽略. 这里只需要记住堆空间和栈空间即可.
小结
它是参考 tcmalloc
实现的(细节上根据自身的需要做了一些优化), 其实就是利用好了OS 管理内存的特点, 扬长避短, 站在巨人的肩膀上!
Go的内存是自动管理的, 那它在背后帮我们做了什么呢?
池
程序动态申请内存空间, 是要使用系统调用的, 比如 Linux 系统上是调用 mmap
方法实现的. 但对于大型系统服务来说, 直接调用 mmap
申请内存,会有一定的代价. 比如:
如何解决上面的问题呢? 有经验的人, 可能很快就想到了解决方案, 那就是我们常说的 对象池
(也可以说是缓存)
假设系统需要频繁动态申请内存来存放一个数据结构, 比如 [10]int
. 那么我们完全可以在程序启动之初, 一次性申请几百甚至上千个 [10]int
. 这样就完美的解决了上面遇到的问题:
这样会造成一定的内存浪费, 我们可以定时检测对象池的大小, 保证可用对象的数量在一个合理的范围, 少了就提前申请, 多了就自动释放.
如果某种资源的申请和回收是昂贵的, 我们都可以通过建立资源池的方式来解决, 比如连接池, 内存池等等, 都是一个思路.
Go的内存管理本质
就是一个内存池, 只不过内部做了很多的优化. 比如自动伸缩内存池大小, 合理的切割内存块等等.
概念
page
: 内存页, 一块 8K 大小的内存空间. Go 与 OS之间的内存申请和释放都是以page
为单位的
span
: 内存块, 一个或多个连续的 page
组成一个span
. 如果把 page
比喻成工人, span
可以看成是小队, 工人被分成若干个队伍, 不同队伍干不同的(sizeclass)活
sizeclass
: 空间规格, 每个 span
都带有一个 sizeclass
, 标记着该 span
中的 page
应该如何使用. 标志着 span
是一个什么样的队伍.
object
: 对象, 用来存储一个变量数据内存空间, 一个 span
在初始化时,会被切割成一堆等大的object
. 假设 object
的大小是 16B, span
大小是 8K, 那么就会把span
中的 page
就会被初始化 8K / 16B = 512
个 object
. 所谓内存分配, 就是分配一个 object
出去.
内存碎片
系统(OS/各种runtime)在内存管理过程中, 会不可避免的出现一块块无法被使用的内存空间, 这就是内存管理的产物.
内部碎片
一般都是因为字节对齐(为什么要字节对齐呢?),如上面介绍 Tiny 对象分配的部分; 为了字节对齐, 会导致一部分空间直接被放弃掉, 不做分配使用.
外部碎片
一般时因为内存的不断分配和释放, 导致一些释放的小内存块分散在内存各处, 无法被用以分配. 不过Go的内存管理机制不会引起大量外部碎片.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cKfkTTBI-1632489279663)(https://i.loli.net/2021/09/23/HODtCgLBh2VZnJY.png)]
看图说话:
不同颜色代表不同的 span
不同span
的 sizeclass
不同, 表示里面的 page
将会按照不同的规格切割成一个个等大的 object
用作分配
测试某个版本初始堆内存的分配情况:
package main
import "runtime"
var stat runtime.MemStats
func main() {
runtime.ReadMemStats(&stat)
println(stat.HeapSys)
}
Go 在程序启动时会分配一块虚拟内存地址,结构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tuUfaBqg-1632489279663)(https://i.loli.net/2021/09/24/hr95up8EePvqJO1.png)]
span:用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个page, 已经使用了多大等等。
bitmap:存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
arena_start:将要分配给应用程序使用的空间
内存池 mheap
Go 的程序在启动之初, 会一次性从OS那里申请一大块内存作为内存池. 这块内存空间会放在一个叫 mheap
的 struct
中管理, mheap 负责将这一整块内存切割成不同的区域, 并将其中一部分的内存切割成合适的大小, 分配给用户使用.
mcentral
用途相同(sizecliass
相同, 用来存储哪种大小的对象)的 span
会以链表的形式组织在一起. 比如当分配一块大小为 n 的内存时, 系统计算 n 应该使用哪种 sizeclass
, 然后根据 sizeclass
的值去找到一个可用的 span
来用作分配. 其中 sizeclass
一共有67种(Go 1.5), 如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mo4o1Epl-1632489279664)(https://i.loli.net/2021/09/23/12fn5wmdcF3aQo8.png)]
找到合适的 span
后, 会从中取一个 object
返回给上层使用. 这些 span
被放在一个叫做 mcentral 的结构中管理.
mheap 将从 OS 那里申请过来的内存初始化成一个大 span
(sizeclass=0). 然后根据需要从这个大 span
中切出小 span
, 放在mcentral中来管理.
大 span
由 mheap.freelarge
和 mheap.busylarge
等管理.
如果 mcentral 中的 span
不够用了, 会从 mheap.freelarge
上再切一块, 如果 mheap.freelarge
空间不够, 会再次从OS那里申请内存重复上述步骤. 下面看看 mheap 和 mcentral 的数据结构:
type mheap struct {
// other fields
lock mutex
free [_MaxMHeapList]mspan // free lists of given length, 1M 以下
freelarge mspan // free lists length >= _MaxMHeapList, >= 1M
busy [_MaxMHeapList]mspan // busy lists of large objects of given length
busylarge mspan // busy lists of large objects length >= _MaxMHeapList
central [_NumSizeClasses]struct { // _NumSizeClasses = 67
mcentral mcentral
// other fields
}
// other fields
}
// Central list of free objects of a given size.
type mcentral struct {
lock mutex // 分配时需要加锁
sizeclass int32 // 哪种 sizeclass
nonempty mspan // 还有可用的空间的 span 链表
empty mspan // 没有可用的空间的 span 列表
}
这种方式可以避免出现外部碎片, 因为同一个 span 是按照固定大小分配和回收的, 不会出现不可利用的一小块内存把内存分割掉.
mcache
如果你阅读的比较仔细, 会发现上面的 mcentral 结构中有一个 lock 字段; 因为并发情况下, 很有可能多个线程同时从 mcentral 那里申请内存的, 必须要用锁来避免冲突.
但锁是低效的, 在高并发的服务中, 它会使内存申请成为整个系统的瓶颈; 所以在mcentral的前面又增加了一层 mcache.
每一个mcache和每一个处理器§是一一对应的, 也就是说每一个P 都有一个 mcache 成员. Goroutine 申请内存时, 首先从自身所在的P的mcache中分配, 如果 mcache 没有可用 span
, 再从 mcentral 中获取, 并填充到 mcache 中.
从 mcache 上分配内存空间是不需要加锁的, 因为在同一时间点, 一个P 只有一个线程在其上面运行, 不可能出现竞争. 没有了锁的限制, 大大加速了内存分配.
整体内存分配模型
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9h9sIHev-1632489279665)(https://i.loli.net/2021/09/23/cQjTxm7BaspVbOC.png)]
其它优化
zero size
有一些对象所需的内存大小是 0, 比如 [0]int, struct{}
, 这种类型的数据根本就不需要内存, 所以没必要走上面那么复杂的逻辑
系统会直接返回一个固定的内存地址, 源码如下:
func mallocgc(size uintptr, typ *_type, flags uint32) unsafe.Pointer {
// 申请的 0 大小空间的内存
if size == 0 {
return unsafe.Pointer(&zerobase)
}
//.....
}
测试代码:
package main
import (
"fmt"
)
func main() {
var (
a struct{}
b [0]int
c [100]struct{}
d = make([]struct{}, 1024)
)
fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c)
fmt.Printf("%p\n", &(d[0]))
fmt.Printf("%p\n", &(d[1]))
fmt.Printf("%p\n", &(d[1000]))
}
Tiny 对象
上面提到的 sizeclass
的span, 用来给 <= 8B
的对象使用, 所以像int32, byte, bool以及小字符串等常用的微小对象, 都会使用 sezeclass
= 1 的 span
, 但分配给他们 8B的空间, 大部分是用不上的. 并且, 这些类型使用频率非常高, 就会导致出现大量的内部碎片.
所以, Go尽量不使用 sizeclass
的span,而是将 < 16B
的对象统一视为 tiny 对象(tinysize). 分配时, 从sizeclass
=2的span中获取一个 16B 的object 用以分配. 如果存储的对象小于 16B, 这个空间会被暂时保存起来(mcache.tiny字段), 下次分配时会复用这个空间, 直到这个 object 用完为止. 如下图所示:
上图方式的空间利用率是 (1+2+8)/16*100% = 68.75%
原始的管理方式利用率是(1+2+8)/(8*3)*100% = 45.83%
源码中注释描述, 说是对tiny对象的特殊处理, 平均会节省 20% 左右的内存.
注意: 如果要存储的数据里有指针, 即使<= 8B
也不会作为tiny对象对待, 而是正常使用 sizeclass = 1
的span(为什么呢?).
大对象
最大的 spanclass
只能存放 32K 的对象. 如果一次性申请超过 32K 的内存, 系统(Go的runtime)会直接绕过 mcache和mcentral, 直接从mheap上获取, mheap中有一个freelarge
字段管理着超大 span
.
释放
没什么特别之处, 就是分配的反过程.
当 mcache 中存在较多空闲 span时, 会归还给 mcentral;
当 mcentral中存在较多空闲 span时, 会归还给 mheap;
当mheap再归还给OS.
Go内存管理也是一个金字塔
这种设计比较通用,比如现在常用的web服务设计, 为了提升系统性能, 一般都会设计成 客户端--->服务端cache--->服务端db
,也是金字塔.
将有限的计算资源布局成金字塔结构, 再将数据从热到冷分为几个层级, 放置在金字塔结构上, 调度器不断做调整, 将热数据放在金字塔顶端, 冷数据放在金字塔底层.
这种设计利用了计算的局部性特征, 认为冷热数据的交替时缓慢的. 所以最怕的就是, 数据访问出现冷热骤变. 在OS上称这种现象为内存颠簸
,系统架构上通常被说成缓存穿透
. 其实都是一个意思, 就是过度的使用了金字塔底层的资源.
源码调用流程图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FnRf2zcf-1632489279668)(https://i.loli.net/2021/09/23/XTWbCe7qU3ahSFp.png)]
小结
这种设计之所以快, 主要有一下几个原因:
当然, 这不是没有代价的, Go需要预申请大块内存, 这必然会出现一定的浪费, 不过好在现在内存比较廉价,不用太在意. Go号称时现代版的C, 时代在发展, 科技的进步,使得我们必然走上用空间换时间的道路上.
这套内部机制,使得开发高性能服务器容易很多, 通俗来讲就是坑少了. 一般情况下, 性能都不会太差. 我遇到过的导致内存分出现压力的主要有两种情况:
逃逸的由来,为什么要分析?
Go 语言较C 语言一个很大的优势就是自带 GC 功能, 可 GC 并不是没有代价的。
对比:
写 C 语言的时候,在一个函数内声明的变量,在函数退出后会自动释放掉,因为这些变量分配在栈上。如果想要变量的数据能在函数退出后还能访问,就需要调用 malloc
方法在堆上申请内存,如果程序不再需要这块内存了, 再调用 free
方法释放掉。
Go语言不需要你主动调用 malloc
来分配堆空间,编译器会自动分析,找出需要 malloc
的变量, 使用堆内存。编译器的这个分析过程就叫做逃逸分析。
所以,当你在一个函数中通过 dict := make(map[string]int)
创建一个map变量, 其背后的数据是放在栈空间还是堆空间上,是不一定的。这要看编译器分析的结果。
然而,逃逸分析并不是百分百准确的,它是有缺陷的。有的时候你会发现有些变量其实在栈空间上分配完全没有问题的,但编译后程序还是把这些数据放在了堆上。如果你了解Go语言编译器逃逸分析的机制,在写代码的时候就可以有意识的绕开这些缺陷,使你的程序更高效。
补充基础知识
Go 语言虽然在内存管理方面降低了编程门槛,即使你不了解堆栈也能正常开发,但如果你要在性能上较真的话,还是要掌握这些基础知识的。
这里不对堆内存和栈内存的区别做太多阐述。 简单来说就是,栈分配廉价,堆分配昂贵。栈空间会随着一个函数的结束自动释放,堆空间需要 GC 模块不断的跟踪扫描回收。如果对这两个概念有些迷糊,建议阅读下面两篇文章:
这里举一个小例子,来对比下堆栈的差别:
func stack() int {
// 变量 i 会在栈上分配
i := 10
return i
}
func heap() *int {
// 变量 j 会在堆上分配
j := 10
return &j
}
stack
函数中的变量 i
在函数退出会自动释放;而heap
函数返回的是对变量i
的引用,也就是说heap
退出后,变量i
还要能被访问,它会自动被分配到堆空间上。
逻辑的复杂度不言而喻,上面的汇编中可以看到,heap
函数调用了runtime.newobject()
方法,它会调用mallocgc
方法从mcache
上申请内存,申请的内部逻辑参考上一章节。堆内存分配不仅分配逻辑上比栈空间复杂,它最致命的是会带来很大的管理成本,Go语言要消耗很多的计算资源对其进行标记回收(也就是GC成本)。
不要以为使用了堆内存就一定会导致性能低下,使用栈内存一定会带来性能优势。实际项目中,系统的性能瓶颈一般都不会出现在内存分配上,千万不要盲目优化。要找到系统瓶颈,用数据驱动优化
逃逸分析
Go 编辑器会自动帮助我们找出需要进行动态分配的变量,它是在编译时追踪一个变量的生命周期,如果能确认一个数据只在函数空间内访问,不会被外部使用,则使用栈空间,否则就要使用堆空间。
命令:
go build -gcflags -m test.go
上面两个函数的编译结果为:
$ go build -gcflags -m test4.go
# command-line-arguments
.\test4.go:5:6: can inline stack
.\test4.go:10:6: can inline heap
.\test4.go:16:6: can inline main
.\test4.go:17:7: inlining call to stack
.\test4.go:18:6: inlining call to heap
.\test4.go:12:2: moved to heap: j
缺陷
需要使用堆空间则逃逸,这没什么可争议的。但编译器有时会将不需要使用堆空间的变量也逃逸掉,这就容易出现性能问题的大坑。
哪些容易导致逃逸呢?
对级间接赋值容易导致逃逸
扩展解释就是对某个引用类对象中的引用类成员进行赋值。
Go 语言中的引用类数据类型有 func
,interface
,slince
,map
,chan
,*Type
。
记住公式 Data.Field = Value, 如果 Data、Field都是引用类的数据类型,则会导致 Value 逃逸, 这里的 =
不单单只赋值,也表示参数传递。
实际的例子
函数变量
如果变量值是一个函数,函数的参数又是引用类型,则传递给它的参数都会逃逸
func test(i int) {}
func testEscape(i *int) {}
func main() {
i, j, m, n := 0, 0, 0, 0
t, te := test, testEscape // 函数变量
// 直接调用
test(m) // 不逃逸
testEscape(&n) // 不逃逸
// 间接调用
t(i) // 不逃逸
te(&j) // 逃逸
}
$ go build -gcflags -m test4.go
# command-line-arguments
.\test4.go:4:6: can inline test
.\test4.go:5:6: can inline testEscape
.\test4.go:7:6: can inline main
.\test4.go:12:6: inlining call to test
.\test4.go:13:12: inlining call to testEscape
.\test4.go:15:3: inlining call to test
.\test4.go:16:4: inlining call to testEscape
.\test4.go:5:17: i does not escape
上例中 te
的类型是func(*int)
,属于引用类型,则调用 te(&j)
形成了te
的参数成员*int
赋值的现象,即te.i = &j
会导致逃逸。其他几种调用都没有形成多级间接赋值的情况。
同理,如果函数的参数类型是slince
,map
或interface{}
都会导致参数逃逸。
func testSlice(slice []int) {}
func testMap(m map[int]int) {}
func testInterface(i interface{}) {}
func main() {
x, y, z := make([]int, 1), make(map[int]int), 100
ts, tm, ti := testSlice, testMap, testInterface
ts(x) // ts.slice = x 导致 x 逃逸
tm(y) // tm.m = y 导致 y 逃逸
ti(z) // ti.i = z 导致 z 逃逸
}
$ go build -gcflags -m test4.go
# command-line-arguments
.\test4.go:3:6: can inline testSlice
.\test4.go:4:6: can inline testMap
.\test4.go:5:6: can inline testInterface
.\test4.go:7:6: can inline main
.\test4.go:10:4: inlining call to testSlice
.\test4.go:11:4: inlining call to testMap
.\test4.go:12:4: inlining call to testInterface
.\test4.go:3:16: slice does not escape
.\test4.go:4:14: m does not escape
.\test4.go:5:20: i does not escape
.\test4.go:8:17: make([]int, 1) does not escape
.\test4.go:8:33: make(map[int]int) does not escape
.\test4.go:12:4: z does not escape
匿名函数的调用也是一样的,它本质上也是一个函数变量。
间接赋值
type Data struct {
data map[int]int
slice []int
ch chan int
inf interface{}
p *int
}
func main() {
d1 := Data{}
d1.data = make(map[int]int) // GOOD: does not escape
d1.slice = make([]int, 4) // GOOD: does not escape
d1.ch = make(chan int, 4) // GOOD: does not escape
d1.inf = 3 // GOOD: does not escape
d1.p = new(int) // GOOD: does not escape
d2 := new(Data) // d2 是指针变量, 下面为该指针变量中的指针成员赋值
d2.data = make(map[int]int) // BAD: escape to heap
d2.slice = make([]int, 4) // BAD: escape to heap
d2.ch = make(chan int, 4) // BAD: escape to heap
d2.inf = 3 // BAD: escape to heap
d2.p = new(int) // BAD: escape to heap
}
$ go build -gcflags -m test4.go
# command-line-arguments
.\test4.go:11:6: can inline main
.\test4.go:13:16: make(map[int]int) does not escape
.\test4.go:14:17: make([]int, 4) does not escape
.\test4.go:16:9: 3 does not escape
.\test4.go:17:12: new(int) does not escape
.\test4.go:19:11: new(Data) does not escape
.\test4.go:20:16: make(map[int]int) escapes to heap
.\test4.go:21:17: make([]int, 4) escapes to heap
.\test4.go:23:9: 3 escapes to heap
.\test4.go:24:12: new(int) escapes to heap
interface
只要使用了 interface 类型(注意,不是interface{}),那么赋值给它的变量一定会逃逸。因为interfaceVariable.Method()
先是间接的定位到它的实际值,再调用实际值的同名方法。执行时实际值作为参数传递给方法。相当于 interfaceVariable.Method.this = realValue
type Iface interface {
Dummy()
}
type Integer int
func (i Integer) Dummy() {}
func main() {
var (
iface Iface
i Integer
)
iface = i
iface.Dummy() // make i escape to heap
// 形成 iface.Dummy.i = i
}
$ go build -gcflags -m test4.go
# command-line-arguments
.\test4.go:7:6: can inline Integer.Dummy
.\test4.go:9:6: can inline main
.\test4.go:14:8: i escapes to heap
<autogenerated>:1: leaking param: .this
<autogenerated>:1: inlining call to Integer.Dummy
<autogenerated>:1: .this does not escape
引用类型的 channel
向 channel 中发送数据, 本质上就是为 channel 内部的成员赋值,就像给一个slince 中的某一项赋值一样。所以chan *Type
,chan map[Type]Type
, chan []Type
, chan interface{]}
类型都会导致发送到 channel 中的数据逃逸。
这本来也是情理之中的,发送给 channel 的数据是要与其他函数分享的,为了保证发送过去的指针依然可用,只能使用堆分配。
func test() {
var (
chInteger = make(chan *int)
chMap = make(chan map[int]int)
chSlice = make(chan []int)
chInterface = make(chan interface{})
a, b, c, d = 0, map[int]int{}, []int{}, 32
)
chInteger <- &a // 逃逸
chMap <- b // 逃逸
chSlice <- c // 逃逸
chInterface <- d // 逃逸
}
$ go build -gcflags -m test4.go
# command-line-arguments
.\test4.go:6:6: can inline test
.\test4.go:3:6: can inline main
.\test4.go:4:6: inlining call to test
.\test4.go:4:6: moved to heap: a
.\test4.go:4:6: map[int]int{} escapes to heap
.\test4.go:4:6: []int{} escapes to heap
.\test4.go:4:6: d escapes to heap
.\test4.go:12:3: moved to heap: a
.\test4.go:12:31: map[int]int{} escapes to heap
.\test4.go:12:40: []int{} escapes to heap
.\test4.go:17:14: d escapes to heap
可变参数
可变参数如 func(arg ...string)
实际与func(arg []string)
是一样的,会增加一层访问路径。这也是 fmt.Sprintf
总是会使参数逃逸的原因。
小结
熟悉堆栈概念可以让我们更容易看透Go程序的性能问题,并进行优化。
多级间接赋值会导致Go 编译器出现不必要的逃逸,在一些情况下,我们只需要修改一下数据结构就会使性能有大福提升。这也是很多人不推荐在Go中使用指针的原因,因为它会增加一级访问路径,而map
,slice
,interface{}
等类型是不可避免要用到的,为了减少不必要的逃逸,只能拿指针开刀了。
GC
编写Go代码不需要像写C/C++那样手动的malloc
和free
内存,因为malloc操作由Go编译器的逃逸分析机制帮我们加上了,而free
动作则是由GC机制来完成。
虽说GC是一个很好的特性,大大降低了编程门槛,但这是以损耗性能为代价的(以满足人们的美好生活为目的)。
这里突发一个插曲,以损耗性能为代价用词非常不合适。我们的现实生活是这样的,以满足人们的美好生活为目标,CPU负载高点 内存不够就再扩点,伴随着工业4.0时代的发展,物理资源的限制不再成为我们的卡点。
Go的GC机制是不断进化提升的,到现在也没有停止。其进化过程中主要有几个重要的里程碑:
标记清除
垃圾回收的算法很多,比如最常见的引用计数,节点复制等等。Go采用的是标记清除方式。当GC开始时,从 root 开始一层层扫描,这里的root取当前所有 goroutine 的栈和全局数据区的变量(主要是这两个地方)。扫描过程中把能被触达的 object 标记出来,那么堆空间未被标记的 object 就是垃圾了;最后遍历堆空间所有 object 对垃圾(未标记)的object 进行清除,清除完成则表示 GC 完成。 清除的 object 会被放回到 mcache 中以备后续分配使用。
在Go 内存管理提到过,Go的内存区域中有一个 bitmap
区域,就是用来存储 object 标记的。
最开始 Go的整个GC 过程需要STW,因为用户进程如果在GC过程中修改了变量的引用关系,可能会导致清理错误。举个例子,我们假设下面的变量堆空间:
A := new(struct {
B *int
})
如果GC已经扫描完了变量A,并对 A 和 B 进行了标记,如果没有STW,在执行清除之前,用户线程有可能会执行 A.B = new(int)
,那么这个新对象 new(int)
会因为没有标记而被清除。
Go GC的STW曾经是大家吐槽的焦点,因为它经常使你的系统卡住,造成几百毫秒的延迟。
并行清除
这个优化很简单,如上面所述,STW是为了阻止标记的错误,那么只需要对标记过程进行 STW,确保标记正确,清除过程是不需要STW的。
标记清除算法致命的缺点就在STW上,所以Go后期的很多优化都是针对STW能缩短它的时间,避免出现服务卡顿。
三色标记法
为了能让标记过程也并行,Go采用了三色标记+写屏障的机制。它的步骤大致如下:
malloc
新分配的 object,会先被标记成黑色再返回示意图:
还有一种情况:
标记过程中,堆上的 object 被赋值给了一个栈上指针,导致这个 object 没有被标记到。因为对栈上指针进行写入,写屏障是检测不到的(实际上并不是做不到,而是代价非常高,写屏障故意没有去管它)。下图展示了整个流程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-khsxxmjP-1632489279669)(https://i.loli.net/2021/09/24/3HfLdcpZSxgUsbM.png)]
为了解决这个问题,标记的最后阶段,还会回头重新扫描一下所有的栈空间,确保没有遗漏。而这个过程就需要启动STW了,否则并发场景会使上述问题反复重现。
GC 流程图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZJX1AwgK-1632489279670)(https://i.loli.net/2021/09/24/saAUq75G9lTPy8i.png)]
Hibrid Write Barrier
三色标记方式,需要再最后重新扫描一遍所有全局变量和goroutie栈空间,如果系统的 goroutine 很多,这个阶段耗时也会比较长,甚至会长达 100ms。毕竟 goroutine 很轻量,大型系统中,上百万的 goroutine 也是常有的事情。
1.8 版本引入了混合写屏障,其会在赋值前对旧数据置灰,再视情况对新值进行置灰,如图所示:
这样就不需要在最后回头重新扫描所有的 goroutine 的栈空间了,这使得整个 GC过程STW几乎可以忽略不计。
写屏障的伪代码:
writePointer(slot, ptr): // 1.8 之前
shade(ptr)
*slot = ptr
writePointer(slot, ptr): // 1.8 之后
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
混合写屏障会有一点小小的代价,就是上图中如果 C 没有赋值给 L,用户执行 B.next = nil
后,C 的确变成了垃圾,而我们却把它置灰了,使得C只能等到下一轮 GC 才能被回收了。
GC 过程创建的新对象直接标记成黑色也会带来这个问题,即使新 object 在扫描结束前变成了垃圾,这次 GC 也不会回收它,只能等下轮。
何时出发GC
GOGC
或者debug.SetGCPercent()
设置,默认是 100,表示当内存增长 100% 执行一次 GC。runtime.GC()
强制触发一次优化
小结
chan map[string][]*string
这种糟糕的数据结构。应用的层级越多,GC的成本也就越高。largespan
; 但对这个大空间的分配使用就需要我们自己写代码管理了,我们将会遇到和OS内存管理类似的问题,比如内存碎片、指针问题、并发问题等等,非常麻烦,写的不好性能反而会更差。好在已经有成熟的开源项目freecache和bigcache可直接使用。