Go Runtime

Go Runtime

1、Goroutine 定义

Golang 在语言级别支持协程,称之为 Goroutine。Golang 标准库提供的所有 系统调用操作(包括所有的同步 I/O 操作),都会出让 CPU 给其他 Goroutine。这让 Goroutine 的切换管理不依赖于系统的线程和进程,也不依 赖于 CPU 的核心数量,而是交给 Golang 的运行时统一调度。

2、GMP 指的是什么

G(Goroutine):我们所说的协程,为用户级的轻量级线程,每个 Goroutine 对象中的 sched 保存着其上下文信息。

M(Machine):对内核级线程的封装,数量对应真实的 CPU 数(真正干活的对 象)。

P(Processor):即为 G 和 M 的调度对象,用来调度 G 和 M 之间的关联关系, 其数量可通过 GOMAXPROCS()来设置,默认为核心数。

GPT - 4

  1. G(Goroutine):Goroutine是Go语言中的轻量级线程,它是并发执行任务的主要实体。每个Goroutine都有自己的调用栈,可以在Go的函数间独立移动。它们的创建和销毁的成本低,可以方便地创建数十万甚至更多的Goroutine。

  2. M(Machine):Machine实际上是操作系统的线程。每个M都在一个操作系统线程上运行,它执行具体的Goroutine。Machine负责调度和执行Goroutine。

  3. P(Processor):Processor是调度的上下文,代表了M需要的资源,可以被看作是M和G之间的桥梁。Processor有一个本地的Goroutine队列,M从这个队列中取Goroutine来执行。在Go的运行时系统中,P的数量是固定的,并且等于GOMAXPROCS的值。

在Go的GMP模型中,当一个Goroutine在一个M上执行时,它会被关联到一个P。每个P管理一个本地的Goroutine队列,M从这个队列中取出Goroutine来执行。当Goroutine阻塞时(例如,等待I/O操作),当前的M会被分离,而另一个M会从P的队列中取出另一个Goroutine来执行。这种设计使得Go可以有效地管理大量的Goroutine,即使在Goroutine经常阻塞的情况下也能保持高效的执行。

3、1.0 之前 GM 调度模型

调度器把 G 都分配到 M 上,不同的 G 在不同的 M 并发运行时,都需要向系统申请资源,比如堆栈内存等,因为资源是全局的,就会因为资源竞争照成很多性能损耗。为了解决这一的问题 go 从 1.1 版本引入,在运行时系统的时候加入 p 对象,让 P 去管理这个 G 对象,M 想要运行G,必须绑定 P,才能运行 P 所管理 的 G。

GM 调度存在的问题:

  • 单一全局互斥锁(Sched.Lock)和集中状态存储

  • Goroutine 传递问题(M 经常在 M 之间传递”可运行”的 goroutine)

  • 每个 M 做内存缓存,导致内存占用过高,数据局部性较差

  • 频繁 syscall 调用,导致严重的线程阻塞/解锁,加剧额外的性能损耗。

4、GMP 调度流程

  • 每个 P 有个局部队列,局部队列保存待执行的 goroutine(流程 2),当 M 绑定的 P 的的局部队列已经满了之后就会把 goroutine 放到全局队列(流 程 2-1)

  • 每个 P 和一个 M 绑定,M 是真正的执行 P 中 goroutine 的实体(流程 3), M 从绑定的 P 中的局部队列获取 G 来执行

  • 当 M 绑定的 P 的局部队列为空时,M 会从全局队列获取到本地队列来执行 G (流程 3.1),当从全局队列中没有获取到可执行的 G 时候,M 会从其他 P 的局部队列中偷取 G 来执行(流程 3.2),这种从其他 P 偷的方式称为 work stealing

  • 当 G 因系统调用(syscall)阻塞时会阻塞 M,此时 P 会和 M 解绑即 hand off,并寻找新的 idle 的 M,若没有 idle 的 M 就会新建一个 M(流程 5.1)

  • 当 G 因 channel 或者 network I/O 阻塞时,不会阻塞 M,M 会寻找其他 runnable 的 G;当阻塞的 G 恢复后会重新进入 runnable 进入 P 队列等待执 行(流程 5.3)

