1. Goroutine 的定义
在 Go 语言中,goroutine 是一种非常轻量级的执行线程。goroutine 是 Go 语言并发模型的核心,允许同时执行多个函数调用。goroutines 在 Go 运行时环境中被多路复用到少量的操作系统(OS)线程上,以实现高效并发。
goroutines 的主要特点包括:
- 轻量级:goroutines 相比于传统的线程,具有非常小的初始栈内存占用,通常是几 KB。
- 动态栈:goroutines 的栈大小不是固定的,而是可以根据需要动态伸缩的,这一点和操作系统线程的固定栈大小不同。
- 非阻塞式调度:goroutines 的调度是由 Go 运行时进行管理的,而不是依赖于操作系统。这允许成千上万的 goroutines 能够在单个进程内轻松运行和协调。
- 协作式调度:goroutines 是协作式调度的。这意味着它们会在某些明确的点(如 I/O 操作,channel 操作,阻塞系统调用,发生了 panic 或显式的运行时调用)进行调度切换。
- 并发简单:Go 语言提供的 goroutines 使并发编程变得更简洁易懂。通过
go
关键字,开发者可以非常方便地启动新的并发任务。
创建一个新的 goroutine 只需要使用go
关键字后跟函数调用即可:
go func(){
fmt.Prntln("I'm running in a goroutine!")
}()
这个例子中,紧跟go
关键字的匿名函数将在一个新的 goroutine 中执行。同样,现有的函数也可以通过在调用前加上go
关键字来进入新的 goroutine 执行。
goroutines 在设计上是为了轻量和易于使用,这使得在 Go 语言中编写同时执行多种独立任务的并发程序变得非常简单。同时,它们与 channels 的结合使用也是 Go 并发模型的一个特色,提供了一种优雅的方式来进行 goroutines 之间的通信。
2. GMP 指的是什么
在 Go 语言的并发模型中,GMP 是内部用于描述 goroutine 调度的三个主要组件:
- G(Goroutine): G 代表一个 goroutine,它包含了执行一个 goroutine 所需的所有信息,比如 goroutine 的堆栈、goroutine 的状态以及 goroutine 正在执行的任务等。每个 G 对于一个独立执行的活动。
- M(Machine):M 代表一个 OS 线程(machine),负责自行 code。OS 线程 M 在 Go 的运行时中是与内核线程一一对应的。M 需要获取一个 G 才能执行里面的代码。
- P(Processor):P 代表处理器,实际上是用来给 G 提供运行环境的资源。每个 P 都有一个本地的运行队列(runqueue),用于调度准备要运行的 G。P 的数量决定了系统同时运行的 G 的最大数量,因为每个 G 在执行时都必须绑定到一个 P 上,即使是有成千上万的 G,如果 P 的数量有限,那也只有有限的 G 可以并行运行。
GMP 模型的设计使得 Go 可以在少量的线程上高效地调度大量的 goroutines,有效地使用系统资源,同时减少了不必要的调度开销。
- Goroutine 到线程的多路复用:许多 G 可以被多路复用到较少的 M 上运行,这样可以在用户级别快速地进行调度,而内核线程数量相对较少(通过 M 表示),从而减少了操作系统调度的负担。
- 工作窃取:为了平衡各个 P 上的工作量,Go 语言的运行时采用了工作窃取的策略。空前的 P 可以从其他 P 的 runqueue 中“窃取”G 来执行。
- 系统调度与线程阻塞:如果一个 goroutine 需要进行系统调用而阻塞,它所在的 M 会被阻塞,而该 M 上的 P 则会分离出来,临时绑定到另一个新的线程(M)上继续执行其他的 Goroutine,以此保持运行不受影响。
- 调度器的扩展性:随着可用的 P 的数量的增加,Goroutine 的调度和执行也可以水平扩展。比如,当我们在多核处理器上运行 Go 程序时,可以通过增加 P 的数量来充分利用多核资源。
GMP 是 Go 调度器的内部实现细节,对于大多数 Go 程序员而言是透明的,它们无需关系调度器如何工作,只需直到 goroutines 将以并发的方式执行即可。然而,理解 GMP 对于神日掌握 Go 的并发性能优化和问题分析是由帮助的。
3. 1.0 之前 GM 调度模型
在 Go 1.0 发布之前的早期版本中,Go 使用的是基本的 GM(Goroutine-Machine)调度模型,而没有我们现在知道的 P 的概念。在这个基本的调度模型中,关键点如下:
- G(Goroutine):Goroutine 是 Go 的并发执行单元,早期版本中的 goroutines 在调度和本质上与现在的版本相似,但是它们被调度到线程上的方式有所不同。
- M(Machine):在没有 P 的概念时,每个 OS 线程直接从全局队列中获取 G(goroutine) 进行执行。这意味着所有可运行的 goroutines 都放在同一个全局队列中。
这个 GM 调度模型存在的一些缺点:
- 全局队列导致竞争:因为所有的 OS 线程都要从同一个全局队列获取 goroutines,所以随着 CPU 内核数量的增加,会出现线程之间的竞争,从而影响性能。
- 没有本地队列优化:现在的 Go 调度器为每个 P 维护一个本地队列,能够减少锁竞争,并有助于提高缓存的效率(因为相同线程重复执行相同的任务更有可能利于缓存)。早期的模型没有这样的优化。
- 没有工作窃取:现代的 Go 调度器允许一个线程(具有 P)从另一个撑满工作的线程偷取 goroutines(成为工作窃取)。早期的模型没有工作窃取,因此不太可能平衡线程之间的工作量。
- 系统调用问题:在早期 GM 模型中,如果一个 goroutine 进行阻塞式系统调用,它会阻塞整个 OS 线程(M)。在后台的版本中,Go 引入了 netpoller 以异步处理 I/O,减少了因系统调用而导致的阻塞问题。
为了解决这些问题,Go 在 1.0 的版本之后引入了 P(Processor)的概念。这使得每个 P 可以拥有自己的本地 goroutine 队列,并且执行工作窃取算法。当一个线程(M)要执行 goroutine 时,它必须首先获取一个 P(即执行资源),然后再从 P 的本地队列获取全局队列中,或通过工作窃取获取 goroutine 来执行。这样的改进大大减少了线程间的竞争,并优化了调度器的性能和效率。 此外,P 的引入还允许在更细的粒度上处理系统调用阻塞的问题,当 goroutine 需要执行系统调用时,它所在的线程(M)会释放 P 给其他线程使用,直到系统调用完成,这样就不会阻塞其他 goroutine 的执行。
4. GMP 调度流程
Go 语言的调度器是一个复杂系统,使用了一种被成为 M:P:G 的工作量管理模型来实现高效的并发。下面概述了 Go 调度器中 GMP 调度流程的基本概念:
- 启动阶段:
- 当 Go 程序开始执行时,它会初始化一定数量的 P,P 的数量默认等于及其的 CPU 核心数。同时,程序至少会启动一个 OS 线程(即 M),这些线程从 P 的本地允许队列中获取 goroutine(即 G)来执行。
- 新建 Goroutine:
- 当 Go 代码中执行
go
语句以启动新的 goroutine 时,该 goroutine 会被放入当前 P 的本地运行队列。 - 如果当前 P 的本地玉兴队列已满,它会尝试将该 goroutine 放入全局队列,或者将一部分 goroutine 分发到其他 P 的本地队列。
- 运行阶段:
- 每个 M 都必须持有一个 P 才能执行 G。M 从绑定的 P 的本地运行队列中弹出 G 并执行。
- 如果本地队列为空,M 可以尝试从全局队列中获取 G,获取从其他 P 的本地队列中“偷取”G。
- 阻塞和唤醒:
- 如果正在执行的 G 进入了阻塞的系统调用(比如文件 I/O),它所在的 M 会被阻塞,P 会解绑该 M,并尝试唤起或创建一个新的 OS 线程(M)来接管 P,并继续从运行队列中运行 G。
- 当阻塞的系统调用完成后,该 M 可能会再次尝试获取一个 P。
- 同步调度(Scheduling):
- 当 G 执行完毕或者在特定的同步点上(如 channel 操作,锁机制等)等待时,调度器会进行调度,将其挂起,然后选择另一个 G 执行。
- P 中可能维护一个有待执行的 G 列表,调度器从中选择下一个要执行的 G。
- 工作窃取:
- 当一个 M 的本地队列耗尽工作时,它将尝试偷取来自另一个 P 队列一半的 G。
- 这种窃取机制有助于平衡不同 P 间的工作负载。
- 休眠与唤醒:
- 如果 M 在偷取尝试后仍无法找到 G,它将与 P 一起进入休眠状态。当新的 G 被创建或现有 G 可执行时,P/M 组合将被唤醒以执行新的或变为可执行状态的 G。
这个调度过程的目的是为了最大化 CPU 利用率和最小化延迟,并通过避免不必要的 OS 线程创建来减少资源消耗。它通过保持调度器的活跃度以及尽量使 OS 线程处于工作状态来实现,并且还避免了锁的竞争,这得益于每个 P 都有自己的本地队列和工作窃取策略。需要注意的使,Go 的调度器细节可能随着版本更新而变化,上述描述适用于 1.x 系列的 Go 版本。
5. GMP 中 work stealing 机制
在 Go 语言的 GMP 调度模型中,工作窃取(work stealing)是一个核心机制,用来平衡各个处理器 P 中的工作负载。这种机制允许闲置的线程(或者说绑定处理器 P 的线程 M)从忙碌的线程中窃取 G 来执行。下面是工作窃取机制的工作流程:
- 本地运行队列检查:
- 当某个线程(M)完成了其当前的 G 的执行或者它的本地运行队列为空时,它会首先检查其绑定的处理器(P)的本地运行队列是否有待执行的 G。
- 全局队列检查:
- 如果 P 的本地运行队列为空,M 将尝试从全局运行队列中获取一个新的 G。
- 窃取尝试:
- 当全局队列也为空时,M 会随机选择一个 P,并尝试从它的本地运行队列中窃取一半的 G。选择哪个 P 是随机的,这样可以增加窃取成功率并降低特定 P 队列的竞争。
- 恢复工作:
- 如果窃取成功,M 会将这些从其他 P 队列窃取的 G 放入本地运行队列,并开始执行它们。
- 休眠与唤醒:
- 如果窃取没有成功(即其他所有 P 都没有可运行的 G),这个线程 M 可能会进入休眠状态,直到有新的 G 被创建或现有的变为可运行状态,此时,它可以被唤醒。
这个机制确保了 CPU 时间不会被浪费在搜索可运行的 G 上,同时还能避免某个线程空闲而其他线程过载的情况发生。通过使用本地队列,Go 减少了锁竞争,由于大部分时间每个 M 之访问其绑定的 P 的本地队列,这样线程键几乎无需相互干扰。当必须进行交互时(例如,在工作窃取的情况下),Go 使用精心设计的算法来最小化锁争用并保持高效的并发执行。 工作窃取因此时支撑 Go 高性能并发调度的关键机制之一,它可以动态地适应各种工作负载,从而优化了 G 的执行和调度。这也提醒按了 Go 调度器的设计理念,即尝试尽可能在用户空间内解决问题,而非依靠操作系统内核的调度。
6. GMP 的 hand off 机制
在 Go 语言的 GMP 调度模型中,交接(hand off)机制通常指的是在 goroutine 执行系统调用或者其他可能导致线程阻塞时处理 M(Machine,操作系统线程)和 P(Processor,处理器上下文)之间关联的过程。这个机制确保了系统调用不会阻断整个调度器的工作,特别是在只有一个或几个线程(M)阻塞的情况下,让其他待运行的 goroutine (G)能够继续执行。 下面是 hand off 过程的基本步骤:
- 系统调用阻塞:
- 如果一个 G 需要执行可能会阻塞的系统调用(例如,读取一个未就绪的文件),它所在的线程 M 将会被操作系统阻塞。
- 解绑 P:
- 为了不浪费处理器资源,阻塞线程 M 将其处理器 P 解绑并放回处理器池中,这样其他可运行的线程 M 可以使用这个处理器 P。
- 创建或唤醒线程:
- 然后,Go 调度器可能会选择唤醒一个闲置的线程,或者创建一个新的线程(如果没有足够的闲置线程),将其与处理器 P 绑定,继续执行其他的 G。
- 系统调用完成:
- 当初步阻塞的系统调用完成后,原来的线程 M 不会立即尝试获取处理器 P 继续原理的工作。取而代之的是,这个线程将会将完成的 G 放入全局队列或者其他处理器 P 的本地队列,并尝试找到一个空闲的 P 与之绑定。
- 重新进入调度器:
- 一旦 M 绑定了一个新的 P,它变可以继续执行其他 G。如果没有 P 可用,线程 M 可能会进入休眠状态,等待被唤醒。
通过这种方式,hand off 机制降低了因系统调用而导致的线程阻塞对整体调度器性能的影响。Go 以用户态的方式关联多数的并发调度任务,只有在不得不进行系统调用时才与操作系统的内核调度器发生交互,从而实现了轻量级且高效的并发模型。
7. 协作式的抢占式调度
1.14 版本之前,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。这种方式存在问题有:
- 某些 Goroutine 可以长时间占用线程,造成其他 goroutine 的饥饿。
- 垃圾回收需要暂停整个程序(stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作。
早期版本的 Go 调度器特点:
- 协作式调度:
- 在 Go 的 1.14 之前,调度器使用协作式调度,也就是说一个 goroutine 会一致运行,直至它自己放弃处理器(例如通过进行一个阻塞操作)。
- 这种方式简单且性能开销小,因为不存在抢占式的上下文切换。但是,如果一个 goroutine 长时间不释放 CPU,它将导致其他 goroutine 处于饥饿。
- 协作式抢占的问题:
- 长时间执行计算操作的 goroutine 可能会阻塞其他的 goroutine 执行,特别是在单核新处理器上。
- 系统调用的阻塞性质会被利用来允许调度器重新获得控制权并调度其他 goroutine。
- 初始的解决方案:
- 一些被设计来主动放弃控制权的机制,例如 goroutine 可以定期检查是否需要让出 CPU。
- 在进行系统调用或其他可能阻塞的操作时发生调度。
- 协作式抢占的改进:
- 在后续版本中,Go 开始引入真正的抢占式调度。这是通过在运行时(runtime)插入抢占点来实现的,它可以在特定的安全点检查并让出 CPU。
- Go 1.14 版本引入了基于信号的抢占,这标志着 Go 语言实现了抢占式调度的一大步。
8. 基于信号的抢占式调度
基于信号的抢占式调度是一种能够在运行中的线程或进程达到一定的执行时间后,强制它中断执行以让其他任务运行的调度机制。这种抢占通常通过操作系统层面的信号实现。
在 Go 语言的上下文中,基于信号的抢占式调度依赖于周期性地向线程发出SIGURG
信号。这个信号被 Go 运行时捕获,并不是用来中断线程的执行,而是作为一种提示:是时候检查这个 goroutine 是否应该继续执行了。
运行时会检查 goroutine 的运行时长,如果超过预设的阈值,它就会尝试在 goroutine 达到一个所谓的“安全点”时进行抢占,这通常是在函数调用时。在安全点,goroutine 的状态容易被保存,允许运行时挂起它,并在之后可以恢复执行。
这种调度方法的目的是为了保持系统的响应性和公平性。通过强制切换执行任务,系统能够确保所有任务有机会执行,从而避免某个任务因为长时间占据资源而导致其他任务饥饿。
优势:
- 提高响应性:长时间运行的任务不会导致系统变得不响应。
- 避免优先级反转:高优先级任务不会因为低优先级任务的长时间执行而被排除在外。
- 无阻塞同步:使得调度器更容易处理同步原语,比如锁。
潜在问题:
- 上下文切换:抢占可能导致增加的上下文切换,这可能对性能产生负面影响。
- 复杂性:实现和维护基于信号的抢占式调度机制可能比协作式调度复杂。
- 非确定性:从程序员的视角看,抢占可能会使得并发缺陷的调式和测试更加困难,因为它引入了额外的非确定性。
9. GMP 调度过程中存在哪些阻塞
GMP 调度是 Go 语言的调度模型,在这个模型中,存在几种可能导致阻塞的情况:
- 系统调用:当 goroutine 执行系统调用,如文件操作、网络通信等,可能会被操作系统挂起,等待系统调用完成。
- 通道操作:当 goroutine 对通道进行发送或接收操作,而通道处于不可用的状态(如无数据可接收或通道已满),会导致 goroutine 阻塞。
- 锁操作:如果 goroutine 试图获得一个已被其他 goroutine 持有的锁(如
sync.Mutex
),它就会阻塞,直到锁被释放。 - 运行时系统调用:Go 的运行时(runtime)还可能因为内存分配(当进行垃圾收集时)或其他必要的维护操作而阻塞 goroutines。
- 网络轮询:如果 goroutine 等待一个网络 I/O 操作的结果,Go 的网络轮询器可能会使其阻塞,直到 I/O 操作就绪。
在这些情况下,Go 运行时会尝试将阻塞的 goroutine 从当前线程(M)上解绑,并将其他可运行的 goroutine 调度到这个线程上,以此提高系统的并行性和整体性能。这一过程涉及 Goroutine(G)、操作系统线程(M)以及对应的处理器(P)之间的协作。
10. Sysmon 有什么作用
Sysmon 也叫监控线程,变动的周期性检查,好处:
- 释放闲置超过 5 分钟的 span 物理内存。
- 如果超过 2 分钟没有垃圾回收,强制执行。
- 将长时间未处理的 netpoll 添加到全局队列。
- 向长时间运行的 G 任务发出抢占调度(超过 10ms 的 G,会进行 retake)。
- 收回因 syscall 长时间阻塞的 P。
在 Go 的 runtime 中,Sysmon
是 runtime 监控系统的一部分,它在后台运行,执行一些系统级别的调度和监控功能。其用处包括但不限于:
- 监控死锁:如果所有的 goroutine 都休眠了,它会报告死锁错误。
- 强制垃圾回收:如果线程在 M 低水位(意味着没有足够的线程进行任务)、且没有足够的 CPU 压力时,
Sysmon
会触发垃圾回收以释放内存。 - 管理空闲时间:
Sysmon
负责在程序空闲时,释放或唤醒线程,以保持运行时的效率。 - 网络轮询其的辅助:在某些情况下,
Sysmon
会协助网络轮询器,保持网络 I/O 操作的监控,避免 goroutine 在等待网络操作时永远被阻塞。
11. 三色标记原理
在 Go 语言的垃圾回收器中,三色标记原理是用来实现垃圾回收的核心算法。在 Go 中,它通常与写屏障(write barrier)配合使用来处理并发垃圾回收过程中的标记工作。以下是 Go 中三色标记算法的高层概念和实践:
- 标记阶段(Marking Phase):
- 根对象从它们的初始白色状态被标记为灰色。
- 垃圾回收器(GC)逐一处理所有灰色对象。对于每个灰色对象,GC 检查它指向的其他对象。
- 如果一个灰色对象执行一个白色对象,那么这个白色对象被标记为灰色,意味着它是潜在可达的。
- 处理完所有引用后,灰色对象本身变为黑色,表示该对象及其子对象都已被扫描。
- 并发执行:
- Go 垃圾回收器在尽可能多的情况下并发进行。在标记阶段,应用程序的 goroutine 会与垃圾收集器同时运行。
- 为了处理应用程序在并发标记期间对堆进行的更改,Go 运行时使用写屏障。写屏障确保在垃圾回收过程中,任何对堆的修改都会更新标记信息,避免活对象变为不可达。
- 终止条件(Termination Condition):
- 当没有更多的灰色对象需要处理时,标记阶段结束。
- 此时,所有活对象都已标记为黑色,而所有剩余的白色对象都内认为是垃圾。
- 清扫阶段(Sweeping Phase):
- 在标记阶段完成之后,清扫阶段将开始。
- 这个阶段回收所有仍然是白色的对象占用的内存。这部分对象被认为是垃圾,因为它们在标记阶段没有被标记为可到达的。
- 停顿时间(Stop-the-world Pauses):
- 尽管大部分的垃圾收集工作是并发进行的,Go 的垃圾回收器还是需要两次短暂的全程暂停:一次是在标记阶段开始时,另一次是在实际的并发标记开始之前进行准备。这些暂停时间在新本版的 Go 中不断缩短,以减少应用程序的延迟。
该算法的优势在于它可以与程序一起并发运行,并且具备精确的内存管理能力。然而,并发执行也带来了复杂性,特别是为了保护应用程序的堆一致性,必须正确实现和维护写屏障。总之,在 Go 中三色标记垃圾回收器在性能和高并发场景下通常表现较好,同时确保了较短的停顿时间。
12. 写屏障
在 Go 语言的垃圾收集器中,写屏障(write barrier)是一个重要的概念和机制。写屏障用于确保在垃圾回收期间,应用程序的内存写操作能正确更新垃圾回收器的状态,以维护正确的内存可达性信息。
并发垃圾回收过程中,应用程序的 goroutine 和垃圾回收器可能同时操作堆内存。goroutine 在执行写操作时,比如更新对象字段或者切片内容,可能会破坏垃圾收集器对堆已经到达的颜色不变性(三色标记法中的不变性)。写屏障确保了在修改对象引用时,相关联的对象不会被错误地回收。
在 Go 垃圾收集中,写屏障的一般行为包括:
- 配合三色标记算法:当应用程序在并发垃圾回收过程中修改引用(比如,将一个对象的字段从一个对象指向另一个对象)时,写屏障会执行额外的操作,以确保这种变化不会导致垃圾收集器遗漏活动对象或错误地标记对象。
- 维护不变性:特别是三色不变性,即一旦对象变为黑色,它就不再指向白色对象,这一点需要通过写屏障来保证。
- 灰色对象队列的管理:如果一个黑色对象被更新以指向一个白色对象(因为垃圾收集器已经检查了该黑色对象),写屏障会将该白色对象标记为灰色,并放入灰色对象队列中,以便垃圾收集器稍后会重新扫描这个对象。
在不同版本的 Go 语言中,写屏障的实现可能会有所变化,但其核心目的是使得垃圾回收可以安全地在应用程序运行的同时执行,而不会导致错过对活动对象的标记。
Go 在 1.8 版本之后默认使用了混合写屏障(Hybrid Write Barrier),它比传统的写屏障更高效,因为它只跟踪堆指针的更改而不是所有指针的更改,减少了写屏障的性能开销。此外,Go 还使用了非阻塞的写屏障,它允许垃圾收集器的写屏障逻辑以非阻塞的方式执行,这也有助于降低垃圾收集对应用响应时间的影响。
13. 插入写屏障
在 Go 语言的垃圾回收机制中,插入写屏障(insertion write barrier)是一种确保堆状态一致性的技术,用于处理并发垃圾回收的复杂性。
具体到写屏障,Go 使用的是一种称为“混合写屏障(Hybrid Write Barrier)”,该策略在 Go 1.5 版本中引入并在后续版本中优化。混合写屏障结合了删除写屏障和插入写屏障的特点。
插入写屏障主要作用是提供对三色标记中不变性的维护,其工作原理如下:
- 对象变黑前阶段: 当垃圾回收器标记堆内的对象时,所有新写入的指针(指从一个对象指向另一个对象的引用)都要经过写屏障。在写屏障的保护下,如果一个黑色对象要写入一个白色对象的引用(指针),写屏障会将该白色对象标记为灰色并加入队列以供后续处理。这保证了黑色对象不会指向白色对象,从而维持了垃圾收集器的不变性。
- 插入逻辑:具体到插入行为,写屏障会拦截对对象引用字段的写操作,检查这种修改是否需要将被引用的对象(收到写入的对象)标记为灰色。如果必要,就把它推入待处理队列中。
这样一来,就算在对象图遍历过程中应用代码改变了对象间的引用关系,新引用的对象也不会被漏标(因为已经被写屏障处理过),从而保证了收集器能够找到所有存活的对象。
在 Go 中,写屏障是可以并发执行的,意味着它们不会阻塞正在进行的内存写操作。这有助于减少垃圾回收引起的延迟,允许 Go 运行时维护较小的停顿时间(Stop-the-World Pauses),使 Go 程序可以适用于需要高并发和低延迟的场景。
需要注意的是,写屏障增加了写操作的开销,尽管 Go 尽力减少这种开销,但在大量写内存操作的程序中仍然可能观察到性能影响。因此,Go 垃圾收集器的设计和实现始终在考虑如何保持系统吞吐量和响应时间之间的平衡。
14. 删除写屏障
到目前为止(截至知识更新点 2023 年),Go 语言的垃圾回收(GC)实现中并不使用传统意义上的删除写屏障(deletion write barrier)。相反,自 Go 1.5 起,Go 垃圾回收使用的是混合写屏障(hybrid write barrier),它结合了删除写屏障和插入写屏障的部分特性。
删除写屏障通常用于其他一些垃圾收集器实现,在处理引用删除时,确保三色标记算法的不变性。在这个上下文中,删除写屏障介入对对象的引用移除操作。当程序试图断开一个对象对另一个对象的引用时,删除写屏障会介入,并可能导致标记某些对象以维持 GC 算法的不变性。这通常与需要在删除操作中记录某些信息的 GC 算法相关,如分代垃圾收集器中的记忆集(remembered set)。
尽管删除写屏障并不适用于 Go 的 GC,但了解它在其他语言和系统上的行为对于理解不同 GC 实现的工作原理及其取舍是有帮助的。在一些使用删除写屏障的垃圾回收实现中,当从一个对象(通常是老年代对象)中移除对另一个对象(例如年轻代对象)引用的指针时,这种变更必须由写屏障捕获,以便垃圾回收器可以按需更新其内部数据结构,如记忆集。
总的来说,虽然删除写屏障现在不是 Go 语言 GC 的一部分,但是写屏障作为 GC 算法中的一个重要机制,仍然是理解现代编程语言中垃圾收集工作的一个关键概念。在 Go 中,写屏障偏向于插入式的行为,它在并发垃圾回收期间维护不变性,保证被程序修改的引用不会引起不可达的活动对象被错误收集。
15. 混合写屏障
Go 语言的垃圾回收机制中的混合写屏障(hybrid write barrier)是从 Go 1.8 版本开始使用的,并在后续版本中进一步优化增强。混合写屏障结合了插入式和删除式写屏障的特点,但它更偏向于插入式行为。其基本思路是在对象引用发生变更时进行干预,以帮助垃圾回收器正确识别存活的对象。
在垃圾回收的三色标记阶段中,垃圾回收器对对象进行标记,初步将它们分为可达和不可达两类。为了处理程序在垃圾回收期间对对象引用的更改,混合写屏障确保:
- 在标记过程中发现存活对象:当一个对象(设为 A)被标记并被视为“黑色”后,理论上它不应再指向任何未标记的“白色”对象。如果在并行标记过程中,黑色对象 A 被更改以指向一个白色对象 B(即应用代码创建了 A 到 B 的新引用),那么混合写屏障确保将对象 B 标记为“灰色”,即加入到待处理队列中,以便稍后进行标记,从而保持不变性。
- 允许应用程序和垃圾回收并发执行:混合写屏障允许应用在垃圾回收器执行并发标记时继续运行,以减少程序的停顿时间。该特性特别适合需要低延迟的应用程序。
- 最小化性能开销:为了减少对应用程序性能的影响,混合写屏障优化为只在垃圾回收标记阶段进行操作,并且仅在修改堆上对象的指针时激活。
使用混合写屏障的优点在于能够更好地平衡应用程序的性能和垃圾回收器的正确性。通过插入式的行为,它避免了对象图在并行标记期间被错误地更新,导致一些应该存活的对象被错误地识别为垃圾。同时,由于它并不处理所有类型的写操作,它比一个全面的删除式或插入式写屏障对性能的影响要小。
值得注意的是,Go 团队对垃圾回收器和相关内存管理机制进行了持续的优化工作,以改善其性能和减少对应用程序造成的干扰。这包括对写屏障的不断修改和调整,以及全面的堆管理策略。因此,在面试时讨论 Go 的混合写屏障,重点应该放在其如何在并发垃圾回收过程中保护内存状态的一致性,以及减少标记阶段可能的延迟。
16. GC 触发时机
Go 语言垃圾回收(GC)的触发时机是由几个条件和策略共同决定的。简而言之,GC 主要在以下情况下触发:
- 堆内存分配阈值:这是最主要的触发点。在 Go 中,默认情况下,当堆内存分配的总量达到上一次 GC 后存活对象总量的两倍时,会启动新的垃圾收集周期。这个参数是可配置的,可以通过设置环境变量 GOGC 来调整(默认值为 100,代表使用的堆内存达到上次 GC 后的存活堆内存的 100%时,触发新一次 GC)。降低这个值会导致 GC 更频繁地运行,而增加这个值会减少 GC 运行的频率,但可能会导致更多的堆内存使用。
- 手动触发:开发者可以手动调用 runtime.GC()函数来触发 GC。这通常用于测试或特定场景,其中开发人员需要控制 GC 运行的时机。
- 内存不足:如果程序分配内存时,已经没有足够的空间可以供新的内存分配使用,垃圾收集器将会被触发以尝试回收未使用的内存。
- 定时器:Go 运行时(runtime)可能会使用一些内部的定时器来定期检查是否需要运行 GC,尽管这不是主要的触发方式。
- 外部事件:例如,操作系统发出内存压力信号时,可能触发 GC 以降低整体内存压力。
- 调度点:在运行时,执行 goroutine 切换的时候,运行时会检查是否有必要进行 GC。
- 其他运行时事件:还有一些其他的事件可能会触发 GC,例如运行时环境的内存分配器在尝试从操作系统申请内存时碰壁,或者是一些特定的程序事件,如 goroutine 的创建或结束。
Go 的垃圾收集机制设计理念是尽量减少程序暂停时间,从而为并发程序提供稳定的性能。因此,从 Go 1.5 版本开始,垃圾收集默认以并发模式运行,减少了程序执行的停顿。
总结一下,Go 语言的 GC 是受多种因素影响的,并非由单一条件触发。运行时试图根据当前程序的内存使用情况和配置做出最佳决策来优化性能与内存使用之间的权衡。
17. Go 语言中 GC 的流程是什么?
Go 语言的垃圾收集(GC)器是一个并发的、标记-清除类型的 GC,用于自动释放不再需要的内存。GC 的流程大致如下:
- 开始:GC 周期的开始通常是由堆内存分配达到一定阈值触发的,这个阈值是上次 GC 后存活对象内存的一定倍数。
- 标记准备(Marking preparation):一旦触发 GC,首先,运行时(runtime)会进行一次程序的停顿(STW,stop-the-world),这个停顿通常非常短,在这个阶段,GC 会进行一些准备工作,比如对所有 goroutine 的栈进行扫描,创建一些初始的 GC 根对象。
- 并发标记(Concurrent marking):在第一次短暂的停顿之后,GC 开始并发标记阶段。在这个阶段,GC 将遍历所有 GC 根对象,并标记所有从这些根对象可达的对象。这个过程中,程序的 goroutines 会继续执行,但是在修改指针时,会通过写屏障来协助 GC 的准确性。
- 标记终止(Mark termination):一旦标记阶段完成,会发生第二次短暂的 STW 停顿。运行时会完成最后的标记工作,并确保所有可达对象都已被标记。
- 清除(Sweep):在第二次停顿结束后,GC 进入清除阶段,这也是并发执行的。GC 会遍历堆上的对象,回收那些在标记阶段没有被标记为可达的对象的内存。自 Go 1.5 起,清除操作是按需进行的。当新的内存分配请求时,若对应的内存段尚未清扫,则会先清扫后再分配。
- 结束:一旦所有不可达的对象均已回收,当前的 GC 周期结束。
值得注意的是,在 Go 中,因为 GC 的并发性质,写屏障是必不可少的,它确保在 GC 的标记阶段中应用的写操作不会破坏已有的标记信息。此外,从 Go 1.8 开始,引入了一个新的标记算法,它使用了一个优化的混合写屏障,以提高性能并减少 STW 的停顿时间。
Go 语言的 GC 设计目标是尽量减少对程序运行的干扰,使 GC 适合高并发的场景。因此,Go 的 GC 一直在不断优化,以便在低延迟和高效率之间找到平衡点。
18. Go 语言中 GC 如何调优
在 Go 语言中,垃圾回收(GC)的调优通常围绕着两个主要目标:最小化 GC 的延迟影响(减少 STW 时间)和减小内存占用。以下是几个用于调优 Go GC 的方法:
- GOGC 环境变量:这是最常见的 GC 调优方法。GOGC 环境变量决定了堆内存的增长百分比,该百分比会触发下一次 GC。默认值是 100,意味着一旦堆内存增长了 100%,就会触发 GC。减少这个比例将会导致 GC 更频繁地运行,可能会降低延迟但提高了 CPU 的使用;增加这个值意味着 GC 会更少运行,但是程序的内存使用峰值也会更高。
- 调整堆内存大小:如果你知道你的应用程序需要大量内存,你可以手动设置堆的大小来避免频繁的 GC。
- 手动触发 GC:通过代码中调用 runtime.GC()函数,你可以决定何时进行 GC。这允许程序在内存需求较低的时候执行 GC,从而避免在高负载时出现延迟。
- 优化内存分配:代码级的调优是非常有效的。尽量避免不必要的内存分配。例如,你可以复用内存,避免大量的临时对象分配,使用内存池,或者优化数据结构以减少内存占用和堆碎片。
- 避免内存泄露:确保不再需要的对象可以被 GC 回收。检查代码,找出潜在的内存泄露,如长时间存活的大对象或者未解除的事件监听器。
- 并发和堆大小监控:使用运行时的 runtime.ReadMemStats 函数定期记录和分析内存的使用情况。监控工具和剖析工具(如 pprof)可以帮助你了解内存分配的状况。
- 调整程序结构:某些情况下,调整程序的架构和处理逻辑可以改善执行效率,减少 GC 的频率和压力。
- 升级 Go 版本:Go 团队不断优化 GC 机制。新的 Go 版本可能会带来更优化的 GC 行为,因此保持在最新的稳定版可以使应用受益于这些优化。
在调优之前,了解应用的内存使用模式是很重要的。对于不同类型的工作负载,最优的 GC 策略可能会有所不同。此外,在进行任何调优之前,建议进行基准测试,以确保改动会带来实际的改进。