Go 的内存管理话题很大, 一边学习, 一边记录, 持续更新.
提纲挈领
和 C,C++ 不同, 一般来说, Go 程序员无需关心变量是在堆上还是在栈上.
Go 语言中内存分配大致有 3 种模式: Stack,Heap,Fixed Size Segment.
栈
栈的概念类似传统 Linux 下 C,C++ 的概念, 即通过栈顶指针移动来分配内存. 一般用来存储局部变量, 函数参数等. 每个 Goroutine 都有自己的执行栈, 互相独立, 无需加锁. Goroutine 的栈是从 Heap 中分配的, 如果运行中栈需要扩展, 会使用连续栈技术进行扩展(通过 Heap 的操作 ---- allocate new,copy old to new,free old).
Goroutine 中的栈很小, 初始只有 8K, 因此创建代价很小.
堆和 GC
Go 语言支持 GC, 针对堆中的对象. 因此, 它在分配内存时需要着重考虑如下两个问题:
如何平衡内存分配效率和内存利用率?
如何支持快速的 GC 迭代, 不对业务造成较大冲击?
同时, 由于堆是所有 Goroutine 共有的, 因此需要加锁, 后面详解中可以观察下它是如何优化这个问题的.
具体到实现上, Go 采用了类似 tmalloc 的做法, 在系统调用上封装了一层, 减少直接系统调用的性能损耗; 同时, 会定期扫描释放长时间不使用的空闲内存. 具体实现技巧, 详见下文.
垃圾回收扫描会 STW, 从 Go1.5 起, 不会超过执行时间的 1/5(eg. 10ms of 50ms execution). 回收时只扫描堆上的对象, 但如果对象在栈上有引用, 也会分析栈上对应变量, 具体如下文.
The garbage collector has to be aware of both heap and stack allocated items. This is easy to see if you consider a heap allocated item, H, referenced by a stack allocated item, S. Clearly, the garbage collector cannot free H until S is freed and so the garbage collector must be aware of lifetime of S, the stack allocated item.
固定大小对象分配
顾名思义, 固定大小内存分配器. 一般用作, 内存管理对象的分配和回收, 如 mspan,mcache,mcentral 等; 另外也被用来分配 data segment 和 code segment. 在运行期内, data segment 的大小不能变化, 因此, 动态内存对象不能在 data segment 内分配.
Fixed sized segments are defined at compile time and do not change size at runtime. Read-write fixed size segments (e.g., the data segment) contain global variables while read-only segments (e.g., code segment and rodata segment) contain constant values and instructions.
性能优化
使用 runtime/pprof 和 go tool pprof 采样分析, 如果 growslices 和 newobject 调用占用很多, 即可考虑内存方面的优化 ---- 减少堆上分配(变量逃逸), 增加栈上分配.
Reuse memory you've already allocated.
- Restructure your code so the compiler can make stack allocations instead of heap allocations. Use go tool compile -m to help you identify escaped variables that will be heap allocated and then rewrite your code so that they can be stack allocated.
- Restructure your CPU bound code to pre-allocate memory in a few big chunks rather than continuously allocating small chunks.
栈
Goroutine 的栈管理模式和线程栈类似, 但实现差异巨大.
栈的内存分配
栈是从堆上进行分配的, 也可以直接从系统系统分配(stackFromSystem) -- 使用 mmap.
较小的栈是从 fixed-size 的 freelist 进行分配的(32k 以下), 更大的栈从空闲的 span 分配.
所谓的 fixed-size, 指固定大小栈的数组, 如 Linux 下为 2k,4k,8k,16k 这样的栈, fixed-order=4
fixed-size stack
32k 以下的栈, 有两种分配方式:
从全局的 stackpool 分配(加锁), 计算对应的 fixed-order, 从链表中获取, 如果失败, 从 heap 中分配一个手动管理的 span, 并串成 span 链表. 从 span 中获取一个 manualFreeList 的对象返回.
从线程 M 私有的 stackcache 进行分配, 计算对应的 fixed-order, 从链表中获取, 并更新统计.
stackpool 和 stackcache 类似, 也是 fixed-size 的数组, 元素是双向链表.
stackcache 是 M 的 mcache 中的成员, 线程私有, 无需加锁. 和 alloc 同级别(堆中的概念, 详见下文), 也是个数组[fixed-order], 每个元素是个单向链表.
更大的栈直接使用 span 块. 从 stackLarge 中分配, stackLarge 是一个全局的大 span 的缓存池 (使用加锁, 数组, 每个元素是一个双向链表), 如果 stackLarge.free[npages] 为空, 则从 heap 中分配一个手动管理的 span(肯定是 goroutine 释放时, 执行了 stack 的 free 操作放入 pool 或者 stackcache 中).
栈增长难题
在 C 语言中, 启动一个线程, 标准库 (the standard lib) 会负责分配一块内存 (默认 8M) 给线程当作栈来使用. 标准库会分配一个内存块, 然后告诉内核开始执行代码. 假设进程要执行一个高度递归的函数, 耗尽了栈的空间, 这时怎么办呢?
修改系统进程栈大小, 统一调大(设为 16M). 这样会浪费内存空间, 即使其他进程不需要那么多的栈空间, 依然会在启动进程时分配.
精确计算每个进程的栈空间大小, 这样过于繁琐, 对开发人员很不友好.
那么 Go 里面, 是如何处理这个问题的呢? Go 尝试给每个 Goroutine 按需分配栈空间. 在 Goroutine 创建时, 会初始分配 2K 内存用作栈空间. 当检测到栈空间不够时, 会调用 morestack 增长栈空间.
Go 中如何检测 Goroutine 栈空间耗尽呢?
Go 中每个函数在执行前, 会先检查是否已经用尽了栈空间(runtime 包装了代码逻辑, 无需开发者关注)
分离栈
在 Go1.5 之前, 采用分离栈 Segmented stacks 技术解决这个问题. 顾名思义, 在 Goroutine 执行中发现栈空间不够时, 会重新分配一块内存作为这个 Goroutine 的延续栈, 用指针串联, 无需虚拟地址空间上相邻. 当执行函数回归后, Goroutine 不需要这么多栈空间了, 会将之前分配的栈释放.
分离栈示意图
- +---------------+
- | |
- | unused |
- | stack |
- | space |
- +---------------+
- | Foobar |
- | |
- +---------------+
- | |
- | lessstack |
- +---------------+
- | Stack info |
- | |-----+
- +---------------+ |
- |
- |
- +---------------+ |
- | Foobar | |
- | | <---+
- +---------------+
- | REST of stack |
- | |
上图是执行 Foobar 函数耗尽栈空间后, 调用 morestack 形成的新的栈空间. 上面的是新分配的栈 stack1, 下面的是被耗尽的栈 stack.stack1 底部 stack info 是 stack 的相关信息. 在 stack1 分配后, 在 stack1 上重新执行 Foobar 函数, 执行完后, 陷入 lessstack 逻辑. lessstack 通过 stack info 找到原始返回地址, 返回旧栈 stack, 并释放 stack1.
Hot Split
但是分离栈有个瑕疵 ---- hot split, 如果恰好有一些 Goroutine 在这个临界点反复执行(如 for loop), 那么会造成反复的 morestack,lessstack,morestack,lessstack 操作(Shrinking stack is a relatively expensive op to runtime), 影响服务性能. 为了解决这个问题, 从 Go1.5 之后, 改为采用连续栈技术来处理栈增长的问题.
连续栈
连续栈(stack copying),Goroutine 初始栈大小和栈耗尽检测逻辑不变. 连续栈会新分配一个 size = stack*2 的新栈 stack1, 同时把 stack 的数据拷贝到 stack1. 这样, 后续栈缩减仅仅是一个 free 操作, 对 runtime 来说, 无需做任何事, 即使缩减后再次要扩充, runtime 也无需做任何操作, 可以复用之前分配的空间.
栈拷贝的细节
主要是栈里面如果有指针, 那么 copy 后, 指针的地址也要相应改变.
栈里面的对象指可能被栈里面的指针关联, 不可能被堆上的指针关联(变量逃逸).
GC 时, 已经知道了栈上哪些变量时指针, 因此迁移栈时, 更新栈里面对应的指针指向新的栈里面的 target 即可. 对于不能确定指针变量的栈(主要是 runtime 中很多 C 代码, 无法进行 GC, 确认指针信息), 回退到分离栈. ---- 这也是为什么现在在用 Go 重写 runtime 逻辑的原因. 同时, 由于栈上指针信息已知, 也为后续进行并发 GC 提供了可能.
堆内存分配与回收详解
三级管理组件: Cache(M 私有),Central,Heap.
- image.PNG
- image.PNG
Heap 是全局的, 所有 Goroutine 共享, 管理的单位是 span.span 是 n 个页的内存块, free 是个 128 个元素的数组, 每个元素是对应页数的 span 链表, 如 free65 表示 65 个页的 span 链表码, 超过 127 页以上的 span 位于 freelarge 中. 对象分配是以字节数为单位的. Heap 初始化时, 初始化了如下规格表, 共 67 种, 分配完 span 后, 会把对应页数的 span 切分为对应 objsize 的小对象, 挂在 span.freelist 上. Central 数组用于管理每一种 objsize 对应的 span 链表.
image.PNG
Mcentral 是中间管理层, 提高复用效率. 包含两个指针, 分别挂载对应 objsize 的 empty span 和 nonempty span. 当 cache 中内存不足时, 向 central 请求分配, 需要加锁.
Cache 是 M 独有的, 为了减少共享锁的损耗, 每个线程一个 Cache. 分配内存时优先从当前 M 的 cache 中去获取. alloc 为 67 种 objsize 对应的 span 指针数组.
组件内存如 Span 结构, Cache 结构都是从 FixAlloc 中分配的, 图中的 spanalloc 负责 span 对象的分配, cachealloc 负责 cache 对象的分配.
初始化
所有的空间分配都是虚拟地址空间. 64bit 机器目前低 48 位可用于寻址, 因此地址空间为 256TB.Heap 在初始化时, 首先尝试从固定地址占用如下地址:
image.PNG
其中, spans 为 256M,bitmap 为 32G,arena 为 512G.
arena 是用户内存对象分配区域, 内存扩展, 即移动 arena.used 的标记位.
bitmap 为每个对象提供 4bit 的标记为, 用以保存指针, GC 标记等信息.
spans TODO.
arena,bitmap,spans 是同步扩张的, 意即 TODO.
分配
假设 Goroutine 需要从分配 14byte 的动态对象,
roundup 到合适的 objsize, 此处为 16byte.
找到 g 当前的 m, 从 m 的 cache 的 alloc 中尝试分配, 从规格表可知应寻找 alloc2 上的 span, 从 span.freelist 中提取可用 object.
如果 span.freelist 为空, 从 heap 对应的 central2 获取新的 span. 如果 central2.nonempty 非空, 则返回 central2.nonempty 上的 span. 加锁
如果 central.nonempty 为空, 从 heap.free 中提取, 从规格表可知, 应该找 size=1 的 span, 即 free1, 提取后切分为 16byte 的 obj 链表, 返回. 加锁
如果 heap1 为空, 继续查找 heap2....., 如果 heap 的 free,freelarge 都为空, 则向操作系统申请新内存块 (最少位 1MB), 分配后切分为 span. ---- 检查 arena.used+size < arena.end, 通过后, 调用 mmap(FIXED) 从 arena.used 开始分配内存. 使用线程私有的 M, 减少锁发生的概率, 提高效率.
以上讨论的都是常规对象的分配, 另有如果是大对象, 会直接到 heap 上去获取 span,tinyobj 会有对应的优化措施, 复用 16bit 的内存块, 利用偏移量记录分配位置等.
结合 GC, 这里需要考虑 central 中获取可用 span 时, span 可能正在被 sweep. 对应的逻辑, 如果 span 要执行 sweep 会先执行 sweep, 再获取 span. 参见源码.
回收
内存回收并不意味着释放, 也会考虑复用. 内存管理器的目的是要在内存使用率和内存分配效率之间做平衡.
内存回收的单位不是 obj, 而是 span. 通过扫描 bitmap 中对象的标记位, 逐步将 obj 收归 span, 上交给 central 或者 heap.free 复用.
遍历 span, 将可回收的 obj 合并到 freelist, 如果全部 obj 都被回收了, 则尝试从 central 交还给 heap 复用. 交还给 heap 时, 会检查左右是否可合并, 合并为更大的 span 一起放到 heap.free 中.
image.PNG
释放
运行时入口函数 main.main 中启动了一个 sysmon 的 goroutine, 每个一段时间, 会检查 heap 里面的闲置内存块. 如果超过了闲置时间, 则释放其关联的物理内存. 这个释放, 并不是真正的释放, 而是通过 madvise 告知 os, 建议内核回收该虚地址对应的物理内存. 内核收到建议后, 如果内存充足, 就会被忽略, 避免性能损耗. 而当再次使用该虚拟地址内存块时, 内核会捕捉到缺页, 重新关联对应物理页.
释放, 并不释放虚拟内存地址, 因此虚地址不会形成空洞, 这个地址空间依然可被访问, 与之相反 mmap.
GC 算法详解(TODO)
变量逃逸
简单来说, 编译器会自动选择变量应该在 stack 还是在 heap 中分配, 并不受 var 声明还是 new 声明影响(和 C\C++ 不同). 一般来说,
编译器通过逃逸分析来决定一个对象放在栈上还是堆上, 逃逸的对象放在堆上, 不逃逸的对象放在栈上.
如果一个变量很大, 那么有可能被分配到栈上.
- type struct T { xxx}
- func f() *T {
- var ret T
- return &ret // ret 从 func f()中逃逸了, 在堆中分配
- }
- func g() {
- y := new(int)
- *y = 1 // y 虽然用了 new, 但是只在 g()作用域生效, 在栈上分配
- }
参考 Go 的 FAQ https://golang.org/doc/faq#stack_or_heap , 仅从正确性的角度出发, 使用者无需关心变量是在 stack 还是在 heap 中, Go 会保证, 如果变量还可能被访问(通过地址), 那就不会失效.
- How do I know whether a variable is allocated on the heap or the stack?
- From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.
The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.
- In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.
- Yet if you are so determined to find out where actually Go allocates the variables, here is a quick trick that you can use, suggeseted here.
- If you need to know where your variables are allocated pass the "-m" gc flag to "go build" or "go run" (e.g., go run -gcflags -m App.go).
具体示例可参考 下面附录中 1,2 项.
来源: https://www.qcloud.com/developer/article/1359184