GPT -4

Go 语言的调度模型基于 GMP 模型,其中 G 代表 Goroutine,M 代表机器(操作系统线程),P 代表处理器(本质上是一个可以执行 Go 代码的上下文环境,有些人也把它看作是调度器)。现在我们来详细解释 GMP 调度流程。

  1. 创建 Goroutine:当你在程序中创建一个新的 Goroutine 时(通过 go func()),这个 Goroutine(G)会被放入到一个 P 的本地运行队列中。这个队列是一个 FIFO 队列,新创建的 Goroutine 会被放在队列的尾部。

  2. 执行 Goroutine:在一个 M(线程)上,从关联的 P 的本地运行队列中取出一个 Goroutine 来执行。如果本地运行队列为空,那么会尝试从其他 P 的本地运行队列中偷取 Goroutine 来执行,如果所有 P 的本地运行队列都为空,那么 M 就会进入休眠状态。

  3. Goroutine 阻塞:如果一个 Goroutine 在执行过程中发生了阻塞(如等待 I/O 操作或者等待 channel 的发送/接收),那么这个 Goroutine 会被挂起,当前的 M 会与这个 Goroutine 分离,然后从关联的 P 的本地运行队列中取出下一个 Goroutine 来执行。这个被阻塞的 Goroutine 在满足唤醒条件(如 I/O 操作完成或者 channel 的发送/接收已准备就绪)时会被放入到一个 P 的本地运行队列中,等待再次被调度执行。

  4. Goroutine 结束:当一个 Goroutine 执行结束后,当前的 M 会从关联的 P 的本地运行队列中取出下一个 Goroutine 来执行。

在 GMP 调度模型中,P 的数量是固定的,它的数量在程序启动时由环境变量 GOMAXPROCS 决定,这个数量通常等于你的 CPU 核心数。M 和 G 的数量则是动态变化的,它们的数量完全由程序的需求决定。

这种调度模型的优点是它可以充分利用多核 CPU 的计算能力,实现真正的并行计算,同时 Goroutine 的轻量级特性也使得你可以在程序中创建大量的 Goroutine 来实现高并发。

5、GMP 中 work stealing 机制

获取 P 本地队列,当从绑定 P 本地 runq 上找不到可执行的 g,尝试从全局链 表中拿,再拿不到从 netpoll 和事件池里拿,最后会从别的 P 里偷任务。P 此时去唤醒一个 M。P 继续执行其它的程序。M 寻找是否有空闲的 P,如果有则 将该 G 对象移动到它本身。接下来 M 执行一个调度循环(调用 G 对象->执行-> 清理线程→继续找新的 Goroutine 执行)

6、GMP 中 hand off 机制

当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P,把 P 转移给其 他空闲的 M 执行。

细节:当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行 时进行现场恢复。Go 调度器 M 的栈保存在 G 对象上,只需要将 M 所需要的寄存 器(SP、PC 等)保存到 G 对象上就可以实现现场保护。当这些寄存器数据被保 护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时 G 任务还没有执行完,M 可以将任务重新丢到 P 的任务队列,等待下一次被调度 执行。当再次被调度执行时,M 通过访问 G 的 vdsoSP、vdsoPC 寄存器进行现场 恢复(从上次中断位置继续执行)。

7、协作式的抢占式调度

在 1.14 版本之前,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调 度。这种方式存在问题有:

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿

  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分 钟的时间,导致整个程序无法工作

8、基于信号的抢占式调度

