八股复习Day 3.golang 内存管理&调度机制

196 阅读8分钟

计算机最重要的一个部分之一就是内存,代码运行在内存上;每个进程有自己的进程空间(虚拟内存),通过页表映射到物理内存。

虚拟内存又会进行分段分区,提高效率;以C++为例,C++程序的内存布局包含五个段,分别是STACK(栈段),HEAP(堆段),BSS(以符号开头的块),DS(数据段data)和TEXT(代码段)

以32位系统为例,最高位的1GB是linux内核空间,用户代码不能写,否则触发段错误。下面的3GB是进程使用的内存。

image.png

内存管理

Golang中实现了内存分配器,原理与tcmalloc类似,核心思想就是把内存分为多级管理,从而降低锁的粒度。

每个线程(P)都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

image.png

内存管理主要管理的是的内存,了解内存管理之前先了解几个概念

  • mspan :golang内存管理中的基本单位,由页组成的,每个页大小为8KB
    • 但是有的变量很小就是数字,有的却是一个复杂的结构体,所以基于TCMalloc模型的Go还将内存页分为67个不同大小级别,从8字节到32KB分了67 种( 8 byte, 16 byte….32KB)
    • 下图为 1K 级别的页
    • mspan是一个双向链表对象,其中包含页面的起始地址,它具有的页面的span类以及它包含的页面数。

image.png

  • mcache: mcache 可以为golang中每个 Processor(GMP模型的P) 提供内存cache使用;每一个mcache由多个mspan组成。
    • P 使用mcahce内存是不需要加锁
    • 申请小内存的时候会直接向mcahe申请,更快

image.png

  • mcentral: 当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会到mcentral 来申请。
    • mcentral被所有的工作线程共同享有,存在多个goroutine竞争的情况,因此从mcentral获取资源时需要加锁。
    • mcentral里维护着两个双向链表,nonempty表示链表里还有空闲的mspan待分配。empty表示这条链表里的mspan都被分配了object
    • 获取内存时,加锁;从nonempty链表找到一个可用的mspan;并将其从nonempty链表删除;将取出的mspan加入到empty链表;将mspan返回给工作线程;解锁。
    • 归还内存时,加锁;将mspanempty链表删除;将mspan加入到nonempty链表;解锁

image.png

  • mheap: 当mcentral也不能满足的大内存时,会向mheap申请
    • mheap负责大内存的分配。当mcentral内存不够时,可以向mheap申请。那mheap没有内存资源呢?跟tcmalloc一样,向OS操作系统申请。
    • 还有,大于32KB的内存,也是直接向mheap申请
    • mheap里的arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。

golang内存分配

简单来讲,Go内存管理的基本单元是mspan,每种mspan由多个页组成,每个页可以分配特定大小的object。

mcache, mcentral, mheap是 Go 内存管理的三大组件;

mcache 管理线程在本地缓存的 mspan;

mcentral 管理全局的 mspan供所有线程使用;

mheap管理 Go的所有动态分配内存。

1、Go在程序启动时,会向操作系统申请一大块内存,由mheap结构全局管理。

2、申请的对象大小

  • object size < 16 byte,使用 mcache 的小对象分配器 tiny 直接分配。
  • object size > 16 byte && size <=32K byte 时,先使用 mcache 中对应的 size class 分配。
  • object size > 32K,则使用 mheap 直接分配。
  • 如果 mcache 对应的 size class 的 span 已经没有可用的块,则向 mcentral 请求。
  • 如果 mcentral 也没有可用的块,则向 mheap 申请。
  • 如果 mheap 也没有合适的 span,则向操作系统申请。

内存逃逸

golang 在使用上不用感知分配到堆上还是栈上, 简单来讲,当对象没发生逃逸,生命周期就在当前函数栈内,则分配到栈上;有外部引用,发生逃逸,则分配到栈上

栈和堆 实际上都是对mspan的使用,用做堆内存的mspan状态为mSpanInUse,而用做栈内存的状态为mSpanManual

垃圾回收

golang 的内存回收由gc完成,gc的机制可以参考之前的文章# [Golang]垃圾回收:三色标记法 & 内存优化实操

调度机制

golang使用的是GMP调度模型

在 go v1.1之前用的是 GM模型

  • G:协程。通常在代码里用 go 关键字执行一个方法,那么就等于起了一个G

  • M:内核线程,操作系统内核其实看不见GP,只知道自己在执行一个线程。GP都是在用户层上的实现。

  • 除了GM以外,还有一个全局协程队列,这个全局队列里放的是多个处于可运行状态GM如果想要获取G,就需要访问一个全局队列。同时,内核线程M是可以同时存在多个的,因此访问时还需要考虑并发安全问题。因此这个全局队列有一把全局的大锁,每次访问都需要去获取这把大锁。

    • 并发量小的时候还好,当并发量大了,这把大锁,就成为了性能瓶颈

image.png

为了解决这个性能瓶颈,go1.1之后增加了一个中间层P

  • P: 逻辑processor处理器,P的数量决定了系统最大可以并行执行的G的数量(系统的物理内存的CPU核数>= P的数量)

image.png

所以对比一下这两种调度模式的流程

GMGMP
1、M给全局协程队列加锁 2、加锁成功,获得G;加锁失败阻塞等待 3、运行G,其他M加锁1、M和P 是绑定的(P可以换到其他M上),M通过P到本地队列拿G 2、P的本地队列空了,到全局队列获取G(长度为本地队列一半) 3、全局队列也为空,会从其他P偷一些G(长度为本地队列一半)
代码层面看: 1、g0(用于调度的协程)调用schedule 函数中获取一个协程g 2、调用execute将协程g绑定到线程M上 3、调用了 gogo 的方法执行协程,gogo 方法拿到 sched 属性中用户协程栈信息后,先插入了一个 goexit 栈帧到栈底,然后通过 sched 中的 sp 和 pc 信息跳转到业务程序执行的位置继续进行执行。 4、goexit退出当前协程g 5、重新调用schedule 函数中获取一个协程g和GM模型的路程基本一致 但是schedule过程中引入了P,会到P的本地队列先获取G,获取不到取全局队列,最后才去其他P的队列偷; 同时为了避免长任务阻塞,对于长任务执行一段时间后,会挂起保存现场,然后放到队列中重新排队

image.png

image.png

数量关系

G 没有明确的数量限制,但是会受内存的影响,创建一个go routine需要申请一块(4k)大小的内存;

P的个数取决于设置的GOMAXPROCS,go新版本默认使用最大内核数,比如你有8核处理器,那么P的数量就是8,GOMAXPROCS可以修改;

  • P的数量不会影响G的创建
  • 在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P

M(Machine)是系统线程,在 Go 中默认的数量限制是 10000,如果超出则会报错:可以通过 debug.SetMaxThreads 的方法进行调整上限数量。

  • M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来

image.png

M0: M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0: G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

抢占

当一个G运行太久的时候,P会发出信号将其暂停,把M让出来给其他G运行

如果G陷入内核IO太久(打断不了, 控制权交出去给操作系统了),那么P会 抛弃这个M,去使用其他的M

TODO...