协作式与抢占式调度的触发与条件
GoLang 中大部分时候都是协作式调度吗?什么时候会发生抢占式调度?
在Go语言的调度机制中,调度器结合了协作式和抢占式调度,不同场景下会采用不同的策略。以下是详细分析:
1. 协作式调度(Cooperative Scheduling)
触发场景:Goroutine 主动让出CPU,通常发生在以下情况:
- 调用函数:如
time.Sleep()
、channel
发送/接收、sync.Mutex
锁操作等。 - I/O阻塞:如文件读写、网络请求等系统调用。
- 主动让出:通过
runtime.Gosched()
显式释放CPU。
此时,Goroutine会通过调用调度器函数(如gopark
)主动切换上下文,调度其他Goroutine运行。
2. 抢占式调度(Preemptive Scheduling)
Go 1.14 引入了基于信号的抢占(Signal-based Preemption),允许在以下场景强制抢占:
触发条件:
-
运行时间过长:
- 系统监控线程(sysmon) 检测到某个Goroutine连续占用CPU超过 10ms(通过
retake
函数标记抢占)。 - 通过向目标线程发送
SIGURG
信号,触发抢占(信号处理函数asyncPreempt
会保存上下文并切换Goroutine)。
- 系统监控线程(sysmon) 检测到某个Goroutine连续占用CPU超过 10ms(通过
-
垃圾回收(GC)需要STW:
- GC的“Stop The World”阶段必须暂停所有用户Goroutine,此时会强制抢占。
-
系统调用返回时:
- 长时间阻塞的系统调用返回后,可能触发抢占以重新分配CPU时间。
技术细节:
- 栈扫描抢占(Go 1.2~1.13):通过插入抢占标记,在函数调用时检查并让出CPU。但无法处理无函数调用的死循环。
- 信号抢占(Go 1.14+):即使Goroutine在无函数调用的循环中(如
for { i++ }
),也能通过信号强制抢占。
3. 关键演进
- Go 1.2:引入协作式抢占(栈扫描),依赖函数调用触发。
- Go 1.14:实现真正的抢占式调度(基于信号),解决长时间循环导致的“饿死”问题。
总结
- 协作式为主:大部分调度仍依赖Goroutine主动让出(如I/O或同步操作)。
- 抢占式补充:在长时间运行、GC或系统调用等场景,强制切换Goroutine,确保公平性。
这种混合机制兼顾了效率和公平性,避免了纯协作式调度可能导致的延迟问题。
系统监控线程sysmon在Go GMP模型中的角色与资源占用
系统监控线程是什么?是 g0 吗?他在 GMP 模型中处于什么位置?会占用一个 M 或者占用一个 P 吗?
在Go语言的GMP调度模型中,系统监控线程(sysmon
)是一个独立的后台线程,负责全局状态的监控和调度优化。以下是详细分析:
1. 系统监控线程(sysmon)是什么?
sysmon
是Go运行时(runtime)启动的一个特殊后台线程,独立于用户Goroutine运行。它的主要职责包括:
- 检测抢占:扫描长时间运行(>10ms)的Goroutine并触发抢占。
- 垃圾回收(GC):在需要时触发GC的标记阶段。
- 网络轮询:处理网络I/O的就绪事件。
- 空闲P回收:回收长时间未使用的P(Processor)资源。
- 处理死锁:检测全局死锁(如所有Goroutine阻塞)。
2. 是g0吗?
- g0的角色:每个操作系统线程(M)都有一个固定关联的g0,它是调度器的核心协程,负责Goroutine的创建、销毁和上下文切换。
- sysmon与g0的关系:
sysmon
运行在一个独立的M线程上,但它的执行逻辑并不依赖某个特定的g0。这个M的g0仅用于基础的调度管理,而sysmon
本身是一个独立循环,不绑定任何用户Goroutine。
3. 在GMP模型中的位置
- GMP模型结构:
- G(Goroutine):用户协程。
- M(Machine):操作系统线程。
- P(Processor):逻辑处理器,管理Goroutine队列。
- sysmon的定位:
sysmon
运行在一个独立的M上,不绑定任何P(即M.sysmontick
标记)。- 它不占用用户Goroutine的调度资源,独立于GMP的主调度流程。
4. 是否占用M或P?
- 占用M:是的,
sysmon
需要一个独立的操作系统线程(M)来运行。 - 不占用P:
sysmon
的M不绑定任何P,它直接通过系统调用(如epoll_wait
、nanosleep
)执行任务,因此不会占用用户Goroutine的P资源。 - 资源隔离:这种设计避免了监控任务与用户Goroutine竞争P,确保调度公平性。
5. 技术细节
- 启动时机:Go程序启动时,由
runtime.main
创建sysmon
线程。 - 执行逻辑:
func sysmon() { for { // 1. 检查网络I/O // 2. 抢占长时间运行的Goroutine // 3. 触发GC阶段 // 4. 休眠一段时间后继续循环 } }
- 无P运行:由于不绑定P,
sysmon
无法直接调度用户Goroutine,但可以通过信号抢占(如SIGURG
)或修改全局状态间接影响调度。
总结
sysmon
是一个独立的后台M线程,不绑定P,也不依赖用户Goroutine的g0。- 它在GMP模型中扮演“守护者”角色,通过全局监控和抢占机制确保调度公平性和系统稳定性。
- 这种设计避免了资源竞争,使得监控任务与用户Goroutine完全隔离,保障高性能调度。
sysmon线程的M线程特性和在GMP模型中的位置
sysmon 使用一个独立的 M ,那这个 M 是固定,还是说会跟其他 Goroutine 所在的 M 发生身份互换?这个 M 会算入 M 总数中吗?会受到 M 的数量限制吗?
我做如下理解: Go 程序启动时都一定至少会启动两个线程:一个是专门运行 sysmon 的线程,其他是用来执行用户 Goroutine 的线程。 我理解的对吗?
以下是关于Go语言中sysmon
线程的M(操作系统线程)的详细解答:
1. sysmon 的 M 是固定的吗?是否会与其他 M 互换身份?
- 专用且独立:
sysmon
运行在一个独立的、专用的M上,这个M不会用于执行用户Goroutine,也不会与其他M发生“身份互换”。 - 设计目标:
sysmon
需要持续监控全局状态(如抢占、GC等),因此它必须独立于用户任务的调度流程。该M不绑定任何P(逻辑处理器),仅用于运行sysmon
自身的监控逻辑。 - 与普通M的区别:
- 普通M:绑定P,从P的队列中获取Goroutine执行。
sysmon
的M:不绑定P,直接通过系统调用(如epoll
、nanosleep
)执行任务,完全独立于GMP的主调度流程。
2. sysmon 的 M 是否计入 M 总数?是否受 M 数量限制?
- 计入总数:是的,
sysmon
的M会被计入Go运行时管理的M总数中。 - 受数量限制:但Go运行时对M的总数有一个软性限制(默认最大值为
10000
,可通过runtime/debug.SetMaxThreads
调整)。不过,由于sysmon
是Go运行时启动的第一个后台线程,它的创建不受用户Goroutine调度的影响,即使达到M数量限制,运行时也会优先保证sysmon
的存在。
3. Go程序启动时的线程数量
你的理解部分正确,但需要更精确的说明:
- 启动时的线程:
- 主线程:执行
runtime.main
和用户main
函数的初始M(绑定P,运行主Goroutine)。 - sysmon线程:在程序初始化阶段由运行时启动,独立运行。
- 其他可能线程:例如,若使用CGO或某些系统调用(如异步I/O),可能会提前创建额外M。
- 主线程:执行
- 总结:Go程序启动时至少有两个线程(主线程 + sysmon线程),但实际数量可能因环境或代码略有差异。
4. 关键验证与示例
验证1:sysmon的M独立于普通M
- 通过Go的调试工具(如
GODEBUG=schedtrace=1000
)观察: 输出中会显示GODEBUG=schedtrace=1000 ./your_program
sysmon
线程的独立存在(不绑定P)。
验证2:M总数与sysmon的关系
- 若强制限制M数量(例如设置为2):
此时程序仍能启动(主线程 + sysmon线程),但无法创建更多M。package main import "runtime/debug" func main() { debug.SetMaxThreads(2) // 主线程 + sysmon线程 // 任何尝试创建新M的操作(如启动Goroutine)将触发错误 }
验证3:sysmon的M不绑定P
- 通过
runtime
包代码分析:// runtime/proc.go func sysmon() { // sysmon循环中不涉及P的绑定逻辑 for { // 执行监控任务(不依赖P) } }
5. 总结
- sysmon的M:专用、独立、不绑定P,计入M总数但不受常规调度逻辑影响。
- 线程数量:Go程序启动时至少有两个线程(主线程 + sysmon线程),但实际可能更多。
- 身份隔离:
sysmon
的M不会执行用户Goroutine,也不会与其他M交换角色,确保监控任务的稳定性。
这种设计保障了Go调度器的公平性和健壮性,同时避免了监控任务与用户任务的资源竞争。
Go程序启动线程数的构成与限制
Go程序启动时一定有两个以上的线程吗?是否有可能通过某些方法限制只有一个线程?
Go 程序启动时默认至少有两个线程(主线程 + sysmon 监控线程),且无法通过标准方法限制到仅一个线程。以下是详细解释:
1. Go 程序启动时的线程数量
(1) 默认情况
- 主线程:执行用户
main
函数和runtime
初始化逻辑,绑定一个 P(逻辑处理器)。 - sysmon 线程:独立的监控线程,不绑定 P,负责抢占、GC 触发等全局任务。
- 其他可能线程:若使用 CGO 或某些系统调用(如异步 I/O),可能提前创建更多线程。
因此,正常 Go 程序启动时至少有两个线程,无法跳过这一机制。
(2) 验证实验
尝试通过 debug.SetMaxThreads(1)
强制限制线程数:
package main
import "runtime/debug"
func main() {
debug.SetMaxThreads(1) // 设置最大线程数为 1
// 程序会因主线程 + sysmon 线程超过限制而崩溃
}
输出结果:
runtime: program exceeds 1-thread limit
fatal error: thread exhaustion
证明即使设置最大线程数为 1,Go 仍需要至少两个线程(主线程 + sysmon),导致程序崩溃。
2. 为什么不能减少到一个线程?
(1) sysmon 的必要性
- 抢占式调度:依赖 sysmon 检测并抢占长时间运行的 Goroutine(如死循环)。
- GC 触发:垃圾回收的标记阶段需要 sysmon 协调。
- 网络轮询:处理异步 I/O 事件的就绪通知。 若没有 sysmon,程序可能因无法抢占 Goroutine 或处理系统事件而阻塞或崩溃。
(2) 主线程的不可替代性
- 程序入口:主线程是 Go 程序的执行起点,负责初始化运行时和用户代码。
- 绑定 P:主线程必须绑定 P 才能运行 Goroutine 队列,而 sysmon 线程不绑定 P,二者角色严格区分。
3. 极端情况:能否通过修改运行时源码实现单线程?
从技术理论上,可以通过以下非标准方法强行实现,但极不推荐:
- 修改 Go 运行时源码:删除 sysmon 的启动逻辑,合并主线程与监控任务。
- 副作用:
- 失去抢占能力,Goroutine 可能永久占用 CPU。
- GC 无法正确触发,导致内存泄漏或 OOM。
- 网络 I/O 等异步操作无法处理,程序行为异常。
- 结论:这种修改会破坏 Go 的核心调度机制,仅适用于特定实验场景,无实际使用价值。
4. 对比其他语言的单线程模型
- JavaScript/Node.js:单线程 + 事件循环,但依赖底层线程池处理 I/O。
- Lua:协程单线程,但实际仍可能依赖宿主环境的多线程(如 LuaJIT)。
- Go 的设计取舍:通过多线程(M)实现高并发,但通过轻量级调度(GMP)降低开销,单线程并非设计目标。
总结
- Go 程序必须至少有两个线程(主线程 + sysmon),无法通过标准方法限制到单线程。
- 根本原因:sysmon 是 Go 调度器和运行时功能(如 GC、抢占)的核心依赖,移除它将导致程序不可用。
- 建议:若需减少资源占用,可优化 Goroutine 数量或限制 P 的数量(
GOMAXPROCS
),但线程数下限由运行时保障。
Go程序中P(逻辑处理器)和M(工作线程)的关系和协作
我使用 GOMAXPROCS=1 go run main.go
启动一个单goroutine的死循环,发现这个进程有3个线程。这3个线程分别是什么?
当使用 GOMAXPROCS=1
运行一个单 Goroutine 的死循环时,进程的 3 个线程通常由以下角色组成:
1. 三个线程的组成
线程类型 | 作用 |
---|---|
主线程 | 运行用户代码的初始线程,绑定一个 P(逻辑处理器),执行 main 函数。 |
sysmon 监控线程 | 独立的后台线程,负责抢占、GC 触发、网络轮询等全局任务。 |
网络轮询器线程 | 处理异步 I/O 事件(如 epoll /kqueue ),由 Go 运行时自动创建。 |
2. 详细分析
(1) 主线程
- 绑定 P:
GOMAXPROCS=1
表示只有一个 P,主线程独占该 P。 - 执行代码:运行用户
main
函数的 Goroutine(死循环占用 CPU)。 - 无系统调用:由于死循环中无 I/O 或阻塞操作,主线程不会让出 P。
(2) sysmon 线程
- 独立运行:不绑定任何 P,负责:
- 检测并抢占运行时间超过 10ms 的 Goroutine(但由于死循环无函数调用,Go 1.14+ 的信号抢占才有效)。
- 触发垃圾回收(GC)的标记阶段。
- 轮询网络事件(与网络轮询器线程协同工作)。
(3) 网络轮询器线程
- 异步 I/O 处理:Go 运行时在初始化时自动创建,即使程序未显式使用网络。
- 操作系统依赖:
- Linux:基于
epoll
,由runtime/netpoll_epoll.go
实现。 - macOS:基于
kqueue
,由runtime/netpoll_kqueue.go
实现。
- Linux:基于
- 默认启动:无论是否有网络操作,该线程都会被创建,用于处理潜在的 I/O 事件。
3. 验证实验
(1) 查看线程信息
在 Linux 下通过 ps -T -p <PID>
或 cat /proc/<PID>/task/
查看线程:
# 示例输出(线程名标识用途):
~$ ps -T -p <PID>
PID SPID TTY TIME CMD
1000 1000 pts/0 00:00 main # 主线程(用户代码)
1000 1001 pts/0 00:00 sysmon # sysmon 线程
1000 1002 pts/0 00:00 IO wait # 网络轮询器线程
(2) 代码行为分析
以下代码会触发 3 个线程:
package main
func main() {
for {} // 单 Goroutine 的死循环
}
- 无网络操作:但网络轮询器线程仍被默认创建,确保异步 I/O 就绪时能及时响应。
- 无系统调用:主线程持续占用 CPU,但 Go 1.14+ 的信号抢占机制会通过 sysmon 介入。
4. 为什么需要网络轮询器线程?
Go 的设计哲学是**“默认支持高并发”**,因此运行时会预先初始化关键组件:
- 网络 I/O 就绪通知:即使当前无网络操作,运行时也会准备好处理突发的 I/O 事件。
- 统一的事件循环:将
epoll
/kqueue
的等待逻辑放在独立线程,避免阻塞用户 Goroutine。
5. 特殊场景下的线程数变化
场景 | 线程数变化 | 原因 |
---|---|---|
禁用网络轮询器 | 2 个线程(主线程 + sysmon) | 需修改运行时源码(不实际推荐)。 |
触发 GC | 可能新增临时线程(标记、清扫任务) | GC 的并行阶段会创建短暂的工作线程。 |
发生阻塞系统调用 | 新增线程解除 P 阻塞 | 若 Goroutine 执行系统调用,运行时会创建新线程接管 P。 |
总结
- 3 个线程的必然性:Go 运行时默认初始化主线程、sysmon 和网络轮询器线程,确保抢占、I/O 和调度功能。
- 设计权衡:通过预创建少量线程,避免运行时动态创建的开销,保障高性能和低延迟。
- 资源控制:可通过
GOMAXPROCS
限制 P 的数量,但线程数下限由运行时保障(通常 ≥3)。
sysmon 在 Go 的哪个版本里被引入?
sysmon 在 Go 的哪个版本里被引入?
sysmon(系统监控线程)是 Go 语言调度器的重要组成部分,它负责全局状态的监控和调度优化。根据历史版本演进和搜索结果中的信息:
-
sysmon 的引入时间
sysmon 线程的引入可以追溯到 Go 1.2 版本。在 Go 1.2 中,调度器首次引入了基于协作的抢占式调度机制,而 sysmon 作为后台监控线程,负责检测长时间运行的 Goroutine 并触发抢占。此时的抢占依赖于 Goroutine 主动调用函数时检查抢占标记(如栈扩展),而 sysmon 通过定期扫描并标记需抢占的 Goroutine 实现协作式调度。 -
sysmon 的演进
- Go 1.14 版本:引入了基于信号的抢占式调度,sysmon 的功能进一步增强。它通过向运行时间超过 10ms 的 Goroutine 所在线程发送
SIGURG
信号,强制触发抢占,解决了无函数调用的死循环无法被抢占的问题。 - 当前版本:sysmon 的功能包括内存回收、网络事件处理、强制 GC 触发、抢占调度等,成为 Go 调度器公平性和稳定性的核心保障。
- Go 1.14 版本:引入了基于信号的抢占式调度,sysmon 的功能进一步增强。它通过向运行时间超过 10ms 的 Goroutine 所在线程发送
-
sysmon 的关键作用
- 协作式抢占(Go 1.2~1.13):通过栈扫描和抢占标记实现,依赖 Goroutine 调用函数时触发调度。
- 信号抢占(Go 1.14+):通过操作系统信号强制抢占,即使 Goroutine 无函数调用也能被调度。
综上,sysmon 线程最早在 Go 1.2 版本被引入,并随着调度器的演进不断优化功能,成为现代 Go 并发模型中不可或缺的组件。
垃圾回收的STW与抢占
我已知 Go 的垃圾回收基于三色标记法与混合写屏障。垃圾回收具体是什么时候发生 STW ?如何发生抢占?
Go 的垃圾回收(GC)确实基于三色标记法和混合写屏障(Hybrid Write Barrier),其设计目标是低延迟和高并发性。在 GC 过程中,Stop The World(STW) 的时长被大幅缩短,但并未完全消除。以下是 GC 中 STW 发生的具体阶段和抢占机制:
一、STW(Stop The World)发生的阶段
Go 的 GC 分为四个阶段,其中 STW 仅在标记终止阶段(Mark Termination)完全暂停所有用户 Goroutine:
1. 标记准备阶段(Mark Setup)
- 短暂 STW(微秒级):暂停所有 Goroutine,初始化 GC 任务,开启写屏障(Write Barrier)。
- 目的:确保在并发标记期间,所有对内存的修改都能被写屏障捕获。
2. 并发标记阶段(Concurrent Mark)
- 无 STW:后台线程(GC Worker)与用户 Goroutine 并发执行,扫描存活对象。
- 混合写屏障:在用户代码修改指针时,通过写屏障记录修改,避免漏标。
3. 标记终止阶段(Mark Termination)
- 完全 STW(主要暂停点):暂停所有用户 Goroutine,完成最后的标记工作。
- 处理剩余的标记任务(如栈扫描)。
- 关闭写屏障,统计存活对象。
- 暂停时长:通常在 微秒到毫秒级(Go 1.14 后优化到亚毫秒级)。
4. 并发清理阶段(Concurrent Sweep)
- 无 STW:回收未被标记的内存块,与用户 Goroutine 并发执行。
二、STW 如何触发抢占?
在标记终止阶段,Go 运行时需要让所有用户 Goroutine 快速暂停。这里依赖两种机制:
1. 协作式抢占(Cooperative Preemption)
- 主动暂停:当 Goroutine 执行到函数调用或安全点(Safe Point) 时(如调用
runtime
包中的函数),会检查是否需要进入 STW。 - 局限性:若 Goroutine 处于无函数调用的密集循环(如
for {}
),协作式抢占会失败。
2. 信号抢占(Signal-based Preemption)
- 强制暂停:Go 1.14 后,通过向运行中的线程发送
SIGURG
信号,触发抢占。- 系统监控线程(sysmon):检测到 STW 请求后,向所有 P(Processor)关联的线程发送信号。
- 信号处理函数:在收到信号时,保存当前 Goroutine 的上下文并暂停执行。
- 解决死循环问题:即使 Goroutine 处于无函数调用的循环中,也能被强制抢占。
三、关键场景示例
场景 1:函数调用触发协作式抢占
func main() {
go func() {
for {
time.Sleep(1) // 函数调用触发抢占检查
}
}()
}
- 每次
time.Sleep
调用会主动让出 CPU,触发抢占检查,顺利进入 STW。
场景 2:无函数调用的死循环
func main() {
go func() {
for {} // 无函数调用,Go 1.13 前无法抢占,1.14+ 通过信号抢占
}()
}
- Go 1.14 前:无法抢占,导致 STW 延迟(甚至程序卡死)。
- Go 1.14+:通过
SIGURG
信号强制抢占,确保 STW 及时执行。
四、优化与挑战
- 缩短 STW 时间:
- 通过并发标记和混合写屏障,将大部分工作移至并发阶段。
- Go 1.15 后,标记终止阶段的 STW 时间进一步优化到 100 微秒以下。
- 抢占延迟:
- 信号抢占需要操作系统支持,且信号处理本身有微秒级开销。
- 极端场景(如大量线程或 CPU 负载过高)可能影响抢占效率。
总结
- STW 发生时机:仅在标记终止阶段完全暂停所有 Goroutine,时间极短。
- 抢占机制:
- 协作式抢占:依赖函数调用或安全点。
- 信号抢占(Go 1.14+):强制暂停无函数调用的 Goroutine。
- 设计目标:通过混合机制,在低延迟和高并发之间取得平衡。
Go 的 GC 通过逐步减少 STW 时间和增强抢占能力,显著降低了业务代码的感知停顿(如从 Go 1.0 的秒级 STW 优化到现在的亚毫秒级)。
写屏障(Write Barrier)机制解析
为什么标记准备阶段只是短暂 STW ,但标记终止阶段却要完全 STW ?这两次 STW 里都具体要做什么?
Go 的垃圾回收(GC)在标记准备阶段(Mark Setup)和标记终止阶段(Mark Termination)均涉及 STW(Stop The World),但两者的 暂停范围 和 耗时 存在显著差异。这种差异源于两个阶段的核心任务不同,以下是详细解释:
一、标记准备阶段(Mark Setup)
1. 为什么只是短暂 STW?
- 目标:为并发标记阶段(Concurrent Mark)做准备,确保所有 Goroutine 在并发标记期间的内存修改能被正确跟踪。
- 无需完全暂停:不需要等待所有 Goroutine 完全停止,只需确保写屏障(Write Barrier)被正确启用,并为后续标记初始化关键数据结构。
- 耗时极短:通常在 微秒级,仅需暂停所有 Goroutine 至安全点(Safe Point)以快速完成初始化。
2. 具体操作
- 开启写屏障:启动混合写屏障(Hybrid Write Barrier),确保后续内存写入被记录,避免漏标。
- 扫描根对象:快速扫描全局变量、栈等根对象(Roots),找到初始存活对象。
- 初始化标记队列:将根对象加入标记队列,供并发标记阶段使用。
- 协调 Goroutine:通过协作式抢占或信号抢占,确保所有 Goroutine 短暂暂停以启用写屏障。
二、标记终止阶段(Mark Termination)
1. 为什么要完全 STW?
- 目标:确保标记结果的一致性,避免并发操作干扰最终统计。
- 关键操作不可并发:
- 需要关闭写屏障,停止记录内存修改。
- 必须完成所有未完成的标记任务(如栈扫描)。
- 统计存活对象,计算需要回收的内存。
- 完全一致性要求:若允许 Goroutine 继续运行,可能修改内存状态,导致统计错误。
2. 具体操作
- 完全暂停所有 Goroutine:通过信号抢占(Go 1.14+)强制暂停所有运行中的 Goroutine。
- 处理剩余标记任务:
- 完成栈扫描(部分 Goroutine 的栈可能未在并发阶段扫描)。
- 处理标记队列中剩余的对象。
- 关闭写屏障:停止混合写屏障,后续用户代码的内存写入不再被跟踪。
- 统计存活对象:确定所有存活对象,生成待回收的内存块列表。
- 准备清理阶段:将未标记的内存标记为可回收,供并发清理阶段(Sweep)处理。
三、关键差异对比
阶段 | 标记准备阶段 | 标记终止阶段 |
---|---|---|
STW 范围 | 短暂暂停,仅需启用写屏障和初始化 | 完全暂停所有 Goroutine |
耗时 | 微秒级(如 10-100μs) | 亚毫秒级(如 100μs-1ms) |
核心任务 | 开启写屏障,扫描根对象 | 关闭写屏障,完成标记,统计结果 |
并发性 | 完成后立即恢复用户 Goroutine 并发执行 | 必须完全暂停,确保最终一致性 |
四、为何标记终止阶段必须完全 STW?
- 写屏障关闭的原子性:
- 关闭写屏障时,若仍有 Goroutine 在运行,可能漏掉某些内存修改,导致错误回收存活对象。
- 统计存活对象的精确性:
- 必须保证所有标记任务完成,否则可能误判对象生死。
- 避免竞争条件:
- 若允许 Goroutine 在标记终止阶段运行,其内存分配或指针修改可能破坏标记结果。
五、示例场景
场景 1:标记准备阶段的短暂 STW
// 标记准备阶段:
1. STW 暂停所有 Goroutine(微秒级)。
2. 开启写屏障,扫描根对象(全局变量、栈)。
3. 恢复所有 Goroutine,并发标记开始。
场景 2:标记终止阶段的完全 STW
// 标记终止阶段:
1. STW 完全暂停所有 Goroutine(亚毫秒级)。
2. 扫描未处理的栈,完成剩余标记。
3. 关闭写屏障,统计存活对象。
4. 恢复所有 Goroutine,并发清理开始。
六、优化与挑战
- Go 1.14+ 的信号抢占:确保标记终止阶段能快速暂停死循环 Goroutine。
- 写屏障的设计:混合写屏障减少了 STW 时间,但仍需短暂暂停以启用/关闭。
- 并发标记的平衡:尽可能将工作移至并发阶段(如大部分标记和清理),最小化 STW 影响。
总结
- 标记准备阶段:短暂 STW 是为了启用写屏障和初始化,不依赖完全一致性。
- 标记终止阶段:完全 STW 是为了保证标记结果的绝对一致性和安全性。
- 设计核心:通过分离关键操作(启用/关闭写屏障、统计结果),Go 在低延迟和高并发之间取得了平衡。