在任何情况下,Go 运行时并行执行(注意,不是并发)的 goroutines 数量是 小于等于 P 的数量的。为了提高系统的性能,P 的数量肯定不是越小越好,所 以官方默认值就是 CPU 的核心数,设置的过小的话,如果一个持有 P 的 M, 由于 P 当前执行的 G 调用了 syscall 而导致 M 被阻塞,那么此时关键点: GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以 后,才会发现有一个 P/M 被 syscall 阻塞了。然后,才会用空闲的 M 来强这 个 P。通过 sysmon 监控实现的抢占式调度,最快在 20us,最慢在 10-20ms 才 会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调 度(一般情况 1ms 可以完成几十次线程调度),Go 发起 IO/syscall 的时候执 行该 G 的 M 会阻塞然后被 OS 调度走,P 什么也不干,sysmon 最慢要 10-20ms 才能发现这个阻塞,说不定那时候阻塞已经结束了,这样宝贵的 P 资源就这么 被阻塞的 M 浪费了。

9、GMP 调度过程中存在哪些阻塞

  • I/O,select

  • block on syscall

  • channel

  • 等待锁

  • runtime.Gosched()

注:runtime.Gosched()是 Go 语言的运行时(runtime)包提供的一个函数,它用来将当前 goroutine 暂停,将控制权交还给调度器,以便调度其他等待执行的 goroutine。这样可以确保其他 goroutine 得到运行的机会,从而实现 Go 的并发性。

这个函数并不常用,因为 Go 的调度器通常会在合适的时机自动进行调度,如 I/O 阻塞,系统调用或显式的通道操作等。然而,在一些特殊的情况下,如果你的 goroutine 需要执行密集型计算并且不能让出控制权,那么可能需要显式地调用 runtime.Gosched(),以确保其他 goroutine 也有执行的机会。

要注意的是,调用 runtime.Gosched() 只是让出 CPU 时间片,当前 goroutine 会被放回到本地运行队列,等待下次被调度执行。它不会导致 goroutine 阻塞或结束。

10、Sysmon 有什么作用

Sysmon 也叫监控线程

  • 释放闲置超过 5 分钟的 span 物理内存(Span物理内存指的是在Go语言中用于动态内存分配的一种内存管理对象);

  • 如果超过 2 分钟没有垃圾回收,强制执行;

  • 将长时间未处理的 netpoll 添加到全局队列;(netpoll是Go语言运行时中的一个网络轮询器,用于管理基于网络I/O操作的goroutine调度)

  • 向长时间运行的 G 任务发出抢占调度(超过 10ms 的 g,会进行 retake);

  • 收回因 syscall 长时间阻塞的 P;

GPT - 4

Go语言中的Sysmon是一个系统监控线程,主要负责监控和调度系统任务。

Sysmon有以下几个主要的作用:

  1. 监控阻塞的Goroutine:如果Sysmon发现有Goroutine被阻塞在系统调用(如网络I/O)上过长的时间,它会创建一个新的线程(M)来执行其他的Goroutine。这样即使某些Goroutine被阻塞,其他Goroutine仍然可以被调度执行。

  2. 调度抢占:为了防止某个Goroutine长时间占用CPU而导致其他Goroutine得不到执行,Sysmon会定期进行抢占式调度。如果一个Goroutine执行的时间超过了一定的阈值,Sysmon会强制把它切换出去,让出CPU给其他的Goroutine。

  3. 垃圾回收:Sysmon负责触发Go的垃圾回收机制。当系统检测到内存使用增加或者达到一定的阈值时,Sysmon会启动垃圾回收过程,回收不再使用的内存。

  4. 处理网络轮询:在网络I/O操作中,Sysmon负责在后台执行网络轮询,从而可以在数据到达时唤醒阻塞在网络I/O操作上的Goroutine。

因此,Sysmon在Go语言的运行时系统中扮演了非常重要的角色,它通过监控和调度机制,保证了Go语言高效的并发执行以及资源的合理利用。

11、三色标记原理

我们首先看一张图,大概就会对 三色标记法有一个大致的了解:

原理:

