00 参考资料

笔记资料:

  1. https://www.yuque.com/xguan56/uwu3y5/gzbs7g
  2. 精选 Go 学习资料https://1024casts.com/topics/Lqno6VNP9b71ZYQ5rZBMd02KG3zQwx
  3. 优秀博客:https://lailin.xyz/post/go-training-01.html

实战代码:
https://github.com/marmotedu/iam

01 并发编程

13 GO语言实践-Runtime

13.1 Goroutine原理

13.2 内存管理

13.2.1 逃逸分析

通过检查变量的作用域是否超出了它所在的栈来决定是否将它分配在堆上

其中“变量的作用域超出了它所在的栈”这种行为即被称为逃逸。逃逸分析在大多数语言里属于静态分析:在编译期由静态代码分析来决定一个值是否能被分配在栈帧上,还是需要“逃逸”到堆上。

  • 减少 GC 压力,栈上的变量,随着函数退出后系统直接回收,不需要标记后再清除
  • 减少内存碎片的产生
  • 减轻分配堆内存的开销,提高程序的运行速度


编译的语句如下:

1
go build -gcflags '-m'

以上的tmp在getRandom函数返回后会“逃逸”到堆上。

上述情况中,num 变量不能指向之前的栈。Go 查找所有变量超过当前函数栈侦的,把它们分配到堆上,避免 outlive 变量。
变量 tmp 在栈上分配,但是它包含了指向堆内存的地址,所以可以安全的从一个函数的栈侦复制到另外一个函数的栈帧。

可能出现“逃逸”的还有如下情况:

  • 一个值被分享到函数栈帧范围之外
  • 在 for 循环外申明,在 for 循环内分配,同理闭包
  • 发送指针或者带有指针的值到 channel 中
  • 在一个切片上存储指针或带指针的值
  • slice 的背后数组被重新分配了
  • 在 interface 类型上调用方法

13.2.2 连续栈

  • 分段栈

Go 应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能自己使用不能被其他 goroutine 使用。栈区的初始大小是2KB(比 x86_64 架构下线程的默认栈2M 要小很多),在 goroutine 运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB。

  • v1.0 ~ v1.1 — 最小栈内存空间为 4KB
  • v1.2 — 将最小栈内存提升到了 8KB
  • v1.3 — 使用连续栈替换之前版本的分段栈
  • v1.4 — 将最小栈内存降低到了 2KB

分段栈会出现hot split问题:
分段栈的实现方式存在 “hot split” 问题,如果栈快满了,那么下一次的函数调用会强制触发栈扩容。当函数返回时,新分配的 “stack chunk” 会被清理掉。如果这个函数调用产生的范围是在一个循环中,会导致严重的性能问题,频繁的 alloc/free。

  • 连续栈

采用复制栈的实现方式,在热分裂场景中不会频发释放内存,即不像分配一个新的内存块并链接到老的栈内存块,而是会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里,当栈缩减回之前大小时,我们不需要做任何事情。

  • runtime.newstack 分配更大的栈内存空间
  • runtime.copystack 将旧栈中的内容复制到新栈中
  • 将指向旧栈对应变量的指针重新指向新栈
  • runtime.stackfree 销毁并回收旧栈的内存空间

如果栈区的空间使用率不超过1/4,那么在垃圾回收的时候使用 runtime.shrinkstack 进行栈缩容,同样使用 copystack

  • 栈扩容

13.2.3 内存管理

TCMalloc 是 Thread Cache Malloc 的简称,是Go 内存管理的起源,Go的内存管理是借鉴了TCMalloc:

  • 内存碎片

随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

  • 大锁

同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

13.2.4 内存结构

  • page: 内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。
  • span: 内存块,一个或多个连续的 page 组成一个 span。
  • sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。
  • object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object。

  • mcache

当程序里发生了 32kb 以下的小块内存申请时,Go 会从一个叫做的 mcache 的本地缓存给程序分配内存。这样的一个内存块里叫做 mspan,它是要给程序分配内存时的分配单元。
在 Go 的调度器模型里,每个线程 M 会绑定给一个处理器 P,在单一粒度的时间里只能做多处理运行一个 goroutine,每个 P 都会绑定一个上面说的本地缓存 mcache。当需要进行内存分配时,当前运行的 goroutine 会从 mcache 中查找可用的 mspan。从本地 mcache 里分配内存时不需要加锁,这种分配策略效率更高。

  • mcentral

如果分配内存时 mcachce 里没有空闲的对口 sizeclass 的 mspan 了,Go 里还为每种类别的 mspan 维护着一个 mcentral。
mcentral 的作用是为所有 mcache 提供切分好的 mspan 资源。每个 central 会持有一种特定大小的全局 mspan 列表,包括已分配出去的和未分配出去的。 每个 mcentral 对应一种 mspan,当工作线程的 mcache 中没有合适(也就是特定大小的)的mspan 时就会从 mcentral 去获取。
mcentral 被所有的工作线程共同享有,存在多个 goroutine 竞争的情况,因此从 mcentral 获取资源时需要加锁。mcentral 里维护着两个双向链表,nonempty 表示链表里还有空闲的 mspan 待分配。empty 表示这条链表里的 mspan 都被分配了object 或缓存 mcache 中。

mcache 从 mcentral 获取和归还 mspan 的流程:

  • 获取 加锁;从 nonempty 链表找到一个可用的mspan;并将其从 nonempty 链表删除;将取出的 mspan 加入到 empty 链表;将 mspan 返回给工作线程;解锁。
  • 归还 加锁;将 mspan 从 empty 链表删除;将mspan 加入到 nonempty 链表;解锁。

mcentral 是 sizeclass 相同的 span 会以链表的形式组织在一起, 就是指该 span 用来存储哪种大小的对象

  • mheap

当 mcentral 没有空闲的 mspan 时,会向 mheap 申请。而 mheap 没有资源时,会向操作系统申请新内存。mheap 主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral 切割成小对象。
mheap 中含有所有规格的 mcentral,所以当一个 mcache 从 mcentral 申请 mspan 时,只需要在独立的 mcentral 中使用锁,并不会影响申请其他规格的 mspan。

  • 大于32kb内存分配

Go 没法使用工作线程的本地缓存 mcache 和全局中心缓存 mcentral 上管理超过32KB的内存分配,所以对于那些超过32KB的内存申请,会直接从堆上(mheap)上分配对应的数量的内存页(每页大小是8KB)给程序。

13.2.5 总结


一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。

  • Go 在程序启动时,会向操作系统申请一大块内存,由 mheap 结构全局管理(现在 Go 版本不需要连续地址了,所以不会申请一大堆地址)
  • Go 内存管理的基本单元是 mspan,每种 mspan 可以分配特定大小的 object