循环调度
集齐各部分理论碎片之后,我们可以尝试对 GMP
的宏观调度流程进行整体串联,开始进入到调度函数runtime.schedule
, 此时的执行权位于 g0
手中:
//go 1.20.3 path: /src/runtime/proc.go
func schedule() {
// 获取当前 goroutine 的 M(操作系统线程)
mp := getg().m
// ... 省略锁以及cgo判断代码
top:
// 获取当前 M 所属的 P(处理器)
pp := mp.p.ptr()
// 设置 P 的抢占标志为 false
pp.preempt = false
//如果当前 M 在自旋状态中,并且本地运行队列中有任务,则抛出异常
if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
throw("schedule: spinning with local work")
}
// 获取一个可运行的 G,可能会阻塞直到有可运行的任务
gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
//如果M在自旋,重置自旋状态
if mp.spinning {
resetspinning()
}
//如果调度被禁用且 G 不是可运行的状态,则将 G 放入 sched.disable.runnable 中,并跳过调度
if sched.disable.user && !schedEnabled(gp) {
lock(&sched.lock)
if schedEnabled(gp) {
unlock(&sched.lock)
} else {
sched.disable.runnable.pushBack(gp)
sched.disable.n++
unlock(&sched.lock)
goto top
}
}
// 尝试唤醒其他的 P
if tryWakeP {
wakep()
}
// 如果 G 被锁住了,启动一个新的 M 来执行 G
if gp.lockedm != 0 {
startlockedm(gp)
goto top
}
// 执行可运行的 G
execute(gp, inheritTime)
}
分析代码,我们去掉一些非关键的逻辑和问题,总结起来该函数流程就两个:
- 通过调用
runtime.findRunnable
函数查找一个可用的G
; - 通过调用
runtime.execute
函数执行这个可用的G
。
下面对这两个函数进行重点分析。
查找可用G — runtime.findRunnable
调度流程中,一个非常核心的步骤,就是为 M
寻找到下一个执行的 G
,这部分内容位于函数 findRunnable
方法中,该函数非常冗长,为了寻找一个可用的G
,真是用心良苦,我们看下源码分析:
//go 1.20.3 path: /src/runtime/proc.go
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// 获取当前 M
mp := getg().m
top:
// 获取当前 P
pp := mp.p.ptr()
// 如果 GC 正在等待,停止当前 M 并重新尝试
if sched.gcwaiting.Load() {
gcstopm()
goto top
}
// 检查 SafePoint 函数是否存在,如果存在则执行
if pp.runSafePointFn != 0 {
runSafePointFn()
}
// 检查定时器,获取当前时间和 pollUntil 时间
now, pollUntil, _ := checkTimers(pp, 0)
// 如果启用了 Trace 或正在关闭 Trace,则尝试从 Trace 中读取 G
if trace.enabled || trace.shutdown {
gp := traceReader()
if gp != nil {
// 将 G 的状态从等待状态改为可运行状态
casgstatus(gp, _Gwaiting, _Grunnable)
// 触发 Trace 的 GoUnpark 事件
traceGoUnpark(gp, 0)
return gp, false, true
}
}
// 如果启用了 GC,并且找到可运行的 GC Worker,则返回该 Worker
if gcBlackenEnabled != 0 {
gp, tnow := gcController.findRunnableGCWorker(pp, now)
if gp != nil {
return gp, false, true
}
now = tnow
}
// 每隔 61 个调度时钟周期,尝试从全局运行队列中获取一个 G
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 如果有 Futex 唤醒,则尝试唤醒等待的 G
if fingStatus.Load()&(fingWait|fingWake) == fingWait|fingWake {
if gp := wakefing(); gp != nil {
ready(gp, 0, true)
}
}
// 如果存在 cgo_yield 函数,则执行
if *cgo_yield != nil {
asmcgocall(*cgo_yield, nil)
}
// 尝试从本地运行队列中获取一个可运行的 G
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// 如果全局运行队列非空,则尝试从全局运行队列中获取一个 G
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零
if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {
// 尝试非阻塞获取网络事件列表
if list := netpoll(0); !list.empty() {
// 从列表中弹出一个 G,并将 G 插入 G 列表
gp := list.pop()
injectglist(&list)
// 将 G 的状态从等待状态改为可运行状态
casgstatus(gp, _Gwaiting, _Grunnable)
// 如果启用了 Trace,则触发 Trace 的 GoUnpark 事件
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
}
// 如果 M 处于自旋状态,或者全局运行队列中的任务数量小于等于 M 的空闲数量的两倍
if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
// 如果 M 不在自旋状态,则将其切换为自旋状态
if !mp.spinning {
mp.becomeSpinning()
}
// 尝试从其他 P 中偷取任务
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil {
return gp, inheritTime, false
}
// 如果有新的任务,则重新尝试
if newWork {
goto top
}
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
}
// 如果启用了 GC,并且 GC Mark 阶段有任务可用,并且可以添加空闲的 GC Mark Worker,则添加一个 Worker
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) && gcController.addIdleMarkWorker() {
// 从 GC Mark Worker 池中取出一个节点
node := (*gcBgMarkWorkerNode)(gcBgMarkWorkerPool.pop())
if node != nil {
// 设置当前 P 的 GC Mark Worker 模式为空闲
pp.gcMarkWorkerMode = gcMarkWorkerIdleMode
// 从节点中获取 G,并将其状态从等待状态改为可运行状态
gp := node.gp.ptr()
casgstatus(gp, _Gwaiting, _Grunnable)
// 如果启用了 Trace,则触发 Trace 的 GoUnpark 事件
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
// 如果没有可用的节点,则移除空闲的 GC Mark Worker
gcController.removeIdleMarkWorker()
}
// 在空闲时执行一些操作,如果获取到一个可运行的 G,则返回
gp, otherReady := beforeIdle(now, pollUntil)
if gp != nil {
// 将 G 的状态从等待状态改为可运行状态
casgstatus(gp, _Gwaiting, _Grunnable)
// 如果启用了 Trace,则触发 Trace 的 GoUnpark 事件
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
// 如果有其他的 G 准备好了,则重新尝试
if otherReady {
goto top
}
// 在没有找到可运行的 G 时,查看全局运行队列中是否有任务
allpSnapshot := allp
idlepMaskSnapshot := idlepMask
timerpMaskSnapshot := timerpMask
lock(&sched.lock)
if sched.gcwaiting.Load() || pp.runSafePointFn != 0 {
unlock(&sched.lock)
goto top
}
if sched.runqsize != 0 {
gp := globrunqget(pp, 0)
unlock(&sched.lock)
return gp, false, false
}
if !mp.spinning && sched.needspinning.Load() == 1 {
// 如果 M 不在自旋状态,并且需要自旋,则切换为自旋状态
mp.becomeSpinning()
unlock(&sched.lock)
goto top
}
if releasep() != pp {
throw("findrunnable: wrong p")
}
now = pidleput(pp, now)
unlock(&sched.lock)
// 如果 M 处于自旋状态,则尝试从全局运行队列中获取一个 G
wasSpinning := mp.spinning
if mp.spinning {
mp.spinning = false
if sched.nmspinning.Add(-1) < 0 {
throw("findrunnable: negative nmspinning")
}
pp := checkRunqsNoP(allpSnapshot, idlepMaskSnapshot)
if pp != nil {
acquirep(pp)
mp.becomeSpinning()
goto top
}
// 再次检查是否有空闲优先级的 GC 任务
pp, gp := checkIdleGCNoP()
if pp != nil {
acquirep(pp)
mp.becomeSpinning()
// 运行空闲优先级的 GC 任务
pp.gcMarkWorkerMode = gcMarkWorkerIdleMode
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
// 检查定时器,获取当前时间
pollUntil = checkTimersNoP(allpSnapshot, timerpMaskSnapshot, pollUntil)
}
// 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零
if netpollinited() && (netpollWaiters.Load() > 0 || pollUntil != 0) && sched.lastpoll.Swap(0) != 0 {
if list := netpoll(0); !list.empty() { // 非阻塞方式获取网络事件
gp := list.pop()
injectglist(&list)
casgstatus(gp, _Gwaiting, _Grunnable)
if trace.enabled {
traceGoUnpark(gp, 0)
}
return gp, false, false
}
}
// 如果网络轮询等待时间不为零,并且网络轮询已初始化,则触发网络轮询中断
if pollUntil != 0 && netpollinited() {
pollerPollUntil := sched.pollUntil.Load()
if pollerPollUntil == 0 || pollerPollUntil > pollUntil {
netpollBreak()
}
}
// 停止当前 M,然后重新尝试
stopm()
goto top
}
综合代码,来总结下流程:
- 获取当前
M
; - 获取当前
P
,检查是否有等待的GC
(垃圾回收),如果有,则执行gcstopm
,并返回步骤1重新执行; - 如果存在运行时注册的安全点函数
runSafePointFn
,则执行它; - 检查计时器,更新
now
,并获取下一个计时器的触发时间pollUntil
; - 如果启用了
Trace
或正在关闭Trace
,则尝试从Trace
中读取G
; - 如果启用了
GC
,并且找到可运行的GC Worker
,则返回该Worker
; - 每隔
61
个调度时钟周期(pp.schedtick%61 == 0
)且全局运行队列不为空(sched.runqsize > 0
),尝试调用globrunqget
函数 从全局运行队列中获取一个G
,如果获取到则返回; - 如果有
Futex
唤醒,则尝试唤醒等待的G
, 如果存在cgo_yield
函数,则执行; - 尝试调用函数
runqget
从本地运行队列中获取一个可运行的G
,如果获取到则返回; - 如果全局运行队列(
sched.runq
)非空,则再次尝试从全局运行队列中获取一个G
(不死心啊),如果获取到则返回; - 如果网络轮询已初始化,并且有等待的网络事件,并且上次轮询的时间不为零,尝试从网络轮询中获取可运行的
goroutine
,如果获取到则返回; - 如果
M
处于自旋状态,或者全局运行队列中的任务数量小于等于M
的空闲数量的两倍,则调用stealWork
函数尝试从其他P
中偷取任务,如果获取到则返回; - 如果启用了
GC
,并且GC Mark
阶段有任务可用,并且可以添加空闲的GC Mark Worker
,则添加一个Worker
并尝试从GC Mark Worker
池中取出一个节点,如果获取成功则返回; - 在空闲时执行一些操作,如果获取到一个可运行的
G
,则返回; - 如果没有找到可运行的
G
,查看全局运行队列中是否有任务,然后根据各种情况,瞅准机会不断地去全局运行队列里面看看能否获取到可运行的G
,最后如果实在取不到G
,则调用stopm()
,然后返回第2
步继续重试;
总结起来就是各种办法找G
,到最后还不死心,在能找的地方再找一次。
其流程图如下:
下面来根据 runtime.findRunnable
函数,来看看调度函数的调度策略。
调度策略
1. 从全局运行队列中获取G — runtime.globrunqget
在runtime.findRunnable
函数中,有多处是调用runtime.globrunqget
函数从全局运动队列中获取G
的地方:
//go 1.20.3 path: /src/runtime/proc.go
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// ......
// 每隔 61 个调度时钟周期,尝试从全局运行队列中获取一个 G
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// ......
// 如果全局运行队列非空,则尝试从全局运行队列中获取一个 G
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// ......
if sched.runqsize != 0 {
gp := globrunqget(pp, 0)
unlock(&sched.lock)
return gp, false, false
}
// ......
}
我们来看看runtime.globrunqget
函数到底做了什么,它是什么处理逻辑:
//go 1.20.3 path: /src/runtime/proc.go
func globrunqget(pp *p, max int32) *g {
// 确保调度器锁被持有
assertLockHeld(&sched.lock)
// 如果全局运行队列为空,则返回 nil
if sched.runqsize == 0 {
return nil
}
// 计算从全局运行队列中获取的 g 的数量
// 为了保证公平性,每个p都有机会从全局队列中拿到一部分g,数量为 n := sched.runqsize/gomaxprocs + 1
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
// 如果有指定的最大数量,并且 n 超过了最大数量,则将 n 限制为最大数量
if max > 0 && n > max {
n = max
}
// 如果 n 超过了 pp 的本地运行队列的一半,则将 n 限制为本地运行队列的一半
if n > int32(len(pp.runq))/2 {
n = int32(len(pp.runq)) / 2
}
// 更新全局运行队列的大小
sched.runqsize -= n
// 从全局运行队列中取出一个 g
gp := sched.runq.pop()
n--
// 从全局运行队列中取出剩余的 g,并放入 pp 的本地运行队列
for ; n > 0; n-- {
gp1 := sched.runq.pop()
runqput(pp, gp1, false)
}
// 返回取出的 g
return gp
}
该函数的主要流程逻辑为:
- 确保调度器锁 (
sched.lock
) 被持有,检查全局运行队列 (sched.runq
) 是否为空,如果为空,直接返回nil
; - 计算从全局运行队列中获取的
goroutine
(g
)的数量n
,n
的计算方式是将全局运行队列的大小 (sched.runqsize
) 除以最大处理器数量 (gomaxprocs
) 加1
,确保至少获取一个g
; - 如果指定了最大数量 (
max > 0
),则将n
限制为最大数量;如果n
超过了全局运行队列的大小,则将n
限制为全局运行队列的大小; - 从全局运行队列中取出一个
g
(gp
),并将全局运行队列大小减去n
,使用循环从全局运行队列中取出剩余的g
,并将它们放入调用者的本地运行队列 (pp.runq
); - 返回取出的第一个
g
(gp
),供调用者使用。
其中将全局运行队列中的G
存放进调用者本地运行队列中,使用了函数runtime.runqput
,该函数在 GO调度模型-GMP(上)已经说明过,此处不再重复。
2. 从P的本地队列中获取G — runtime.runqget
在runtime.findRunnable
函数中,调用runtime.runqget
函数尝试从 P
本地队列中获取一个可执行的 goroutine
,其出处:
//go 1.20.3 path: /src/runtime/proc.go
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// ......
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// ......
}
runtime.runqget
函数核心逻辑:
//go 1.20.3 path: /src/runtime/proc.go
// 从本地运行队列获取一个 goroutine
func runqget(pp *p) (gp *g, inheritTime bool) {
// 尝试从本地运行队列的 runnext 字段获取一个 goroutine
next := pp.runnext
if next != 0 && pp.runnext.cas(next, 0) {
return next.ptr(), true
}
// 如果 runnext 为空,或者 cas 失败,尝试从 runq 数组中获取一个 goroutine
for {
// 获取本地运行队列头和尾
h := atomic.LoadAcq(&pp.runqhead)
t := pp.runqtail
// 如果队列为空,返回 nil 和 false
if t == h {
return nil, false
}
// 从 runq 数组中获取一个 goroutine
gp := pp.runq[h%uint32(len(pp.runq))].ptr()
// 尝试更新本地运行队列头,如果成功则返回 goroutine 和 false
if atomic.CasRel(&pp.runqhead, h, h+1) {
return gp, false
}
}
}
来分析下流程:
- 尝试从本地运行队列的
pp.runnext
字段获取一个goroutine
,如果pp.runnext
不为空且cas
操作成功,表示有下一个可运行的goroutine
,直接返回; - 如果
pp.runnext
为空或cas
失败,尝试从pp.runq
数组中获取一个goroutine
, 通过取余操作计算索引,从数组中获取goroutine
,获取成功后尝试更新本地运行队列头。
该函数主要用于从本地运行队列获取可运行的 goroutine
,尽量保持先进先出的顺序。如果本地运行队列为空,则返回 nil
和 false
。函数的返回值中,inheritTime
表示是否继承时间片。
3. 从其他P的本地队列中偷取G — runtime.stealWork
当本线程无可运行的G
时,尝试从其他线程绑定的P
偷取G
,而不是销毁线程。这种机制称为work stealing 机制。
如果 M
处于自旋状态,或者全局运行队列中的任务数量小于等于 M
的空闲数量的两倍, 这时候就会触发work stealing 机制,采取steal
模式:
//go 1.20.3 path: /src/runtime/proc.go
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// ......
if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
// 如果 M 不在自旋状态,则将其切换为自旋状态
if !mp.spinning {
mp.becomeSpinning()
}
// 尝试从其他 P 中偷取任务
gp, inheritTime, tnow, w, newWork := stealWork(now)
if gp != nil {
return gp, inheritTime, false
}
// ......
}
// ......
}
首先了解什么是自旋?为什么会自旋?
在 Go
的运行时系统中,M
(内核线程)在一些情况下会采用自旋(spinning
)的方式等待可运行的 G
(goroutine
)。
自旋是指在等待某个条件满足的时候,持续地执行一些快速的空循环,而不是让线程进入休眠状态。自旋的目的是避免线程在等待的过程中进入内核态,减少上下文切换的开销。
在 Go
的运行时系统中,M
可能会自旋的情况包括:
- 自旋等待运行队列中有可运行的 G: 当一个
M
在运行队列中没有可运行的G
时,它可能会自旋一段时间,避免直接进入休眠状态。这是为了避免频繁地在用户态和内核态之间切换的开销。如果一段时间内仍然没有可运行的G
,M
就会放弃自旋,进入休眠状态。 - 自旋等待全局运行队列中有可运行的 G: 如果一个
M
在本地运行队列和全局运行队列都没有可运行的G
,它可能会在全局运行队列上自旋一段时间。这是为了避免进入休眠状态后,又需要被唤醒执行的开销。 - 自旋等待 GC 完成: 在进行垃圾回收(
GC
)的过程中,M
有时会自旋等待GC
完成,以减少GC
的停顿时间。
了解完这些,我们就大致明白为什么自旋下会去去其他P
偷G
了。
再来看看 runtime.stealWork
源码:
//go 1.20.3 path: /src/runtime/proc.go
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()
ranTimer := false
const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1
// 遍历 P 的顺序是随机的,通过 fastrand()函数获取一个伪随机数种子
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
// 如果是最后一次迭代,或者当前 P 所在的位置上有定时器,就检查定时器
if stealTimersOrRunNextG && timerpMask.read(enum.position()) {
tnow, w, ran := checkTimers(p2, now)
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
if ran {
// 尝试从当前 P 的运行队列中获取可运行的 G
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, now, pollUntil, ranTimer
}
ranTimer = true
}
}
// 如果当前enum.position位置上没有空闲 P,尝试从其运行队列偷取 G
if !idlepMask.read(enum.position()) {
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, false, now, pollUntil, ranTimer
}
}
}
}
// 没有找到可运行的 G,返回 nil
return nil, false, now, pollUntil, ranTimer
}
来总结下流程:
- 获取当前
G
所在的P
,设置ranTimer
为false
,表示当前是否已经运行过定时器; - 指定了尝试偷取的次数
stealTries
为4
,每次循环尝试从其他P
中偷取G
,并设置stealTimersOrRunNextG
用来标志着是否是最后一次迭代,循环主要步骤如下:- 遍历其他
P
,通过fastrand
随机数来随机获取P
,如果当前状态处于GC
等待状态,直接返回,如果当前P
等于随机抽取到的P
,则跳过本次循环; - 检查是否是最后一次迭代且当前
P
位置上有定时,如果符合这些条件则调用checkTimers
函数检查其他P
的定时器,并获取当前时间tnow
、最早的定时器时间w
以及是否已经运行定时器ran
,然后做如下操作:- 更新当前时间为
tnow
,如果最早的定时器时间w
不为零(表示有定时器)并且(pollUntil
为零或者w
小于pollUntil
),则更新pollUntil
为w
,这是为了确保pollUntil
记录的是最早的定时器时间; - 如果定时器已经运行过(
if ran
),说明在checkTimers
中发现了到期的定时器,这种情况下,尝试从当前P
的运行队列中获取可运行的G
,如果成功从运行队列中获取到G
则直接返回; - 执行
ranTimer = true
,标记定时器已经运行。
- 更新当前时间为
- 如果 所在的位置
enum.position
上没有空闲P
,尝试调用runtime.runqsteal
从其运行队列偷取G
。
- 遍历其他
- 如果找到可运行的
G
,返回G
、inheritTime
、当前时间now
、pollUntil
时间(用于下次轮询)和是否运行过定时器ranTimer
;如果没找到可运行的G
,返回nil
。
在 stealWork
函数中调用 checkTimers
检查定时器的目的是在尝试从其他 P
中偷取可运行的 G
时,顺便检查是否有到期的定时器。这是因为在某些情况下,如果有到期的定时器,可能会有可运行的 G
与之关联,从而提供额外的工作。总体来说,目的是充分利用系统资源,通过检查定时器,可能获取到额外的可运行 G
,从而提高系统的并发性。
再来看看从其他P
中偷取G
的主逻辑函数runtime.runqsteal
:
//go 1.20.3 path: /src/runtime/proc.go
// runqsteal 尝试从另一个 P 的运行队列中偷取 G。
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
// 获取本地运行队列pp的尾部
t := pp.runqtail
// 从 p2 偷取 G,runqgrab 返回偷取的 G 的数量
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
// 如果没有偷取到 G,返回 nil
if n == 0 {
return nil
}
//更新 pp 的运行队列尾部
n--
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
// 如果只偷取到一个 G,则直接返回
if n == 0 {
return gp
}
// 如果偷取到多个 G,则更新运行队列尾部
h := atomic.LoadAcq(&pp.runqhead)
// 检查是否会导致运行队列溢出
if t-h+n >= uint32(len(pp.runq)) {
throw("runqsteal: runq overflow")
}
// 更新运行队列尾部
atomic.StoreRel(&pp.runqtail, t+n)
// 返回偷取到的 G
return gp
}
这个函数用于从另一个 P
的运行队列中偷取 G
。关键步骤包括:
- 获取
pp
的运行队列尾部,即t
; - 调用
runtime.runqgrab
函数,从p2
的运行队列中偷取G
,并返回偷取的数量n
; - 如果没有偷取到
G
,直接返回nil
;如果只偷取到一个G
,直接返回该G
; - 如果偷取到多个
G
,更新pp
的运行队列尾部,然后检查是否会导致运行队列溢出,如果是,抛出异常; - 更新运行队列尾部,并返回偷取到的
G
。
其中 runtime.runqgrab
函数是关键steal
逻辑,代码如下:
//go 1.20.3 path: /src/runtime/proc.go
// runqgrab 尝试从 P 的运行队列中批量偷取 G。
func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
for {
// 获取运行队列头部和尾部
h := atomic.LoadAcq(&pp.runqhead)
t := atomic.LoadAcq(&pp.runqtail)
// 计算可偷取的数量,取一半
n := t - h
n = n - n/2
// 如果可偷取数量为 0
if n == 0 {
// 如果是偷取下一个 G,并且下一个 G 不为空
if stealRunNextG {
if next := pp.runnext; next != 0 {
// 如果 P 的状态是 _Prunning
if pp.status == _Prunning {
// 根据不同的操作系统进行不同的暂停操作
if GOOS != "windows" && GOOS != "openbsd" && GOOS != "netbsd" {
usleep(3)
} else {
osyield()
}
}
// 尝试将下一个 G 从运行队列头部移除
if !pp.runnext.cas(next, 0) {
continue
}
// 将偷取到的下一个 G 放入批量数组中
batch[batchHead%uint32(len(batch))] = next
// 返回偷取数量为 1
return 1
}
}
// 如果没有偷取到 G,则返回 0
return 0
}
// 如果可偷取数量大于运行队列长度的一半,则继续尝试
if n > uint32(len(pp.runq)/2) {
continue
}
// 将运行队列中的 G 复制到批量数组中
for i := uint32(0); i < n; i++ {
g := pp.runq[(h+i)%uint32(len(pp.runq))]
batch[(batchHead+i)%uint32(len(batch))] = g
}
// 尝试更新运行队列头部
if atomic.CasRel(&pp.runqhead, h, h+n) {
// 返回偷取数量
return n
}
}
}
这个函数用于从 P
的运行队列中批量偷取 G
。关键步骤包括:
- 获取运行队列头部和尾部, 计算可偷取的数量,取一半;
- 如果可偷取数量为
0
,则进一步判断stealRunNextG
是否为true
(即本次偷取是否为循环最后一次),如果是,则偷取pp.runnext
的G
,将其放入批量数组batch
中并返回1
,否则直接返回0
; - 如果可偷取数量
n
大于运行队列长度的一半(len(pp.runq)/2
),则跳过本次执行,回到for
循环继续尝试; - 走到这一步,则将运行队列中的需要偷取数量
n
的G
复制到批量数组batch
中后,更新运行队列头部(pp.runqhead
),返回偷取数量。
除了上述三种调度策略,还有从GC Work
中获取G
,从NetPoller
中获取G
等,再次不再详细说明,后续章节相关内容会提及。
执行可用G — runtime.execute
分析完了怎么选取运行g
的问题,现在在回到schedule
调度函数,分析它是如何执行要运行g
的,也就是对应到schedule
中的execute
处理逻辑。
//go 1.20.3 path: /src/runtime/proc.go
func execute(gp *g, inheritTime bool) {
// 获取当前G的M
mp := getg().m
// 检查是否启用了 Goroutine Profile。
if goroutineProfile.active {
// 尝试记录 Goroutine 的 Profile。
tryRecordGoroutineProfile(gp, osyield)
}
// 将当前 G 设置为 M 的当前 G。
mp.curg = gp
// 将当前 M 设置为 G 的 M。
gp.m = mp
// 将 G 的状态从 Runnable 切换到 Running。
casgstatus(gp, _Grunnable, _Grunning)
// 将 G 的 waitsince字段置零,表示该 G 不再处于等待状态。
gp.waitsince = 0
// 将 G 的 preempt 字段置为 false,表示不允许抢占。
gp.preempt = false
// 设置 G 的栈警戒区域的起始地址。
gp.stackguard0 = gp.stack.lo + _StackGuard
// 如果不继承时间片(不是被抢占的状态),则增加所属 P 的 schedtick 字段,表示该 P 的调度轮次增加。
if !inheritTime {
mp.p.ptr().schedtick++
}
// 从调度器中获取 Profile 的频率。
hz := sched.profilehz
// 如果当前 M 的 Profile 频率不等于全局频率,则设置线程的 CPU Profiler。
if mp.profilehz != hz {
setThreadCPUProfiler(hz)
}
// 如果 Trace 已经启用,则执行相应的 Trace 操作。
if trace.enabled {
// 如果 G 处于系统调用中且被跟踪,记录系统调用退出事件。
if gp.syscallsp != 0 && gp.sysblocktraced {
traceGoSysExit(gp.sysexitticks)
}
// 调用 traceGoStart() 记录 Goroutine 的开始事件。
traceGoStart()
}
// 调用 gogo 函数,启动 G 的执行。
gogo(&gp.sched)
}
runtime.execute
切换到选择的要运行的g
的栈执行,设置g
为运行状态和是否被抢占等信息之后,调用gogo
函数从当前g0
栈切换到要运行g
的栈,真正开始执行用户程序。
gogo
函数是用汇编语言实现的,具体源码解析如下,切换原理就是将要运行g
的调度信息g.sched
从内存中恢复到CPU
寄存器,设置SP
和IP
等寄存器的值,跳转到要运行的位置开始执行指令。
在完成gp
运行前的准备工作后,excute
函数调用gogo
函数完成从g0
到gp
的转换:
- 让出
CPU
的执行权; - 栈的切换;
gogo
函数汇编代码如下:
TEXT runtime·gogo(SB), NOSPLIT, $16-8
MOVQ buf+0(FP), BX // 0(FP)表示第一个参数,即buf=&gp.sched , BX = buf
MOVQ gobuf_g(BX), DX // DX=buf.g=&gp.sched.g
MOVQ 0(DX), CX // make sure g != nil
get_tls(CX)
MOVQ DX, g(CX) // 把g放入到tls[0],即把要运行的g的指针放进线程本地存储,后面的代码可以通过本地线程存储,获取到当前正在执行的goroutine的地址,在这之前,本地线程存储中存放的是g0的地址
MOVQ gobuf_sp(BX), SP //SP=buf.sp=&gp.sched.sp,即把CPU的栈顶寄存器SP设置为gp.sched.sp,成功完成从g0栈切换到gp栈
// 恢复调度上下文到CPU对应的寄存器
// 将系统调用的返回值放入到AX寄存器中
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
// 前面已经将调度相关的值都放入到CPU的寄存器中了,将gp.sched中的值清空,这样可以减轻gc的工作量
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
// 把gp.sched.pc的值放入到BX寄存器,如果是对于main goroutine,sched.pc中存储的是runtime包中main()函数的地址
MOVQ gobuf_pc(BX), BX
// JMP把BX寄存器中的地址值放入到CPU的IP寄存器中,然后CPU跳转到该地址的位置开始执行指令
JMP BX
gogo
函数主要做了两件事情:
- 把
gp.sched
的成员恢复到CPU
的寄存器完成状态以及栈的切换; - 跳转到
gp.sched.pc
所指的指令地址(初始化情况下,该地址为函数runtime.main
地址入口)处执行。
其实gogo
函数调用的G
分两种情况:
- 一种是初始化的时候,我们创建了第一个
G
,即main goroutine
,我们完成了G
的创建,在程序初始化的情况下,我们第一个调度完成的G
便是它; - 另外就是普通的
G
,即我们自己写程序创建的G
。
在此,我们也顺便了解下main goroutine
,看看这个G
干了些什么。
runtime.main
直接看源码:
//go 1.20.3 path: /src/runtime/proc.go
func main() {
// 获取当前 G 的 M(线程本地存储的运行时线程)。
mp := getg().m
// 将 G0 的 race 上下文置零。
mp.g0.racectx = 0
// 设置最大栈大小。
if goarch.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
// 设置栈大小的上限。
maxstackceiling = 2 * maxstacksize
// 标记主函数已经启动。
mainStarted = true
// 如果 GOARCH 不是 wasm,则创建系统监控线程。
if GOARCH != "wasm" {
// 使用系统栈运行 `sysmon` 函数创建一个新的 M。
systemstack(func() {
newm(sysmon, nil, -1)
})
}
// 锁定当前 OS 线程。
lockOSThread()
// 检查当前 M 是否为 M0。
if mp != &m0 {
throw("runtime.main not on m0")
}
// 获取当前时间作为运行时初始化的时间。
runtimeInitTime = nanotime()
// 检查获取时间是否为零。
if runtimeInitTime == 0 {
throw("nanotime returning zero")
}
// 如果开启了 inittrace,则初始化 inittrace。
if debug.inittrace != 0 {
inittrace.id = getg().goid
inittrace.active = true
}
// 执行 runtime 初始化任务。
doInit(&runtime_inittask)
// 设置需要解锁当前线程。
needUnlock := true
defer func() {
// 在函数退出时解锁当前线程。
if needUnlock {
unlockOSThread()
}
}()
// 启用垃圾回收。
gcenable()
// 创建 main 初始化完成的通道。
main_init_done = make(chan bool)
// 如果是 Cgo,启动 Cgo 线程并通知运行时初始化完成。
if iscgo {
// 检查必要的 Cgo 函数是否存在。
if _cgo_thread_start == nil {
throw("_cgo_thread_start missing")
}
if GOOS != "windows" {
if _cgo_setenv == nil {
throw("_cgo_setenv missing")
}
if _cgo_unsetenv == nil {
throw("_cgo_unsetenv missing")
}
}
if _cgo_notify_runtime_init_done == nil {
throw("_cgo_notify_runtime_init_done missing")
}
// 启动模板线程。
startTemplateThread()
// 调用 `_cgo_notify_runtime_init_done` 通知运行时初始化完成。
cgocall(_cgo_notify_runtime_init_done, nil)
}
// 执行 main 初始化任务。
doInit(&main_inittask)
// 关闭 init 完成的通道。
inittrace.active = false
close(main_init_done)
// 不再需要解锁当前线程。
needUnlock = false
unlockOSThread()
// 如果是归档文件或库,则直接返回。
if isarchive || islibrary {
return
}
// 获取 main 函数并执行。
fn := main_main
fn()
// 如果启用了 race,则执行退出钩子。
if raceenabled {
runExitHooks(0)
racefini()
}
// 检查是否有正在运行的 panic defers。
if runningPanicDefers.Load() != 0 {
for c := 0; c < 1000; c++ {
if runningPanicDefers.Load() == 0 {
break
}
Gosched()
}
}
// 如果有 panic,则将 G 挂起,等待处理。
if panicking.Load() != 0 {
gopark(nil, nil, waitReasonPanicWait, traceEvGoStop, 1)
}
// 执行退出钩子。
runExitHooks(0)
// 退出程序。
exit(0)
// 无限循环,触发空指针异常。
for {
var x *int32
*x = 0
}
}
runtime.main
函数的主要工作是:
- 启动一个
sysmon
系统监控线程,该线程负责程序的gc
、抢占调度等; - 执行
runtime
包和所有包的初始化; - 执行
main.main
函数; - 最后调用
exit
系统调用退出进程,之前提到的注入goexit
程序对main goroutine
不起作用,是为了其他线程的回收而做的。
runtime.goexit
而对于非main goroutine
来说,执行完用户逻辑之后会回到goexit
函数,因为在创建非main goroutine
的时候伪造成G
是被goexit
函数调用的,下面开始分析回到goexit
函数做了哪些处理:
// 在 Goroutine 返回 goexit + PCQuantum 时运行的最顶层函数。
TEXT runtime·goexit(SB),NOSPLIT,$0-0
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // 不会返回
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP
看上去是被goexit
调用,后续,其会继续调用goexit1
和goexit0
函数:
func goexit1() {
if raceenabled {
racegoend()
}
if trace.enabled {
traceGoEnd()
}
mcall(goexit0)
}
runtime.goexit1
函数处理一些data race
等检查逻辑之后,调用了mcall
函数,mcall
是汇编实现的,mcall
函数的功能是从当前用户程序g
切换到g0
上运行,然后在g0
栈上执行goexit0
函数。
TEXT runtime·mcall(SB), NOSPLIT, $0-8
// 取出参数的值也就是goexit0的地址,放入到DI寄存器中
MOVQ fn+0(FP), DI
// 获取当前的g,这里的g还是用户程序的g
get_tls(CX)
//设置AX寄存器的值为g
MOVQ g(CX), AX // save state in g->sched
//mcall返回地址放入到BX寄存器中
MOVQ 0(SP), BX // caller's PC
// 将当前的调度信息也就是CPU寄存器的值保存到g的sched字段中,因为接下来将发送goroutine的切换,从用户程序g切换到g0
// 保存PC
MOVQ BX, (g_sched+gobuf_pc)(AX)
LEAQ fn+0(FP), BX // caller's SP
// 保存SP
MOVQ BX, (g_sched+gobuf_sp)(AX)
// 保存g
MOVQ AX, (g_sched+gobuf_g)(AX)
// 保存BP
MOVQ BP, (g_sched+gobuf_bp)(AX)
// 将g保存到BX寄存器中
MOVQ g(CX), BX
// 将g.m保存到BX寄存器中
MOVQ g_m(BX), BX
// 将g.m.g0保存到寄存器SI中
MOVQ m_g0(BX), SI
// 经过上面3步操作,成功拿到了g0的地址,非常完美
// 比较g0是否与g相等,理论上不应该相等,如果出现相等,一定是有问题
CMPQ SI, AX // if g == m->g0 call badmcall
JNE 3(PC)
MOVQ $runtime·badmcall(SB), AX
JMP AX
// SI中保存的是g0的地址,经过这步操作将g0的地址放到了线程本地存储中
MOVQ SI, g(CX) // g = m->g0
// 恢复g0的栈顶指针到CPU的SP寄存器中,成功将g0栈放入到CPU上,完成了从用户程序g栈到g0的栈切换
MOVQ (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.sp
// 将goexit0的参数g入栈
PUSHQ AX
// 将funcval对象的指针保存到DX
MOVQ DI, DX
// 获取funcval对象的第一个成员也就是goexit0的地址
MOVQ 0(DI), DI
//调用goexit0(g)函数
CALL DI
POPQ AX
MOVQ $runtime·badmcall2(SB), AX
JMP AX
RET
概括起来,mcall
完成两个主要逻辑:
- 保存当前的
g
的调度信息到内存中,通过当前的g
,找到与它绑定的m
,在通过m
找到m
中保存的g0
,然后将g0
保存到tls
中,修改CPU
寄存器的值为g0
栈的内容; - 切换到
g0
栈,执行goexit0
函数。
可以看到mcall
完成的功能与前面介绍的gogo
函数功能完全相反。
gogo
函数实现从g0
栈到用户程序g
的切换,而这里的mcall
恰好实现从用户程序g
到g0
的切换,所以通过gogo
和mcall
函数,我们可以在runtime
代码和用户程序代码之间来回切换。
再来看看 goexit0
函数,goexit0
函数把gp
状态从_Grunning
修改为_Gdead
,然后清理gp
对象中保存内容,其次通过函数dropg
解除gp
和m
之间的绑定关系,然后将gp
放入到P
的freeg
队列中缓存起来,以便后续复用,最后调用schedule
,进行新一轮调度。
goexit0
函数源码如下:
//go 1.20.3 path: /src/runtime/proc.go
func goexit0(gp *g) {
mp := getg().m
pp := mp.p.ptr()
// 将 G 的状态从 _Grunning 设置为 _Gdead
casgstatus(gp, _Grunning, _Gdead)
// 将 G 的栈标记为可扫描,并更新 gcController 相关的统计信息
gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
// 如果是系统 goroutine,则减少系统 goroutine 计数
if isSystemGoroutine(gp, false) {
sched.ngsys.Add(-1)
}
// 将 G 的 M 字段置为空,表示不再关联 M
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
mp.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil
gp._panic = nil
gp.writebuf = nil
gp.waitreason = waitReasonZero
gp.param = nil
gp.labels = nil
gp.timer = nil
// 如果启用了 GC 并且 G 协助的字节数大于 0
if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {
// 计算协助的工作单元,并添加到后台 GC 扫描的工作量中
assistWorkPerByte := gcController.assistWorkPerByte.Load()
scanCredit := int64(assistWorkPerByte * float64(gp.gcAssistBytes))
gcController.bgScanCredit.Add(scanCredit)
gp.gcAssistBytes = 0
}
// 调用 dropg 函数,将 G 从运行队列中移除
dropg()
// 如果 GOARCH 是 "wasm"
if GOARCH == "wasm" {
// 将 G 放回 P 的全局队列,并调用 schedule 函数
gfput(pp, gp)
schedule()
}
// 如果 M 处于锁定状态
if mp.lockedInt != 0 {
print("invalid m->lockedInt = ", mp.lockedInt, "\n")
throw("internal lockOSThread error")
}
// 将 G 放回 P 的全局队列
gfput(pp, gp)
// 如果处于锁定状态
if locked {
// 根据不同的操作系统进行相应的处理
if GOOS != "plan9" {
// 在 Plan 9 操作系统下,将 M 的 lockedExt 置零
gogo(&mp.g0.sched)
} else {
mp.lockedExt = 0
}
}
// 调用 schedule 函数
schedule()
}
该函数总起来有以下流程:
- 把
G
的状态由运行中(_Grunning
)改为已中止(_Gdead
); - 清空
G
的成员; - 调用
dropg
函数解除M
和G
之间的关联; - 调用
gfput
函数把G
放到P
的自由列表中, 下次创建G
时可以复用; - 调用
schedule
函数继续调度。
到这里,一个简单的 Goroutine
的生命周期就结束了,但是 M
物理线程还不会结束,它会再次执行 schedule
进入下一个调度循环。
调度流程
任何goroutine
被调度起来都是通过schedule()->execute()->gogo()
从g0
调度到用户goroutine
,然后当协程结束后(非main goroutine
),又会通过goexit()->goexit1()->mcall()->goexit0()->schedule()
又回到了schedule()
函数,基本上就是如下的调度循环:
如上图,main Goroutine
与普通Goroutine
在调度上还是存在一定区别的,main Goroutine
执行完后就直接调用exit
退出了循环,而普通的Goroutine
则继续进行着循环调度。
还有一个问题,以上循环中从mcall
调用goexit0
函数开始,到gogo
函数结束,都是在g0
栈上执行的,如此循环,不管系统栈有多大,终究会耗尽g0
的系统栈啊,那Go
中是如何避免这件事的呢?
秘密在于每次通过mcall
函数切换到g0
栈的时候都是切换到g0.sched.sp
所在的固定位置,而这之所以行得通也得益于从schedule
开始的一些列函数都不会返回,所以重用这些函数上一轮调度时使用的栈内存是没有问题的。
总结一下每一个工作线程的执行流程和调度循环都如下图所示:
调度时机
调度策略让我们知道了协程是如何调度的,下面继续说明什么时候会发生协程调度。根据调度方式的不同,调度时机分为3种,分别是 主动调度、被动调度 和 抢占调度。
主动调度
主动调度的逻辑还是很好理解的,就是当前的g
放弃CPU
执行权,将其放入到全局运行队列中,因为它还是可以继续运行的,只是我们主动放弃了,等待下次被调度程序执行。在用户代码中通过调用runtime.Gosched()
函数发生主动调度。
主动调用的触发路径为: runtime.Gosched
-> runtime.gosched_m
-> runtime.goschedImpl
。
相关源码如下:
//go 1.20.3 path: /src/runtime/proc.go
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
//前面有讲过此函数为汇编代码,此处忽略
func mcall(fn func(*g))
func gosched_m(gp *g) {
......
// gp为准备挂起的用户协程g,即调用runtime.Gosched()函数所在的协程g
goschedImpl(gp)
}
func goschedImpl(gp *g) {
// 获取准备挂起g(gp)的状态,它的状态为目前处于_Grunning,因为它正在运行
status := readgstatus(gp)
// 检查gp的状态是否合法,如果不为_Grunning状态说明状态出现了异常
if status&^_Gscan != _Grunning {
dumpgstatus(gp)
throw("bad g status")
}
// 将gp的状态从_Grunning修改为_Grunnable
casgstatus(gp, _Grunning, _Grunnable)
// 解除m和gp的互相绑定,分别将m.curg和gp.m设置为nil
dropg()
lock(&sched.lock)
// 把gp放入全局的运行队列中
globrunqput(gp)
unlock(&sched.lock)
// 进行新一轮调度
schedule()
}
goschedImpl
函数的主要流程是:
- 主动调度会从当前协程
g
切换到g0
并更新协程状态由运行中_Grunning
变为可运行_Grunnable
; - 通过
dropg()
取消g
与m
的绑定关系; - 通过
globrunqput()
将g放入到全局运行队列中; - 最后调用
schedule()
函数开启新一轮的调度循环。
被动调度
协程挂起
当协程休眠、通道堵塞、网络堵塞、垃圾回收导致暂停时,协程会被动让渡出执行的权利给其他可运行的协程继续执行,这个叫协程挂起。在协程挂起的流程中,调度器通过gopark()
函数执行被动调度逻辑。gopark()
函数最终调用park_m()
函数来完成调度逻辑。
被动调用的触发路径为: runtime.gopark
-> runtime.park_m
。
来看看 gopark
以及相关源码:
//go 1.20.3 path: /src/runtime/proc.go
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
// 检查是否有超时的情况
if reason != waitReasonSleep {
checkTimeouts()
}
// 获取当前 M 和 G
mp := acquirem()
gp := mp.curg
// 检查 G 的状态,必须是 _Grunning 或 _Gscanrunning
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
// 设置等待相关的参数
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waittraceev = traceEv
mp.waittraceskip = traceskip
// 释放当前 M
releasem(mp)
// 切换到内核态执行 park_m
mcall(park_m)
}
gopark
函数则使用mcall
函数(前面分析过,主要作用是保存当前goroutine
现场,然后切换到g0
栈去调用作为参数传入的函数)取执行park_m
函数:
//go 1.20.3 path: /src/runtime/proc.go
func park_m(gp *g) {
// 获取当前 M
mp := getg().m
// 如果开启了 trace,记录 GoPark 事件
if trace.enabled {
traceGoPark(mp.waittraceev, mp.waittraceskip)
}
// 将 G 的状态设置为 _Gwaiting
casgstatus(gp, _Grunning, _Gwaiting)
// 释放 G
dropg()
// 获取等待解锁的函数和锁
if fn := mp.waitunlockf; fn != nil {
// 调用等待解锁的函数
ok := fn(gp, mp.waitlock)
// 清空等待解锁的函数和锁
mp.waitunlockf = nil
mp.waitlock = nil
// 如果等待解锁成功,将 G 的状态设置为 _Grunnable,执行 G
if !ok {
if trace.enabled {
traceGoUnpark(gp, 2)
}
casgstatus(gp, _Gwaiting, _Grunnable)
execute(gp, true)
}
}
// 调度新的 G
schedule()
}
park_m
函数主要做了下面几件事:
- 首先会从当前协程
g
切换到g0
并更新协程状态由运行中_Grunning
变为等待中_Gwaiting
; - 然后通过
dropg()
取消g
与m
的绑定关系; - 接着执行
waitunlockf
函数,如果该函数返回false
, 则协程g
立即恢复执行,否则等待唤醒; - 最后调用
schedule()
函数开启新一轮的调度循环。
协程唤醒
与主动调度不同的是,被动调度的协程g
不会放入到全局队列中进行调度。而是一直处于等待中_Gwaiting
状态等待被唤醒。当等待中的协程被唤醒时,协程的状态由_Gwaiting
变为可运行_Grunnable
状态,然后被添加到当前p
的局部运行队列中。唤醒逻辑通过函数goready()
调用ready()
实现:
//go 1.20.3 path: /src/runtime/proc.go
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
func ready(gp *g, traceskip int, next bool) {
// 如果开启了 trace,记录 GoUnpark 事件
if trace.enabled {
traceGoUnpark(gp, traceskip)
}
// 读取 G 的状态
status := readgstatus(gp)
// 获取当前 M
mp := acquirem()
// 检查 G 的状态是否为等待状态
if status&^_Gscan != _Gwaiting {
// 打印 G 的状态信息
dumpgstatus(gp)
throw("bad g->status in ready")
}
// 将 G 的状态设置为可运行状态
casgstatus(gp, _Gwaiting, _Grunnable)
// 将 G 放入本地 P 的运行队列
runqput(mp.p.ptr(), gp, next)
// 唤醒本地 P
wakep()
// 释放当前 M
releasem(mp)
}
函数用于将 G
设置为可运行状态(_Grunnable
),然后将其放入本地 P
的运行队列。最后,唤醒本地 P
进行调度。如果开启了 trace
,还会记录 GoUnpark
事件。
抢占调度
现代操作系统的调度器多为抢占式调度,其实现方式通过硬件中断来支持线程的切换, 进而能安全的保存运行上下文。在 Go
运行时实现抢占式调度同样也可以使用类似的方式,通过 向线程发送系统信号的方式来中断 M
的执行,进而达到抢占的目的。 但与操作系统的不同之处在于,由于运行时诸多机制的存在(例如垃圾回收器),还必须能够在 Goroutine
被停止时,保存充足的上下文信息。 这就给中断信号带来了麻烦,如果中断信号恰好发生在一些关键阶段(例如写屏障期间), 则无法保证程序的正确性。这也就要求我们需要严格考虑触发异步抢占的时机。
异步抢占式调度的一种方式就与运行时系统监控有关,监控循环会将发生阻塞的 Goroutine
抢占, 因此为了确保每个g
都有机会被调度执行,保证调度的公平性,在初始化的时候会启动一个特殊的线程来执行监控任务(sysmon
函数)。
特殊的线程来执行系统监控任务,系统监控运行在一个独立的工作线程M
上,该线程不用绑定逻辑处理器P
。系统监控每隔10ms
会检测是否有准备就绪的网络协程,并放置到全局队列中。
抢占调用的触发路径为: runtime.sysmon
-> runtime.retake
-> runtime.preemptone
。
runtime.sysmon
先来看下 runtime.sysmon
源码:
//go 1.20.3 path: /src/runtime/proc.go
func sysmon() {
// 加锁,增加系统监控计数
lock(&sched.lock)
sched.nmsys++
checkdead()
unlock(&sched.lock)
lasttrace := int64(0)
idle := 0
delay := uint32(0)
for {
// 根据 idle 情况设置 delay
if idle == 0 {
delay = 20
} else if idle > 50 {
delay *= 2
}
if delay > 10*1000 {
delay = 10 * 1000
}
// 休眠一段时间
usleep(delay)
now := nanotime()
// 如果处于 GC 等待状态或没有空闲 P,则尝试唤醒系统监控
if debug.schedtrace <= 0 && (sched.gcwaiting.Load() || sched.npidle.Load() == gomaxprocs) {
lock(&sched.lock)
if sched.gcwaiting.Load() || sched.npidle.Load() == gomaxprocs {
syscallWake := false
next := timeSleepUntil()
if next > now {
sched.sysmonwait.Store(true)
unlock(&sched.lock)
sleep := forcegcperiod / 2
if next-now < sleep {
sleep = next - now
}
shouldRelax := sleep >= osRelaxMinNS
if shouldRelax {
osRelax(true)
}
syscallWake = notetsleep(&sched.sysmonnote, sleep)
if shouldRelax {
osRelax(false)
}
lock(&sched.lock)
sched.sysmonwait.Store(false)
noteclear(&sched.sysmonnote)
}
if syscallWake {
idle = 0
delay = 20
}
}
unlock(&sched.lock)
}
// 获取 sysmonlock,并进行系统监控的一系列操作
lock(&sched.sysmonlock)
now = nanotime()
// 如果存在 cgo_yield 函数,则调用它
if *cgo_yield != nil {
asmcgocall(*cgo_yield, nil)
}
// 检查网络轮询是否超时,进行网络轮询
lastpoll := sched.lastpoll.Load()
if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
sched.lastpoll.CompareAndSwap(lastpoll, now)
list := netpoll(0)
if !list.empty() {
incidlelocked(-1)
injectglist(&list)
incidlelocked(1)
}
}
// 处理 NetBSD 的 sysmon 问题
if GOOS == "netbsd" && needSysmonWorkaround {
if next := timeSleepUntil(); next < now {
startm(nil, false)
}
}
// 唤醒 scavenge 垃圾回收器
if scavenger.sysmonWake.Load() != 0 {
scavenger.wake()
}
// 尝试重新获取 P
if retake(now) != 0 {
idle = 0
} else {
idle++
}
// 如果满足触发 GC 的条件,并且强制 GC 为 idle 状态,则唤醒强制 GC
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
lock(&forcegc.lock)
forcegc.idle.Store(false)
var list gList
list.push(forcegc.g)
injectglist(&list)
unlock(&forcegc.lock)
}
// 如果开启了调度追踪,并且距离上次追踪已经超过指定时间,则进行调度追踪
if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
lasttrace = now
schedtrace(debug.scheddetail > 0)
}
// 释放 sysmonlock
unlock(&sched.sysmonlock)
}
}
该函数主要是用于监视和调度系统级任务,流程如下:
-
增加系统监控计数(
sched.nmsys++
),检查是否有死锁; -
根据空闲状态设置延迟时间
delay
,在第一轮循环的时候休眠20
微妙,之后每轮循环中休眠时间加倍,直到最大休眠时间达到10
毫秒; -
休眠一段时间,之后进行一系列的监控和调度任务:
-
如果处于
GC
等待状态或没有空闲P
,尝试唤醒系统监控; -
获取
sysmonlock
并进行一系列系统监控任务:- 调用
cgo_yield
函数; - 检查网络轮询是否超时,进行网络轮询;
- 处理
NetBSD
的sysmon
问题; - 如果存在需要唤醒的垃圾回收器任务,进行
scavenge
垃圾回收器唤醒; - 调用
retake
函数尝试重新获取P
; - 如果满足触发
GC
的条件,并且强制GC
处于idle
状态,则唤醒强制GC
; - 如果开启了调度追踪,并且距离上次追踪已经超过指定时间,则进行调度追踪。
- 调用
-
释放
sysmonlock
。
-
runtime.sysmon
函数是一个死循环函数,为了保证每个协程都有执行的机会,系统监控服务会对执行时间过长(大于10ms
)的协程、或者处于系统调用(大于20微秒
)的协程进行抢占。在循环中也会检查是否有准备就绪的网络,并将其放入到全局队列中,也会进行抢占处理,按时间强制执行gc
等操作。
runtime.retake
接下来关注下 runtime.retake
这个函数,runtime.retake
是一个大循环,检查所有的P
,所有的P
保存在全局变量allp
中。对于P
只对它的两种情况进行处理,分别是_Prunning
和_Psyscall
,因为只有这两种状态的P
与之关联的G
正在执行,需要判断是否进行抢占。
runtime.retake
函数源码如下:
//go 1.20.3 path: /src/runtime/proc.go
func retake(now int64) uint32 {
n := 0
// 获取全局 P 列表锁,确保对 P 列表的操作是互斥的
lock(&allpLock)
// 遍历所有 P
for i := 0; i < len(allp); i++ {
// 获取当前索引的 P
pp := allp[i]
// 如果 P 为空,跳过
if pp == nil {
continue
}
// 获取 P 的系统监控信息
pd := &pp.sysmontick
// 获取 P 的状态
s := pp.status
// 标志变量,表示是否需要进行系统抢占
sysretake := false
// 如果 P 处于运行或系统调用状态
if s == _Prunning || s == _Psyscall {
// 获取 P 的调度时钟计数
t := int64(pp.schedtick)
// 如果系统监控信息中的调度时钟与当前 P 的不一致,则更新系统监控信息
if int64(pd.schedtick) != t {
pd.schedtick = uint32(t)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
// 如果距离上次抢占的时间已经超过一定阈值,则触发一次抢占
preemptone(pp)
sysretake = true
}
}
// 如果 P 处于系统调用状态
if s == _Psyscall {
// 获取 P 的系统调用时钟计数
t := int64(pp.syscalltick)
// 如果未进行系统抢占并且系统监控信息中的系统调用时钟与当前 P 的不一致,则更新系统监控信息
if !sysretake && int64(pd.syscalltick) != t {
pd.syscalltick = uint32(t)
pd.syscallwhen = now
continue
}
// 如果运行队列为空且有空闲 P 且距离上次系统调用的时间小于一定阈值,则继续等待
if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
continue
}
// 释放全局 P 列表锁
unlock(&allpLock)
// 减少全局空闲 P 数量
incidlelocked(-1)
// 尝试将 P 状态从运行中改为空闲
if atomic.Cas(&pp.status, s, _Pidle) {
// 如果启用了 trace,则记录相关事件
if trace.enabled {
traceGoSysBlock(pp)
traceProcStop(pp)
}
// 增加系统监控中的空闲 P 数量
n++
// 增加 P 的系统调用时钟计数
pp.syscalltick++
// 切换当前 P,并继续执行
handoffp(pp)
}
// 增加全局空闲 P 数量
incidlelocked(1)
// 重新获取全局 P 列表锁
lock(&allpLock)
}
}
// 释放全局 P 列表锁
unlock(&allpLock)
// 返回触发抢占的 P 数量
return uint32(n)
}
该函数的主要流程逻辑如下:
- 获取全局
P
列表锁,确保对P
列表的操作是互斥的; - 遍历所有
P
,基本操作如下:- 获取当前索引的
P
;如果P
为空,跳过;获取P
的系统监控信息;获取P
的状态; - 如果
P
处于运行或系统调用状态(s == _Prunning || s == _Psyscall
):- 获取
P
的系统调用时钟计数,如果未进行系统抢占并且系统监控信息中的系统调用时钟与当前P
的不一致,则更新系统监控信息; - 如果距离上次抢占的时间已经超过一定阈值(
pd.schedwhen+forcePreemptNS <= now
),则触发一次preemptone
抢占;
- 获取
- 如果
P
处于系统调用状态(s == _Psyscall
):- 获取
P
的系统调用时钟计数,如果未进行系统抢占并且系统监控信息中的系统调用时钟与当前P
的不一致,则更新系统监控信息; - 如果运行队列为空且有空闲
P
且距离上次系统调用的时间小于一定阈值,则继续等待, 结束本次循环; - 释放全局
P
列表锁,减少全局空闲P
数量; - 尝试将
P
状态从_Psyscall
改为_Pidle
,如果成功,则增加系统监控中的空闲P
数量,增加P
的系统调用时钟计数,调用handoffp
函数切换当前P
,并继续执行; - 增加全局空闲
P
数量,重新获取全局P
列表锁;
- 获取
- 获取当前索引的
- 释放全局
P
列表锁; - 返回触发抢占的
P
数量。
从函数的逻辑来看, runtime.retake
函数会对两类情况进行P
抢占:
抢占运行时间过长的P
首先什么情况才是运行时间过长呢?主要条件是:
-
首先
P
的状态为_Prunning
,即说明与P
关联的M
上的G
正在被运行; -
当前
G
运行时间超过了10
毫秒,即运行时间超过了forcePreemptNS
值;
这个G
的运行时间怎么去计算呢?答案是根据P
的上次调度时间,首先P
结构有一个schedtick
字段,每进行一次调度,schedtick
的值会+1,也就是每切换一个G
运行,schedtick
的值加1
, 而 P
中还有一个sysmontick
字段,该字段是一个复合结构,它有四个成员:
//go 1.20.3 path: /src/runtime/runtime2.go
type p struct {
......
schedtick uint32
syscalltick uint32
sysmontick sysmontick
......
}
//go 1.20.3 path: /src/runtime/proc.go
type sysmontick struct {
// 调度的计数器
schedtick uint32
// 保存调度的时间
schedwhen int64
// 进行系统调用的计数器
syscalltick uint32
// 进入系统调用时的时间
syscallwhen int64
}
sysmontick.schedtick
和 sysmontick.schedwhen
分别保存上一次P
调度的时候计数器值和调度的时间。
有趣的是P
结构体中也有个 p.schedtick
字段,而这也是设计的巧妙之处:
- 当
p.schedtick == p.sysmontick.schedtick
时,说明这段时间没有进行G
的切换, 同一个G
的情况下,就可以去判断 该G
的运行的时间了,就比较当前的时间(now
)与sysmontick
中schedwhen
时间差是否大于10
毫秒,如果大于10
毫秒,需要进行preemptone
函数抢占; - 当
p.schedtick != p.sysmontick.schedtick
时,说明这段时间进行了G
的切换,直接更新sysmontick
计数器的值为当前P
的schedtick
值,更新sysmontick
调度时间为当前时间即可。
下面看进行真正抢占的处理逻辑函数 runtime.preemptone
:
//go 1.20.3 path: /src/runtime/proc.go
func preemptone(pp *p) bool {
// 获取当前 P 所属的 M
mp := pp.m.ptr()
// 如果 M 为空,或者 M 就是当前 goroutine 所属的 M,直接返回 false
if mp == nil || mp == getg().m {
return false
}
// 获取当前 M 的当前执行的 goroutine
gp := mp.curg
// 如果当前 goroutine 为空,或者当前 goroutine 是 M 的初始 goroutine,直接返回 false
if gp == nil || gp == mp.g0 {
return false
}
// 设置当前 goroutine 为可抢占状态
gp.preempt = true
// 设置当前 goroutine 的栈警戒地址为 stackPreempt
gp.stackguard0 = stackPreempt
// 如果支持异步抢占并且没有禁用异步抢占
if preemptMSupported && debug.asyncpreemptoff == 0 {
// 设置当前 P 为可抢占状态
pp.preempt = true
// 调用预抢占函数 preemptM
preemptM(mp)
}
// 返回 true 表示执行了抢占操作
return true
}
该函数的主要作用是将指定 P
中的当前 goroutine
设置为可抢占状态,并根据条件调用异步抢占函数 preemptM
。如果发生抢占,返回 true
,否则返回 false
。
在这边将当前的goroutine
设置为可抢占状态的同时,也将stackguard0
字段设置为stackPreempt(0xfffffade)
,并没有看到将当前G
暂停执行的逻辑, 这是一个异步处理。
在栈章节中,我们知道函数调用序言部分会检查 SP
寄存器与 stackguard0
之间的大小,如果 SP
小于 stackguard0
则会 触发 morestack_noctxt
,触发栈分段操作,换言之,如果抢占标记将 stackgard0
设为比所有可能的 SP
都要大(即 stackPreempt
), 则会触发 morestack
,进而调用 newstack
:
//go 1.20.3 path: /src/runtime/stack.go
func newstack() {
...
// 如果是发起的抢占请求而非真正的栈分段
preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
// 保守的对用户态代码进行抢占,而非抢占运行时代码
// 如果正持有锁、分配内存或抢占被禁用,则不发生抢占
if preempt {
if !canPreemptM(thisg.m) {
// 不发生抢占,继续调度
gp.stackguard0 = gp.stack.lo + _StackGuard
gogo(&gp.sched) // 重新进入调度循环
}
}
...
if preempt {
...
// 表现得像是调用了 runtime.Gosched,主动让权
gopreempt_m(gp) // 重新进入调度循环
}
...
}
// 与 gosched_m 一致
func gopreempt_m(gp *g) {
...
goschedImpl(gp)
}
从newstack
代码可以看出,newstack
函数主要完成两项工作:
- 检查是否响应
sysmon
发起的抢占请求; - 检查栈是否需要进行扩容;
newstack
检查被抢占G
的状态,如果处于抢占状态,调用gopreempt_m
将被抢占的gp
切换出去放入到全局g
运行队列。gopreempt_m
是goschedImpl
的简单包装,真正处理逻辑在goschedImpl
函数, 该函数是否很熟悉?对,它又回到了主动调度的调度逻辑中去了。
抢占系统调用中堵塞的P
该类型P
的抢占需要满足 P
在_Psyscall
状态并符合下列条件的任何一个或者多个:
P
的运行队列pp.runq
中还有等待运行的G
(runqempty(pp)
为false
),需要及时的让P
的本地队列中的G
得到调度,而当前的G
又处在系统调用中,无法执行其他的G
,因此将当前的P
抢占过来运行其他的G
;P
对应的M
处于系统调用中到现在已经超过了10
毫秒(pd.syscallwhen+10*1000*1000 > now
);- 当前没有空闲的
P
(sched.npidle
为0
)也没有自旋的M
(sched.nmspinning
为0
)(sched.nmspinning.Load()+sched.npidle.Load() > 0
),说明大家都在忙,这时抢占当前正处于系统调用中而实际上用不上的P
,分配给其他线程用于调度执行其他的G
。
当上述三个条件有任何一个不满足时,会将P
的状态从_Psyscall
修改为_Pidle
,然后调用handoffp
函数,将与进入系统调用的M
绑定的P
分离。这种机制我们称为hand off 机制 。
handoffp
源码如下:
//go 1.20.3 path: /src/runtime/proc.go
func handoffp(pp *p) {
// 如果 P 中的本地队列不为空,或者全局运行队列不为空,则直接启动 M 执行任务
if !runqempty(pp) || sched.runqsize != 0 {
startm(pp, false)
return
}
// 如果处于追踪状态或者追踪正在关闭,并且追踪读取器可用,则直接启动 M 执行任务
if (trace.enabled || trace.shutdown) && traceReaderAvailable() != nil {
startm(pp, false)
return
}
// 如果启用了 GC,并且存在需要标记的工作,则直接启动 M 执行任务
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) {
startm(pp, false)
return
}
// 如果没有其他 M 处于自旋状态且没有空闲的 M,则将当前 P 标记为自旋中状态,并启动 M 执行任务
if sched.nmspinning.Load()+sched.npidle.Load() == 0 && sched.nmspinning.CompareAndSwap(0, 1) {
sched.needspinning.Store(0)
startm(pp, true)
return
}
// 获取全局调度锁
lock(&sched.lock)
// 如果 GC 正在等待,则将 P 状态设置为 _Pgcstop,并减少等待计数
if sched.gcwaiting.Load() {
pp.status = _Pgcstop
sched.stopwait--
// 如果所有 M 都停止等待,则唤醒停止等待的通知
if sched.stopwait == 0 {
notewakeup(&sched.stopnote)
}
unlock(&sched.lock)
return
}
// 如果 P 上存在需要运行的 SafePoint 函数,则执行该函数,并减少 SafePoint 等待计数
if pp.runSafePointFn != 0 && atomic.Cas(&pp.runSafePointFn, 1, 0) {
sched.safePointFn(pp)
sched.safePointWait--
// 如果所有 M 的 SafePoint 函数都执行完毕,则唤醒 SafePoint 等待的通知
if sched.safePointWait == 0 {
notewakeup(&sched.safePointNote)
}
}
// 如果全局运行队列不为空,则释放调度锁并启动 M 执行任务
if sched.runqsize != 0 {
unlock(&sched.lock)
startm(pp, false)
return
}
// 如果当前空闲的 P 数量为 gomaxprocs-1,并且上次轮询的时间不为零,则释放调度锁并启动 M 执行任务
if sched.npidle.Load() == gomaxprocs-1 && sched.lastpoll.Load() != 0 {
unlock(&sched.lock)
startm(pp, false)
return
}
// 计算无障碍唤醒时间
when := nobarrierWakeTime(pp)
// 将 P 放入空闲队列
pidleput(pp, 0)
unlock(&sched.lock)
// 如果有合适的唤醒时间,则唤醒网络轮询器
if when != 0 {
wakeNetPoller(when)
}
}
handoff
会对当前的条件进行检查,如果满足下面的条件则会调用startm
函数启动新的工作线程来与当前的P
进行关联,执行可运行的G
。
从handoffp
的代码可以看出,在如下几种情况下则需要调用我们已经分析过的startm
函数启动新的工作线程出来接管_p_
:
_p_
的本地运行队列或全局运行队列里面有待运行的goroutine
;- 需要帮助
gc
完成标记工作; - 系统比较忙,所有其它
_p_
都在运行goroutine
,需要帮忙; - 所有其它
P
都已经处于空闲状态,如果需要监控网络连接读写事件,则需要启动新的m
来poll
网络连接。
系统调度
系统调用为用户态进程提供了硬件的抽象接口。并且是用户空间访问内核的唯一手段,除异常和陷入外,它们是内核唯一的合法入口。保证系统的安全和稳定。
在Linux
中,每个系统调用被赋予一个独一无二的系统调用号。当用户空间的进程执行一个系统调用时,会使用调用号指明系统调用。
因为用户代码特权级较低,无权访问需要最高特权级才能访问的内核地址空间的代码和数据。所以需要特殊指令,在golang中支持系统调用是Syscall
函数。
Go
通过以下函数提供了对Syscall
的直接调用支持:
//go 1.20.3 path: /src/syscall/syscall_unix.go
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
这些函数的实现都是汇编,并且和具体的硬件架构及OS
相关,比如Linux
下ARM
架构的相应实现,在 src/pkg/syscall/asm_linux_arm.s
中。
按照 linux
的 syscall
调用规范,我们只要在汇编中把参数依次传入寄存器,并调用 SYSCALL
指令即可进入内核处理逻辑,系统调用执行完毕之后,返回值放在 RAX
中:
RDI | RSI | RDX | R10 | R8 | R9 | RAX |
---|---|---|---|---|---|---|
参数一 | 参数二 | 参数三 | 参数四 | 参数五 | 参数六 | 系统调用编号/返回值 |
Syscall 与 Syscall6 的区别:只是参数个数的不同,其他都相同。
Syscall 与 RawSyscall 的区别:Syscall
开始会调用 runtime·entersyscall
,结束时会调用 runtime·exitsyscall
;而 RawSyscall
没有。这意味着 Syscall
是受调度器控制的,RawSyscall
不受。因此 RawSyscall
可能会造成阻塞。
下面来看一下源代码:
TEXT ·Syscall(SB),NOSPLIT,$0-56
CALL runtime·entersyscall(SB) // 进入系统调用
// 准备参数,执行系统调用
MOVQ a1+8(FP), DI
MOVQ a2+16(FP), SI
MOVQ a3+24(FP), DX
MOVQ trap+0(FP), AX // syscall entry
SYSCALL
CMPQ AX, $0xfffffffffffff001 // 对比返回结果
JLS ok
MOVQ $-1, r1+32(FP)
MOVQ $0, r2+40(FP)
NEGQ AX
MOVQ AX, err+48(FP)
CALL runtime·exitsyscall(SB) // 退出系统调用
RET
ok:
MOVQ AX, r1+32(FP)
MOVQ DX, r2+40(FP)
MOVQ $0, err+48(FP)
CALL runtime·exitsyscall(SB) // 退出系统调用
RET
在通过汇编指令 INVOKE_SYSCALL
执行系统调用前后,上述函数会调用运行时的 runtime.entersyscall
和 runtime.exitsyscall
,正是这一层包装能够让我们在陷入系统调用前触发运行时的准备和清理工作。系统调用函数示意图:
不过出于性能的考虑,如果这次系统调用不需要运行时参与,就会使用 syscall.RawSyscall
简化这一过程,不再调用运行时函数。
系统调用 | 类型 |
---|---|
SYS_TIME | RawSyscall |
SYS_GETTIMEOFDAY | RawSyscall |
SYS_SETRLIMIT | RawSyscall |
SYS_GETRLIMIT | RawSyscall |
SYS_EPOLL_WAIT | Syscall |
… | … |
由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被设置成 RawSyscall
类型,例如:SYS_EPOLL_CREATE
、SYS_EPOLL_WAIT
(超时时间为 0)、SYS_TIME
等。
正常的系统调用过程相对比较复杂,下面将分别介绍进入系统调用前的准备工作和系统调用结束后的收尾工作。
系统调用前函数
在执行系统调用前调用 runtime.entersyscall
-> runtime.reentersyscall
,,reentersyscall
它会完成 Goroutine
进入系统调用前的准备工作:
//go 1.20.3 path: /src/runtime/proc.go
func entersyscall() {
reentersyscall(getcallerpc(), getcallersp())
}
func reentersyscall(pc, sp uintptr) {
// 获取当前 G
gp := getg()
// 增加当前 G 的锁计数
gp.m.locks++
// 设置栈警戒值为 stackPreempt,表示在 syscall 期间可以发生抢占
gp.stackguard0 = stackPreempt
// 设置 throwsplit 为 true,表示当前 G 正在进行 split stack 操作
// 保存 pc 和 sp 到当前 G 的栈中
save(pc, sp)
// 设置当前 G 的 syscallsp 和 syscallpc
gp.syscallsp = sp
gp.syscallpc = pc
// 将当前 G 的状态切换为 _Gsyscall
casgstatus(gp, _Grunning, _Gsyscall)
// 检查 syscallsp 是否在当前 G 的栈范围内,如果不在则抛出异常
if gp.syscallsp < gp.stack.lo || gp.stack.hi < gp.syscallsp {
systemstack(func() {
print("entersyscall inconsistent ", hex(gp.syscallsp), " [", hex(gp.stack.lo), ",", hex(gp.stack.hi), "]\n")
throw("entersyscall")
})
}
// 如果开启了追踪,执行 traceGoSysCall,并再次保存 pc 和 sp
if trace.enabled {
systemstack(traceGoSysCall)
save(pc, sp)
}
// 如果处于 sysmon 等待状态,执行 entersyscall_sysmon,并再次保存 pc 和 sp
if sched.sysmonwait.Load() {
systemstack(entersyscall_sysmon)
save(pc, sp)
}
// 如果 P 上存在需要运行的 SafePoint 函数,执行 runSafePointFn,并再次保存 pc 和 sp
if gp.m.p.ptr().runSafePointFn != 0 {
systemstack(runSafePointFn)
save(pc, sp)
}
// 获取当前 G 所在的 P
pp := gp.m.p.ptr()
// 解除当前 G 和 P 之间的关联
pp.m = 0
gp.m.oldp.set(pp)
gp.m.p = 0
// 将当前 P 的状态设置为 _Psyscall
atomic.Store(&pp.status, _Psyscall)
// 如果全局 GC 正在等待,执行 entersyscall_gcwait,并再次保存 pc 和 sp
if sched.gcwaiting.Load() {
systemstack(entersyscall_gcwait)
save(pc, sp)
}
// 减少当前 G 的锁计数
gp.m.locks--
}
runtime.reentersyscall
的主要做了下列事情:
-
设置当前
G
进入系统调用状态:_Grunning
–>_Gsyscall
; -
如果
sysmon
处于等待状态,唤醒sysmon
线程; -
将当前的
P
与M
解绑。但M
会记住该P
,M
会保留P
到m.oldp
中, 同时设置P
的状态为处于系统调用_Psyscall
; -
如果
GC
处于等待运行状态,且当前P
是最后一个转换为_Pgcstop
的,则唤醒GC
。
需要注意的是 runtime.reentersyscall
会使P
和M
的分离,当前线程会陷入系统调用等待返回,在锁被释放后,会有其他 Goroutine
抢占处理器资源。
系统调用后函数
系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall
为当前 Goroutine
重新分配资源,该函数有两个不同的执行路径:
- 调用
runtime.exitsyscallfast
; - 切换至调度器的
Goroutine
并调用runtime.exitsyscall0
。
runtime.exitsyscall
源码如下:
//go 1.20.3 path: /src/runtime/proc.go
func exitsyscall() {
// 获取当前 G
gp := getg()
// 增加当前 G 的锁计数
gp.m.locks++
// 检查 syscall frame 是否有效,如果不再有效,抛出异常
if getcallersp() > gp.syscallsp {
throw("exitsyscall: syscall frame is no longer valid")
}
// 重置 waitsince,用于标记当前 G 不再等待
gp.waitsince = 0
// 获取旧的 P,并清空 G 中保存的旧 P 的指针
oldp := gp.m.oldp.ptr()
gp.m.oldp = 0
// 调用 exitsyscallfast 进行快速退出 syscall 的处理
if exitsyscallfast(oldp) {
// 如果开启了 goroutineProfile,尝试记录 goroutine 的 profile
if goroutineProfile.active {
systemstack(func() {
tryRecordGoroutineProfileWB(gp)
})
}
// 如果开启了 trace,执行 traceGoStart
if trace.enabled {
if oldp != gp.m.p.ptr() || gp.m.syscalltick != gp.m.p.ptr().syscalltick {
systemstack(traceGoStart)
}
}
// 增加 P 的 syscalltick,表示 syscall 完成
gp.m.p.ptr().syscalltick++
// 将 G 的状态切换为 _Grunning
casgstatus(gp, _Gsyscall, _Grunning)
// 重置 G 的 syscallsp 为 0,重置 G 的锁计数
gp.syscallsp = 0
gp.m.locks--
// 根据是否需要抢占,设置 G 的 stackguard0
if gp.preempt {
gp.stackguard0 = stackPreempt
} else {
gp.stackguard0 = gp.stack.lo + _StackGuard
}
// 重置 G 的 throwsplit 为 false
// 如果调度被禁用且 G 不处于可调度状态,执行 Gosched
if sched.disable.user && !schedEnabled(gp) {
Gosched()
}
return
}
// 重置 G 的 sysexitticks 为 0
gp.sysexitticks = 0
// 如果开启了 trace,等待 oldp 的 syscalltick 不等于 G 的 syscalltick
if trace.enabled {
for oldp != nil && oldp.syscalltick == gp.m.syscalltick {
osyield()
}
// 设置 G 的 sysexitticks 为当前 CPU 时间
gp.sysexitticks = cputicks()
}
// 减少 G 的锁计数
gp.m.locks--
// 调用 exitsyscall0 处理 syscall 的退出过程
mcall(exitsyscall0)
// 重置 G 的 syscallsp 为 0,增加 P 的 syscalltick,重置 G 的 throwsplit 为 false
gp.syscallsp = 0
gp.m.p.ptr().syscalltick++
gp.throwsplit = false
}
函数用于处理 G
从 syscall
中退出的过程,其中涉及到对状态的切换和处理。根据不同条件,可能执行快速退出 syscall
的处理或者调用 exitsyscall0
处理 syscall
的退出过程。
这两种不同的路径会分别通过不同的方法查找一个用于执行当前 Goroutine
处理器 P
,快速路径 runtime.exitsyscallfast
:
//go 1.20.3 path: /src/runtime/proc.go
func exitsyscallfast(oldp *p) bool {
// 获取当前 G
gp := getg()
// 如果调度处于 freezeStopWait 状态,不执行 fast exit syscall
if sched.stopwait == freezeStopWait {
return false
}
// 如果存在旧的 P 且旧 P 的状态为 _Psyscall,将其状态切换为 _Pidle
if oldp != nil && oldp.status == _Psyscall && atomic.Cas(&oldp.status, _Psyscall, _Pidle) {
// 将 G 切换到新的 P 上
wirep(oldp)
// 执行 exitsyscallfast_reacquired 处理
exitsyscallfast_reacquired()
return true
}
// 如果存在空闲的 P,执行 exitsyscallfast_pidle 处理
if sched.pidle != 0 {
var ok bool
// 在系统栈上执行 exitsyscallfast_pidle,并检查是否成功
systemstack(func() {
ok = exitsyscallfast_pidle()
// 如果成功且开启了 trace,等待 oldp 的 syscalltick 不等于 G 的 syscalltick
if ok && trace.enabled {
if oldp != nil {
for oldp.syscalltick == gp.m.syscalltick {
osyield()
}
}
// 记录 G 的退出 syscall 的事件
traceGoSysExit(0)
}
})
if ok {
return true
}
}
return false
}
快速路径 runtime.exitsyscallfast
流程如下:
- 如果
Goroutine
的原来的P
,即oldp
处于_Psyscall
状态,会直接调用wirep
将Goroutine
与处理器进行关联; - 如果调度器中存在闲置的处理器(即
sched.pidle>0
),会调用runtime.acquirep
使用闲置P处理当前Goroutine
;
另一个相对较慢的路径 runtime.exitsyscall0
会将当前 Goroutine
切换至 _Grunnable
状态,并移除线程 M
和当前 Goroutine
的关联:
//go 1.20.3 path: /src/runtime/proc.go
func exitsyscall0(gp *g) {
// 将 G 的状态从 _Gsyscall 切换为 _Grunnable
casgstatus(gp, _Gsyscall, _Grunnable)
// 释放当前 G
dropg()
// 锁定调度器
lock(&sched.lock)
var pp *p
// 如果调度器启用,尝试从空闲 P 队列中获取 P
if schedEnabled(gp) {
pp, _ = pidleget(0)
}
var locked bool
if pp == nil {
// 如果未获取到 P,将 G 放入全局运行队列
globrunqput(gp)
// 记录是否 G 被锁定
locked = gp.lockedm != 0
} else if sched.sysmonwait.Load() {
// 如果获取到 P 且 sysmon 在等待中,唤醒 sysmon
sched.sysmonwait.Store(false)
notewakeup(&sched.sysmonnote)
}
// 解锁调度器
unlock(&sched.lock)
// 如果获取到 P,将 G 放入 P 的本地队列,并执行 G
if pp != nil {
acquirep(pp)
execute(gp, false)
}
// 如果 G 被锁定,停止锁定的 M 并执行 G
if locked {
stoplockedm()
execute(gp, false)
}
// 停止当前 M 并调度
stopm()
schedule()
}
runtime.exitsyscall0
流程如下:
- 更新
G
的状态是_Grunnable
; - 调用
dropg()
:解除当前G
与M
的绑定关系; - 尝试获取
P
,获取到则acquirep
绑定P
和M
,execute
进入调度循环;未获取到则globrunqput
将gp
放入sched.runq
; - 调用
stopm()
释放M
,将M
加入全局的idel M
列表,这个调用会阻塞,知道获取到可用的P
; M
获取到了可用的P
,阻塞结束, 会调用schedule()
函数,执行一次新的调度。
无论哪种情况,我们在这个函数中都会调用 runtime.schedule
触发调度器的调度。