首先把所有的对象都放到白色的集合中

  • 从根节点开始遍历对象,遍历到的白色对象从白色集合中放到灰色集合中

  • 遍历灰色集合中的对象,把灰色对象引用的白色集合的对象放入到灰色集 合中,同时把遍历过的灰色集合中的对象放到黑色的集合中

  • 循环步骤 3,知道灰色集合中没有对象

  • 步骤 4 结束后,白色集合中的对象就是不可达对象,也就是垃圾,进行回 收

GPT - 4

Go语言的垃圾收集(GC)采用了三色标记清除算法。这个算法是一种保守式的并发垃圾收集算法,它使用了三种颜色标记:白色、灰色和黑色,来表示对象(也就是内存中的数据)在垃圾收集过程中的状态。

  1. 白色:表示可能是垃圾的对象,如果在标记过程结束时仍然是白色,那么这些对象将会被清除。

  2. 灰色:表示活动的(也就是正在使用或者可能会被使用的)对象,或者包含指向白色对象的引用的对象。

  3. 黑色:表示活动的对象,但不包含任何指向白色对象的引用。

三色标记算法的工作流程如下:

  1. 初始化:所有对象都标记为白色。

  2. 标记根对象:将所有根对象(通常是全局变量和当前执行线程的栈上的对象)标记为灰色。

  3. 标记:从灰色对象开始,查找它们引用的对象。把这些被引用的对象从白色改为灰色,并把原来的灰色对象改为黑色。重复此过程,直到没有灰色对象为止。

  4. 清除:把所有仍然标记为白色的对象视为垃圾进行回收。

三色标记算法有一个重要的不变性质需要维护:黑色对象不能直接引用白色对象。这个不变性保证了在并发标记的过程中,不会遗漏任何需要标记的对象。

这个算法的优点是可以并发进行,也就是在垃圾收集过程中,应用程序的其他线程仍然可以运行。这对于Go这样需要高并发的语言来说是非常重要的。

12、写屏障

Go 在进行三色标记的时候并没有 STW,也就是说,此时的对象还是可以进行修 改。

注 : STW是Stop-The-World(全停)的缩写,是指一种暂停整个应用程序运行的机制。在一些垃圾回收算法中,需要先停止应用程序的执行,然后再进行内存回收。Go语言使用三色标记法进行垃圾回收,其中包括并发标记和清除阶段。在并发标记阶段,Go语言运行时系统会采用一个叫做并发标记协作机制的方法来避免STW,以保证应用程序的正常运行。因此,在Go语言的垃圾回收过程中,不需要STW机制。

那么我们考虑一下,下面的情况。

我们在进行三色标记中扫描灰色集合中,扫描到了对象 A,并标记了对象 A 的 所有引用,这时候,开始扫描对象 D 的引用,而此时,另一个 goroutine 修改 了 D->E 的引用,变成了如下图所示

这样会不会导致 E 对象就扫描不到了,而被误认为 为白色对象,也就是垃圾 写屏障就是为了解决这样的问题,引入写屏障后,在上述步骤后,E 会被认为 是存活的,即使后面 E 被 A 对象抛弃,E 会被在下一轮的 GC 中进行回收,这一 轮 GC 中是不会对对象 E 进行回收的。

13、插入写屏障

Go GC 在混合写屏障之前,一直是插入写屏障,由于栈赋值没有 hook 的原 因,栈中没有启用写屏障,所以有 STW。Golang 的解决方法是:只是需要在结 束时启动 STW 来重新扫描栈。这个自然就会导致整个进程的赋值器卡顿

注:栈赋值没有hook是指在Golang程序中,无法对栈上的变量赋值时进行拦截或者监听。

14、删除写屏障

Golang 没有这一步,Golang 的内存写屏障是由插入写屏障到混合写屏障过渡 的。简单介绍一下,一个对象即使被删除了最后一个指向它的指针也依旧可以 活过这一轮,在下一轮 GC 中才被清理掉。

