通常语义中的线程,指的是内核级线程,核心点如下:
协程又称为用户级线程核心点如下:
Goroutine,经 Golang 优化后的特殊“协程”,核心点如下:
模型 | 依赖内核 | 可并行 | 可应对阻塞 | 栈可动态扩缩 |
---|---|---|---|---|
线程 | √ | √ | √ | X |
协程 | X | X | X | X |
goroutine | X | √ | √ | √ |
goroutine更像是一个博采众长的存在。实际上,“灵活调度” 一词概括得实在过于简要,Golang 在调度 goroutine 时,针对“如何减少加锁行为”,“如何避免资源不均”等问题都给出了精彩的解决方案,这一切都得益于经典的 “gmp” 模型
gmp = goroutine + machine + processor (+ 一套有机组合的机制),下面先单独拆出每个组件进行介绍,最后再总览全局,对 gmp 进行总述
gmp 数据结构定义为 runtime/runtime2.go 文件中
type g struct {
// ...
// m:在 p 的代理,负责执行当前 g 的 m;
m *m
// ...
sched gobuf
// ...
}
type gobuf struct {
sp uintptr
pc uintptr
ret uintptr
bp uintptr // for framepointer-enabled architectures
}
const(
_Gidle = itoa // 0
_Grunnable // 1
_Grunning // 2
_Gsyscall // 3
_Gwaiting // 4
_Gdead // 6
_Gcopystack // 8
_Gpreempted // 9
)
type m struct {
g0 *g // goroutine with scheduling stack
// ...
tls [tlsSlots]uintptr // thread-local storage (for x86 extern register)
// ...
}
type p struct {
// ...
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
// ...
}
sched 是全局队列的封装
type schedt struct {
// ...
lock mutex
// ...
runq gQueue
runqsize int32
// ...
}
即 普通任务 g 和调度查找任务 g0 之间的转换
goroutine 的类型可以分为两类:
func gogo(buf *gobuf)
// ...
func mcall(fn func(*g))
通常,调度指的是由 g0 按照特定策略找到下一个可执行 g 的过程. 而本小节谈及的调度类型是广义上的“调度”,指的是调度器 p 实现从执行一个 g 切换到另一个 g 的过程.
这种广义“调度”可分为几种类型:
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
// ...
mcall(park_m)
}
通常 goready 与 gopark 成对出现,能够将 g 从阻塞状态恢复过来的,重新进入等待执行的状态
源码位于 runtime/proc.go
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
正常调度
g 中的任务执行完后,g0 会将当前 g 置于死亡状态,发起新一轮的调度
抢占调度:
如果 g 执行系统调度时间过长,超过了指定的市场,且全局的 p 资源比较紧缺,此时将 p 和 g 解绑,抢占出来用于其他 g 调度。等 g 完成系统调用后,会重新进入可执行队列中等待被调度
但是跟前三种调度方式不同的是,其余三个调度方式都是在 m 下的 g0 完成的,抢占调度则不同
因为发起系统调度时需要打破用户态的边界进入内核,此时 m 也会因系统调用而陷入僵直,无法主动完成抢占调度的行为
所以Golang进程会有一个全局监控协程 monitor g 的存在,这个 g 会越过 p 直接跟 m 进行绑定,不断轮询对所有的 p 的执行状况进行监控,倘若发现满足抢占调度的条件,则从第三方角度出手干预。主动发起抢占调度动作
调度流程的主干方法是位于 runtime/proc.go 中的 schedule 函数,此时的执行权位于 g0 手中:
func schedule() {
// ...
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
// ...
execute(gp, inheritTime)
}
调度流程中,一个非常核心的步骤,就是为 m 寻找到下一个执行的 g,这部分内容位于 runtime/proc.go 的 findRunnable 方法中:
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
_g_ := getg()
top:
_p_ := _g_.m.p.ptr()
// ...
// 判断执行查找到 61 次没有
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
// 加锁向全局队列进行查找
lock(&sched.lock)
gp = globrunqget(_p_, 1)
// 释放锁
unlock(&sched.lock)
if gp != nil {
// 返回可执行的 g
return gp, false, false
}
}
// ...
// 尝试从 p 本地队列中进行查找
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime, false
}
// ...
// 判断全局队列长度,尝试从全局队列中进行查找
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 尝试获取就绪的网络协议 --> 向 epoll 就绪队列中进行查找
if netpollinited()