调度,在计算机科学中,是一个核心概念,它决定了在多任务环境中,哪些任务何时、以何种顺序运行。对于现代操作系统和运行时而言,高效且公平的调度机制是其性能和响应能力的关键。Go语言作为一个强调并发和高性能的现代编程语言,其调度器(Go Scheduler)的设计同样精妙而复杂。
在Go 1.14版本之前,Go调度器主要依赖于一种“协作式抢占”(Cooperative Preemption)机制。这种机制虽然在多数情况下工作良好,但在特定场景下,例如遇到长时间运行且不包含函数调用的CPU密集型循环时,会导致其他Goroutine饥饿,影响系统的公平性和响应性。为了解决这一问题,Go 1.14引入了一种更强大的机制——基于信号的异步抢占式调度(Signal-Based Asynchronous Preemptive Scheduling)。
本次讲座将深入探讨Go语言的调度机制,从基础概念入手,逐步揭示Go 1.14+中基于信号的异步抢占式调度的物理细节,包括其工作原理、实现机制、涉及的运行时组件以及对Go程序行为的影响。
调度:协作与抢占的抉择
在深入Go的调度细节之前,我们首先需要理解调度器的基本概念及其两种主要模式:协作式调度和抢占式调度。
1. 调度器的核心职责
调度器是操作系统或运行时环境中的一个关键组件,其核心职责是在多个可运行任务之间分配有限的处理器资源。它需要做出以下决策:
- 何时切换任务? (When to switch?)
- 切换到哪个任务? (Which task to switch to?)
- 如何高效地执行切换? (How to perform the switch efficiently?)
一个优秀的调度器应具备以下特性:
- 公平性 (Fairness): 确保所有任务都能获得合理的处理器时间,避免某些任务长时间饥饿。
- 效率 (Efficiency): 最小化调度开销,最大化处理器利用率。
- 响应性 (Responsiveness): 快速响应交互式任务或高优先级任务的需求。
- 吞吐量 (Throughput): 在单位时间内完成尽可能多的任务。
2. 协作式调度 (Cooperative Scheduling)
在协作式调度中,任务(或线程/Goroutine)需要主动放弃处理器控制权,让调度器有机会切换到其他任务。这通常通过调用特定的“yield”或“sleep”函数来完成。
优点:
- 简单: 实现相对简单,不需要复杂的内核或运行时支持。
- 低开销: 任务切换的上下文保存和恢复通常较少,因为任务在已知安全点主动放弃。
- 易于同步: 由于任务主动放弃控制,开发者更容易预测切换点,从而简化并发同步。
缺点:
- 不公平: 如果一个任务长时间不主动放弃控制权,其他任务将无法运行,导致饥饿。
- 响应性差: 无法及时响应高优先级任务或外部事件。
- 程序错误影响大: 一个有缺陷的任务可能导致整个系统停滞。
Go在1.14之前,主要依赖于协作式抢占。Go运行时会在一些“安全点”进行检查,例如函数调用、通道操作、垃圾回收STW(Stop The World)阶段等。如果一个Goroutine长时间运行且不经过这些安全点(比如一个for {}循环),它就会霸占M(OS线程),导致其他Goroutine无法调度。
3. 抢占式调度 (Preemptive Scheduling)
与协作式调度相反,抢占式调度允许调度器在任何时间点中断一个正在运行的任务,强制其放弃处理器控制权,并将处理器分配给另一个任务。这种中断通常由硬件中断(如定时器中断)或软件信号触发。
优点:
- 公平性好: 确保所有任务都有机会运行,避免饥饿。
- 响应性高: 能够及时响应高优先级任务或外部事件。
- 鲁棒性强: 单个任务的错误不会导致整个系统停滞。
- 更高的吞吐量: 通过更均匀地分配CPU时间,可以提高整体任务完成效率。
缺点:
- 复杂: 实现需要更复杂的内核或运行时支持,涉及中断处理、上下文切换等底层机制。
- 开销较高: 任务切换可能发生在任意时间点,需要保存和恢复更多的上下文信息。
- 同步困难: 任务可能在任何指令之后被中断,这使得并发编程中的锁和同步机制变得更加关键和复杂。
Go 1.14+引入的基于信号的异步抢占式调度,正是为了克服协作式调度的缺点,提供更强的公平性和响应性。
| 特性 | 协作式调度 | 抢占式调度 |
|---|---|---|
| 控制权转移 | 任务主动放弃 | 调度器强制中断 |
| 公平性 | 差(可能饥饿) | 好(避免饥饿) |
| 响应性 | 差(依赖任务主动性) | 高(可及时中断) |
| 实现复杂性 | 简单 | 复杂 |
| 同步难度 | 相对简单(可预测切换点) | 困难(切换点不可预测,需严谨同步) |
| 典型场景 | 早期操作系统,Go 1.14前CPU密集型循环 | 现代操作系统,Go 1.14+,实时系统 |
Go调度器基础:M、P、G模型
理解Go的抢占式调度,首先要了解Go调度器的核心模型:M、P、G。这个模型是Go语言高并发能力的基础。
- G (Goroutine): Go语言并发的基本单元,类似于轻量级线程。每个Go函数调用都可以作为一个Goroutine运行。Goroutine由Go运行时管理,拥有自己的栈空间,但栈空间非常小(初始仅几KB),并且可以动态伸缩。
- M (Machine/OS Thread): 操作系统线程。Go运行时会将Goroutine调度到M上执行。M是真正的执行者,它从P那里获取可运行的G,并执行其代码。Go运行时会创建和管理一定数量的M,这些M在操作系统层面是普通的线程。
- P (Processor/Context): 逻辑处理器。P是M和G之间的桥梁,它代表了M执行Goroutine所需的上下文。每个P都维护一个本地可运行Goroutine队列。P的数量通常由
GOMAXPROCS环境变量决定,默认是CPU的核心数。
工作流程简述:
- 当一个G准备好运行时,它会被放入P的本地队列或全局队列。
- 一个M需要执行Goroutine时,会尝试从其关联的P的本地队列中获取G。
- 如果P的本地队列为空,M会尝试从全局队列中获取G,或者从其他P的本地队列中“窃取”(work-stealing)G。
- M获取到G后,将G的上下文加载到M上,并开始执行G的代码。
- 当G执行完毕、阻塞、或需要切换时,M会将G的上下文保存起来,并从P那里获取下一个G来执行。
这个模型有效地将Go的并发原语(Goroutine)与底层操作系统线程解耦,使得Go可以在有限的OS线程上高效地运行成千上万个Goroutine。P的存在确保了即使M数量少于GOMAXPROCS,Go运行时也能充分利用CPU核心。
Go早期协作式抢占的局限性
在Go 1.14之前,Go调度器主要依赖于Goroutine在特定“安全点”进行协作。这些安全点包括:
- 函数调用: 每次Go函数调用时,运行时都会检查是否需要进行调度。
- 通道操作: 对channel的发送和接收操作。
- 网络I/O: 进行网络读写时。
- 系统调用: 进入或退出系统调用时。
- 垃圾回收: 在垃圾回收的STW阶段,所有Goroutine都会被停止。
这种机制在大多数Go程序中运行良好,因为Go程序通常包含大量的函数调用、通道通信和I/O操作。然而,它在特定场景下暴露出严重的局限性:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1) // 限制为一个P,更容易观察饥饿现象
// Goroutine A: 打印消息
go func() {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println("Goroutine A: Running...")
}
}()
// Goroutine B: CPU密集型循环,不包含函数调用
go func() {
fmt.Println("Goroutine B: Starting CPU-bound loop...")
sum := 0
for i := 0; i < 1_000_000_000; i++ { // 大量迭代,不调用其他函数
sum += i
}
fmt.Println("Goroutine B: Finished CPU-bound loop. Sum =", sum)
}()
// 主Goroutine等待一段时间,确保其他Goroutine有机会运行
time.Sleep(5 * time.Second)
fmt.Println("Main Goroutine: Exiting.")
}
在Go 1.14之前,如果你运行上述代码,你会发现Goroutine A可能只打印了几次甚至一次消息,然后Goroutine B会长时间霸占CPU,直到其循环结束。 Goroutine B的循环内部没有进行任何函数调用、通道操作或系统调用,因此Go调度器没有机会中断它。这导致Goroutine A长时间无法执行,出现了饥饿(Starvation)现象。
这种饥饿问题不仅影响其他Goroutine的公平性,还会对系统的响应性产生负面影响。例如,如果有一个处理用户请求的Goroutine被一个计算密集型Goroutine卡住,用户的请求将无法得到及时响应。此外,垃圾回收器在执行“扫描栈”等操作时,需要遍历所有Goroutine的栈。如果某些Goroutine长时间不被调度,垃圾回收器可能需要等待更长时间才能完成这些操作,从而延长STW暂停时间。
为了解决这些问题,Go运行时需要一种机制,能够在Goroutine不主动协作的情况下,强制中断其执行。这就是异步抢占式调度诞生的原因。
异步抢占式调度 (Go 1.14+)
Go 1.14引入的异步抢占式调度,旨在解决CPU密集型Goroutine的饥饿问题,提高调度器的公平性和响应性。它通过一种“混合式”的方法实现:利用操作系统信号来异步触发抢占,但实际的上下文切换仍然发生在Goroutine的协作点。
1. 为什么选择信号?
操作系统信号(Signals)是一种软件中断机制,允许操作系统或进程向另一个进程(或同一进程内的线程)发送异步通知。信号可以用于多种目的,包括:
- 通知进程发生了特定事件(如子进程终止)。
- 请求进程执行特定操作(如中断处理)。
- 处理异常情况(如除零错误、非法内存访问)。
对于异步抢占,信号是理想的选择,原因如下:
- 异步性: 信号可以在程序执行的任意时刻到达,中断当前正在运行的线程。
- 内核支持: 信号是操作系统提供的标准机制,可靠且高效。
- 目标线程: 信号可以精确地发送给特定的OS线程(M),确保只中断目标Goroutine所依附的M。
Go在Unix-like系统(Linux, macOS等)上主要使用SIGPROF信号来实现异步抢占。SIGPROF通常用于CPU时间分析,它会定期发送给进程,报告CPU的使用情况。Go运行时巧妙地复用了这个信号,使其既能用于Goroutine调度,也能用于性能分析。
2. Go调度器如何请求抢占?
异步抢占的整个过程可以分为几个关键阶段:
阶段一:sysmon Goroutine检测并请求抢占
Go运行时有一个特殊的Goroutine,名为sysmon(system monitor),它在一个独立的M上以固定的间隔(通常是10毫秒)运行。sysmon负责执行多项后台任务,包括:
- 垃圾回收检查。
- 网络轮询(NetPoller)。
- 释放不必要的M。
- 检测长时间运行的Goroutine并请求抢占。
sysmon通过检查每个P的schedtick(调度计数器)来判断是否有Goroutine长时间霸占CPU。每个P都有一个schedtick,每次Goroutine被调度时都会递增。如果一个P的schedtick在两次sysmon检查之间没有变化,或者P.runq(本地可运行队列)长时间为空但P.m.spinning为真(表示M正在忙着运行G),那么sysmon就会认为这个P上的Goroutine可能正在长时间运行,需要被抢占。
当sysmon决定抢占一个P时,它会执行以下操作:
- 将该P的
P.preempt标志设置为true。 - 向该P当前关联的M发送一个
SIGPROF信号。
// 伪代码: sysmon Goroutine的一部分逻辑
func sysmon() {
for {
// ... 其他监控任务 ...
// 遍历所有P,检查是否需要抢占
for _, p := range allp {
if p.status == _Pidle { // P空闲,无需抢占
continue
}
// 如果P的调度计数器长时间未更新,或者M正在运行G但没有新的调度发生
if p.schedtick == p.lastSchedtick && p.m != nil && p.m.spinning {
// 请求抢占
p.preempt = true
// 向当前M发送SIGPROF信号
// 这个操作在Go运行时内部通过runtime.signalM发送
// 例如:runtime.signalM(p.m, runtime.SIGPROF)
}
p.lastSchedtick = p.schedtick // 更新上一次的调度计数器
}
// ... 睡眠一段时间后继续 ...
}
}
3. SIGPROF信号的处理
阶段二:运行时安装信号处理程序
Go运行时在程序启动时会安装自己的SIGPROF信号处理程序。这个处理程序是C语言实现的,因为它需要在内核模式下运行,并对Go运行时的数据结构进行操作。
Go运行时通过runtime.initsig和runtime.setsig等函数,使用sigaction系统调用来设置SIGPROF的处理器。它通常会设置SA_RESTART(允许被中断的系统调用在信号处理程序返回后自动重启)和SA_SIGINFO(允许信号处理程序接收更详细的信号信息,包括上下文寄存器)。
阶段三:信号处理程序执行
当sysmon向目标M发送SIGPROF信号后,操作系统会中断该M当前正在执行的用户代码,并跳转到Go运行时注册的SIGPROF处理程序中执行。
信号处理程序的核心逻辑如下:
- 保存上下文: 操作系统在调用信号处理程序之前,会自动保存被中断线程的当前CPU寄存器状态(上下文)。
- 检查M状态: 信号处理程序首先会检查当前M的状态。
- 如果M正在执行Cgo代码或在系统调用中(
M.inGoSyscall或M.inCgo),则无法进行抢占。在这种情况下,信号处理程序会立即返回,等待M回到Go代码后再次尝试抢占。 - 如果M正在执行Go代码,并且其关联的P的
P.preempt标志为true(表示sysmon请求了抢占),则继续处理。
- 如果M正在执行Cgo代码或在系统调用中(
- 修改栈保护页 (Stack Guard Page): 这是异步抢占最关键的一步。信号处理程序会找到当前Goroutine(G)的栈信息,并修改其
g.stackguard0字段。stackguard0是Go运行时用于检测栈溢出的一个特殊值。- 正常情况下,
stackguard0指向Goroutine栈的最低地址,用于在每次函数调用时检查栈是否即将溢出。 - 为了触发抢占,信号处理程序会将
g.stackguard0设置为一个特殊值stackPreempt。
- 正常情况下,
- 返回: 信号处理程序执行完毕后,操作系统会恢复之前保存的M的上下文,并让M从被中断的位置继续执行。
// 伪代码: Go运行时内部的SIGPROF信号处理程序
void sigprof_handler(int sig, siginfo_t *info, void *context) {
// 获取当前线程的M
M *m = getg()->m;
if (m == nil || m->curg == nil) { // 如果M没有关联Goroutine或M正在初始化
return;
}
G *g = m->curg;
P *p = m->p;
// 检查是否需要抢占 (由sysmon设置)
if (p == nil || !p->preempt) {
// 如果不是抢占请求,可能是常规的profiling信号,做profiling工作
// ...
return;
}
// 检查Goroutine是否处于可抢占状态
// 不能在cgo或系统调用中抢占
if (g->syscallstack != nil || g->inAtomic || g->inIO || g->inExternalCode) {
// 无法立即抢占,等待G回到Go用户代码
return;
}
// 设置抢占标记
g->preempt = true;
// 修改栈保护页,强制下次函数调用触发stack check
// context->uc_mcontext->rsp 是栈指针
// g->stackguard0 = stackPreempt 是关键
// 信号处理程序会根据架构修改上下文中的栈指针
// 在x86-64上,通过修改g->stackguard0,使得下一次函数序言中的栈检查失败
// 从而强制调用morestack
if (GOARCH == "amd64") {
// 这是一个概念性的描述,实际操作更复杂,涉及汇编指令的修改或寄存器上下文
// 实际Go通过修改g->stackguard0来实现,而不是直接修改指令
// 这里只是为了说明其效果:让函数序言的栈检查触发morestack
g->stackguard0 = (uintptr)stackPreempt;
}
}
4. 协作式“陷阱”:morestack与preemptM
阶段四:Goroutine遇到栈检查
当信号处理程序返回后,被中断的Goroutine会继续执行。由于g.stackguard0被修改为stackPreempt,当这个Goroutine下一次执行函数调用时,其函数序言(function prologue)中的栈检查逻辑将不再通过。
在Go的函数序言中,会有一段汇编代码来检查当前栈空间是否足够。如果不足,或者stackguard0被设置为stackPreempt,则会跳转到runtime.morestack函数。
// 伪代码: Go函数序言中的栈检查 (x86-64)
TEXT runtime.some_go_func(SB), NOSPLIT, $0-8
// ... 保存寄存器等 ...
// 栈检查: SP是否低于(栈指针 - stackguard0)
// 如果g->stackguard0 == stackPreempt,这个检查就会失败
MOVQ SP, AX
MOVQ (g_stackguard0)(R14), CX // R14是当前G的指针
CMPQ AX, CX
JBE runtime.morestack(SB) // 如果SP <= CX,说明需要更多栈空间或触发抢占
阶段五:morestack触发抢占
runtime.morestack函数是Go运行时处理栈增长和抢占的核心。当morestack被调用时,它会检查g.preempt标志:
- 如果
g.preempt为true(表示抢占被请求),morestack不会尝试增长栈,而是调用runtime.preemptM。 - 如果
g.preempt为false,则说明是真的栈空间不足,morestack会尝试分配更多栈空间。
阶段六:preemptM执行抢占
runtime.preemptM是实际执行Goroutine上下文切换的地方:
- 它会保存当前Goroutine(G)的完整上下文(包括CPU寄存器、程序计数器PC、栈指针SP等)。
- 将G的状态从
_Grunnable(或其他运行状态)切换为_Grunnable,并将其放回其P的本地运行队列(或全局队列)。 - 将P的
preempt标志重置为false。 - 然后,
preemptM会调用schedule()函数,让M从其P的队列中获取下一个可运行的G来执行。
至此,一个Goroutine的异步抢占过程完成。最初被中断的Goroutine被放回队列等待下一次调度,而另一个Goroutine获得了执行机会。
总结异步抢占流程
sysmonGoroutine: 定期检查P的schedtick,发现长时间运行的Goroutine。- 请求抢占:
sysmon将P的P.preempt标志设置为true,并向M发送SIGPROF信号。 - 信号处理程序: M被
SIGPROF中断,运行时注册的信号处理程序执行。 - 修改
stackguard0: 信号处理程序设置g.preempt = true,并将当前G的g.stackguard0设置为stackPreempt。 - 恢复执行: M从中断点恢复执行。
- 触发
morestack: 当G下次进行函数调用时,函数序言的栈检查失败,跳转到runtime.morestack。 - 调用
preemptM:morestack发现g.preempt为true,调用runtime.preemptM。 - 上下文切换:
preemptM保存G的上下文,将其放回运行队列,并调度M执行下一个Goroutine。
这个过程是一个巧妙的“混合式”抢占:信号提供异步中断的能力,但实际的上下文切换仍然发生在Goroutine代码中的协作点(函数序言的栈检查),从而避免了在任意指令处进行上下文切换的复杂性和潜在危险。
代码示例与验证
让我们用代码来验证Go 1.14+的异步抢占效果。
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1) // 限制为一个P,更容易观察调度行为
// Goroutine A: 打印消息,模拟一个低频任务
go func() {
for i := 0; i < 10; i++ {
time.Sleep(200 * time.Millisecond) // 引入延迟,但仍会被抢占
fmt.Printf("Goroutine A: Running... (%d)n", i)
}
}()
// Goroutine B: CPU密集型循环,不包含函数调用
go func() {
fmt.Println("Goroutine B: Starting CPU-bound loop...")
sum := 0
// 足够大的循环,确保会触发抢占
for i := 0; i < 5_000_000_000; i++ {
sum += i // 纯计算,无函数调用
}
fmt.Println("Goroutine B: Finished CPU-bound loop. Sum =", sum)
}()
// 主Goroutine等待一段时间,确保其他Goroutine有机会运行
time.Sleep(8 * time.Second)
fmt.Println("Main Goroutine: Exiting.")
}
使用Go 1.14或更高版本编译并运行上述代码。你会观察到Goroutine A会周期性地打印消息,即使Goroutine B正在执行一个长时间的CPU密集型循环。这表明Goroutine B被成功抢占,让Goroutine A获得了执行机会。
为了更清晰地看到调度器的行为,我们可以使用GODEBUG=schedtrace=1000ms环境变量。
GODEBUG=schedtrace=1000ms go run your_program.go
输出中会包含类似以下的信息(部分截取和解释):
SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=1 idlethreads=0 runq=0 [0 0]
SCHED 1004ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=1 idlethreads=0 runq=0 [0 0]
Goroutine B: Starting CPU-bound loop...
Goroutine A: Running... (0)
SCHED 2005ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=1 idlethreads=0 runq=0 [0 0]
Goroutine A: Running... (1)
SCHED 3006ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=1 idlethreads=0 runq=0 [0 0]
Goroutine A: Running... (2)
SCHED 4006ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=1 idlethreads=0 runq=0 [0 0]
Goroutine A: Running... (3)
// ...
Goroutine B: Finished CPU-bound loop. Sum = ...
Main Goroutine: Exiting.
SCHED行显示了调度器每秒的概况。即使gomaxprocs=1,我们仍然看到Goroutine A被定期执行。这正是异步抢占的效果。sysmon检测到Goroutine B长时间运行,发送SIGPROF信号,强制其在下一个函数序言(即使是循环内部的隐式函数序言,或者只是对栈的检查)处让出CPU。
异步抢占的影响与收益
Go 1.14+引入的异步抢占机制,对Go程序的行为和性能带来了显著的积极影响:
- 提高公平性: 解决了CPU密集型Goroutine导致的饥饿问题,确保所有Goroutine都能获得合理的CPU时间。这对于多租户系统或包含混合工作负载的应用程序尤为重要。
- 改善响应性: 交互式任务或高优先级任务不再被长时间运行的计算任务卡住,能够更快地响应用户输入或外部事件。
- 降低GC延迟: 垃圾回收器在执行“停止世界”(STW)阶段时,需要扫描所有Goroutine的栈。如果某些Goroutine长时间不调度,GC可能需要等待更长时间才能安全地完成扫描。异步抢占确保所有Goroutine都能被及时调度,从而减少了GC等待时间,缩短了STW暂停。
- 提高CPU利用率和吞吐量: 通过更均匀地分配CPU时间,调度器可以更好地利用可用的CPU核心,从而提高整体吞吐量。
- 减少死锁风险 (间接): 虽然异步抢占不直接解决死锁问题,但它减少了因Goroutine饥饿而导致的“活性问题”(liveness issues),使得某些依赖于其他Goroutine进行状态更新的并发模式更加健壮。
局限性与注意事项
尽管异步抢占带来了诸多好处,但它并非没有局限性:
- Cgo和系统调用: Go运行时无法在Goroutine执行Cgo代码或进行阻塞式系统调用时对其进行抢占。这是因为此时执行流离开了Go运行时环境,Go无法控制其行为。Goroutine只能在Cgo调用返回或系统调用完成后才能被抢占。
- 原子操作和临界区: Go运行时在执行一些内部的原子操作或进入关键临界区时,会暂时禁用抢占。这是为了保护运行时自身的数据结构不被意外中断而损坏。开发者在Go代码中执行原子操作或持有锁的临界区时,需要确保这些操作足够短小,以免长时间阻塞其他Goroutine。
- 额外的开销: 信号处理、栈保护页修改、
morestack调用和上下文切换都会引入一定的运行时开销。然而,这种开销通常远低于因缺乏抢占而导致的性能损失和响应性问题。Go运行时已经尽力优化这些开销,使其在大多数情况下可以忽略不计。 - 调试复杂性: 抢占式调度使得Goroutine的执行顺序变得更不确定,这可能会增加并发bug的调试难度。然而,这是并发编程的固有挑战,与调度方式无关,需要开发者编写健壮的并发代码。
与其他语言/运行时对比
对比其他语言和运行时的调度机制,可以更好地理解Go调度器的独特之处。
- Java/C# (.NET): 这些语言的并发模型基于OS线程。它们的调度完全由底层操作系统内核负责,是典型的完全抢占式。JVM或CLR只是将语言级的线程映射到OS线程。优点是简单直接,但OS线程的创建和切换开销较大,难以支撑数百万级别的并发任务。
- Erlang/Elixir: Erlang VM (BEAM) 使用自己的轻量级进程(与OS进程不同),这些进程也是绿色线程。BEAM调度器是纯抢占式的,但其抢占点是基于指令计数器或消息处理。每个Erlang进程都运行在一个“时间片”内,时间片结束后,无论是否完成,都会被抢占。其特点是无共享状态、通过消息传递通信,使得抢占带来的同步问题较少。
- Node.js: Node.js是单线程事件循环模型。用户JS代码是完全协作式的,不进行I/O操作的长时间运行JS代码会阻塞整个事件循环。非阻塞I/O操作由底层C++线程池或异步I/O机制处理。这种模型简单高效,但对于CPU密集型任务需要借助
worker_threads或外部进程。 - Rust (异步运行时如Tokio): Rust的异步编程依赖于
Future和async/await。像Tokio这样的运行时,其调度器本质上是协作式的,await点是协作点。但是,Tokio的runtime本身运行在OS线程池上,这些OS线程是抢占式的。因此,Rust的异步代码在OS线程层是抢占的,但在Future层是协作的。
Go的调度器,尤其是1.14+的异步抢占机制,可以说是一种巧妙的混合模型。它在M(OS线程)层面利用了OS信号提供的异步中断能力,但在G(Goroutine)层面,实际的上下文切换仍然发生在特定的“陷阱点”(函数序言的栈检查),这保留了部分协作式调度的简单性和低开销,同时又解决了纯协作式调度带来的饥饿问题。这种设计在保证高性能和低开销的同时,提供了出色的公平性和响应性,是Go语言在并发领域的一大亮点。
Go 1.14+ 基于信号的异步抢占式调度,是Go运行时持续演进的又一里程碑。它巧妙地结合了操作系统信号的异步中断能力和Go运行时自身的协作机制,在保持Go调度器轻量高效特性的同时,极大地提升了Goroutine调度的公平性和响应性。这一改进不仅解决了Go在处理CPU密集型任务时的痛点,也为Go程序在更广泛的应用场景中提供了更稳定、更可预测的性能表现,彰显了Go运行时在并发控制上的精湛技艺。