15、混合写屏障

  • 混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫 描垃圾即可;

  • 混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,GC 期间,任 何在栈上创建的新对象,均为黑色。扫描过一次就不需要扫描了,这样就 消除了插入写屏障时期最后 STW 的重新扫描栈;

  • 混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的 是 GC 过程全程无 STW;

  • 混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是 要停止这个 goroutine 赋值器的工作(针对一个 goroutine 栈来说,是 暂停扫的,要么全灰,要么全黑哈,原子状态切换)。

16、GC 触发时机

主动触发:调用 runtime.GC

被动触发:

使用系统监控,该触发条件由 runtime.forcegcperiod变量控制,默认为 2 分 钟。当超过两分钟没有产生任何 GC 时,强制触发 GC。 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。如 Go 的 GC 是一种比例 GC, 下一次 GC 结束时的堆大小和上一次 GC 存活堆大小成比例.

17、Go 语言中 GC 的流程是什么?

Go1.14 版本以 STW 为界限,可以将 GC 划分为五个阶段:

GCMark 标记准备阶段,为并发标记做准备工作,启动写屏障

STWGCMark 扫描标记阶段,与赋值器并发执行,写屏障开启并发

GCMarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏 障

GCoff 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭

GCoff 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭。

18、GC 如何调优

通过 go tool pprof 和 go tool trace 等工具

  • 控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU 的利用率。

  • 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例 如提前分配足够的内存来降低多余的拷贝。

  • 需要时,增大 GOGC 的值,降低 GC 的运行频率。

GPT - 4

在 Go 语言中,垃圾收集器的目标是在控制内存使用的同时,最小化程序的暂停时间。以下是 Go 的垃圾收集器(GC)触发时机,流程,以及如何进行调优。

GC 触发时机

在 Go 中,垃圾收集主要在以下时机触发:

  1. 当堆内存分配的数量达到上次 GC 之后的一定比例(通常是两倍)。也就是说,如果上次 GC 之后,程序分配了 X 字节的内存,那么当程序尝试分配第 2X+1 字节的内存时,会触发 GC。

  2. 手动调用 runtime.GC() 函数。这将立即触发 GC,但在实践中很少使用,因为它可能会导致程序的暂停。

GC 流程

Go 的 GC 流程遵循“三色标记清除”算法,大致流程如下:

  1. 标记开始阶段(Mark Start):这个阶段会停止所有的 Goroutine。

  2. 标记阶段(Marking):在此阶段,从根对象开始,GC 会标记所有可达的对象。这个阶段可以与 Goroutine 并发执行。

  3. 标记结束阶段(Mark Termination):这个阶段会再次停止所有的 Goroutine,以完成所有剩余的标记工作。

  4. 清理阶段(Sweeping):在这个阶段,清除所有未被标记的对象。这个阶段在后台进行,与 Goroutine 并发执行。

GC 调优

虽然 Go 的垃圾收集器设计得相对高效,但在某些情况下,你可能还需要进行一些调优,以达到最佳的性能。这里有几种可能的调优方法:

  1. 调整 GOGC 环境变量:GOGC 环境变量控制着堆内存分配增长的百分比,超过这个百分比就会触发 GC。默认值是 100,表示堆内存分配增长到上次 GC 后的 100% 时,就会触发 GC。增大这个值会使 GC 的频率降低,减小这个值会使 GC 的频率升高。

  2. 使用 sync.Pool 复用对象:频繁分配和释放内存对象会增加 GC 的压力。通过复用对象,可以减少内存分配的次数,从而减轻 GC 的压力。

  3. 避免产生大量的短命对象:短命的临时对象会增加 GC 的压力。如果可能,尽量避免创建大量的临时对象,或者使用其他数据结构(如

buffer)来避免频繁的内存分配。

  1. 减少全局变量的使用:全局变量可能会导致某些内存无法被 GC 回收,因为它们始终被认为是可达的。

请注意,每个应用程序都是不同的,因此优化策略需要根据具体情况来定。通常,你应该使用性能分析工具(如 pprof)来识别可能的性能瓶颈,然后根据分析结果进行优化。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!