Go 的 goroutine 是一种轻量级线程,底层由调度器、栈管理和运行时系统支撑。
1. goroutine 的底层原理
1.1 轻量级线程
协作式调度为主:
goroutine是 Go 运行时系统提供的一种轻量级线程,底层由 Go 的调度器负责调度。Go 的调度器在用户级别实现了goroutine的调度,而不是依赖操作系统的线程调度,这使得goroutine可以更高效地运行。早期 Go 的调度依赖函数调用点上的主动让出(协作式调度),从 Go 1.14 开始引入基于信号的异步抢占,保证不配合的 goroutine 也能被切换。栈管理:
goroutine使用的是非常小的初始栈(通常只有几 KB),这种设计可以大幅减少内存开销。Go 运行时系统会在需要时自动扩展goroutine的栈大小,这使得goroutine可以在保持小内存占用的同时,处理复杂的递归调用或深度操作。
1.2 调度器
M:N 模型:Go 使用 M:N 调度模型,其中 M 表示操作系统线程,N 表示
goroutine。多个goroutine可以被调度到少量的操作系统线程上,这种设计减少了线程切换的开销,并且更容易在多核 CPU 上进行负载均衡。调度器组件:Go 的调度器包含以下几个主要组件:
P(Processor):代表逻辑处理器,负责执行goroutine。调度器根据逻辑处理器的数量来决定有多少个P。M(Machine):代表操作系统线程,负责执行P上的goroutine。调度器将goroutine调度到M上。G(Goroutine):代表一个goroutine。每个G都有自己的栈和调度信息。
调度算法:Go 的调度器使用抢占式调度算法。
goroutine会定期进行时间片轮转,以确保所有goroutine都能够获得执行时间。调度器会根据goroutine的状态(如等待、运行、阻塞)来决定如何调度goroutine。
2. goroutine 的实现细节
2.1 栈管理
初始栈:每个
goroutine从一个小的初始栈开始(通常为 2 KB)。这种小栈使得创建goroutine的开销很小。栈扩展:当
goroutine需要更多栈空间时,Go 的运行时系统会自动扩展栈。扩展栈是通过复制当前栈的内容到更大的内存区域来实现的。这种栈扩展的过程是自动进行的,对用户代码是透明的。
2.2 调度器内部结构
调度队列:调度器维护多个队列来管理
goroutine。主要包括:- 运行队列:存放准备执行的
goroutine。 - 等待队列:存放因等待 I/O 或其他条件而被阻塞的
goroutine。 - 系统队列:存放等待操作系统线程的
goroutine。
- 运行队列:存放准备执行的
工作窃取:调度器使用工作窃取算法来平衡负载。工作窃取允许一个忙碌的
P从其他较空闲的P中窃取goroutine,以便均衡负载并提高 CPU 使用率。
2.3 线程和协程的切换
上下文切换:
goroutine的上下文切换比操作系统线程轻量得多,只涉及用户级别的状态保存和恢复,不经过操作系统。抢占:Go 的调度器会定期检查
goroutine的状态并进行抢占,防止个别goroutine长时间占着 CPU。
3. 实现细节和示例
以下是一些 Go 的 goroutine 实现细节的代码示例和解释(为便于理解而简化的示意伪代码,并非运行时的真实源码,真实实现位于 runtime/proc.go 等文件中):
3.1 goroutine 的创建
在 Go 中,通过 go 关键字创建 goroutine,底层实际调用了 Go 运行时的相关函数:
func newGoroutine(fn func()) *g {
g := new(g)
g.fn = fn
g.stack = makeStack() // 创建初始栈
return g
}
func goStart(g *g) {
// 将 goroutine g 加入调度队列
sched.runQueue.push(g)
}3.2 栈扩展
当 goroutine 需要更多栈空间时,Go 运行时会自动扩展栈:
func growStack(g *g) {
oldStack := g.stack
newStack := allocateNewStack()
copyStack(oldStack, newStack)
g.stack = newStack
}3.3 调度器实现
调度器内部维护了多个队列来管理 goroutine 的调度:
type Scheduler struct {
runQueue queue.Queue // 运行队列
waitQueue queue.Queue // 等待队列
mQueue queue.Queue // 系统队列
}
func (s *Scheduler) schedule() {
// 从运行队列中取出一个 goroutine 并执行
g := s.runQueue.pop()
runGoroutine(g)
}总结
goroutine 便宜在两处:初始栈只有几 KB,创建成本低;调度在用户态完成,切换不陷入操作系统。M:N 模型再把大量 goroutine 压到少量系统线程上,多核也能负载均衡。