Appearance
沿着一个 G 找到下一个可运行任务
同样开四个 worker,一个做哈希,一个等定时器。两组的每次操作耗时都下降,是否说明它们原来都缺 CPU?先区分测量对象:并行基准把总耗时除以完成数;等待中的任务可以重叠,却没有让单个定时器提前到期。带着这个反例读下面的 G、M、P,再回来看实验。
网关维护数千条长连接时,大部分时间在等待 socket。若每次等待都独占一个 OS 线程,线程栈和内核调度会成为额外开销。Go 将执行上下文拆成三种对象:G 存栈、调度寄存器和等待原因;M 是执行代码的 OS 线程;P 持有执行 Go 代码所需的本地队列、分配缓存等资源。GOMAXPROCS 限制同时执行用户 Go 代码的 P 数,不是 goroutine 数,也不限制所有 M 的总数。
flowchart LR; newproc-->runq; runq-->findRunnable; findRunnable-->execute; execute-->gopark; gopark-->waiting; waiting-->goready; goready-->runq; execute-->goexit0
在 src/runtime/proc.go 从 newproc 看新 G 如何入队,再从 schedule 进入 findRunnable。调度器检查本地队列、全局队列、网络就绪事件等;空闲 P 可以从别的 P 偷取一批工作,减少所有 P 共争一个锁的时间。周期性检查全局队列是公平性机制,优先本地工作则有利于缓存局部性。执行阻塞系统调用时 M 可能停在内核,P 可以转交其他 M。对于可轮询 socket,runtime 的 netpoller 使用 Darwin kqueue 或 Linux epoll,G 停放而 M 可以执行其他 G。磁盘文件 I/O 不能一概类推。异步抢占避免长循环永久独占 P,但它不保证某个 G 的时延上界。
sh
cd modeldock
rg -n '^func (newproc|schedule|findRunnable|execute|park_m|goexit0)\(' "$(go env GOROOT)/src/runtime/proc.go"
go test ./labs/runtime -run TestBlockedProducerExits -trace=/tmp/modeldock.trace
go tool trace /tmp/modeldock.trace跟进一次成功偷取。 runqgrab 用原子操作认领受害者队列中的一批 G;runqsteal 从批次中直接取一条运行,余下加入本 P 的队列。直接返回避免刚偷来的工作再经历一次入队/出队。队列长度是 head/tail 的差值,不能把环形数组下标当作长度。
完整源码:handbook/snippets/runqsteal.go.txt · 第 2–18 行
txt
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
t := pp.runqtail
n := runqgrab(p2, &pp.runq, t, stealRunNextG)
if n == 0 {
return nil
}
n--
gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()
if n == 0 {
return gp
}
h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
if t-h+n >= uint32(len(pp.runq)) {
throw("runqsteal: runq overflow")
}
atomic.StoreRel(&pp.runqtail, t+n) // store-release, makes the item available for consumption
return gp对照 trace 中 runnable 与 waiting:前者等 P,后者等外部事件或同步。若请求已取消而生产者仍卡在发送,增加 GOMAXPROCS 无效;发送方必须也 select ctx.Done。goroutine 泄漏通常不是运行时“忘记回收”:活 G 的栈保留引用,GC 不能让它消失。采集两次 goroutine profile,看同一阻塞栈的数量是否随已完成请求累积。
同一机器、两类工作,改变 P 数
一个 worker 重复做 SHA256,另一种等待 1ms 定时器;等待代表可 park 的阻塞,不代表一次真实网络往返。RunParallel 默认 worker 会随 GOMAXPROCS 增长,因而此实验同时改变执行资源和在途数量;它不用于证明等待请求需要更多 CPU。
完整源码:modeldock/labs/runtime/schedule_test.go · 第 9–28 行
go
// Run with -cpu=1,4. Both workloads use the same parallel driver; one consumes
// execution time, the other parks awaiting a timer (a controlled wait, not a network benchmark).
func BenchmarkWork(b *testing.B) {
for _, kind := range []string{"cpu", "wait"} {
b.Run(kind, func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
var sum [32]byte
for pb.Next() {
if kind == "cpu" {
for range 1024 {
sum = sha256.Sum256(sum[:])
}
} else {
time.Sleep(time.Millisecond)
}
}
})
})
}
}sh
cd modeldock
go test ./labs/runtime -run '^$' -bench BenchmarkWork -cpu=1,4 -benchmem
go test -c ./labs/runtime -o /tmp/cloudlab-sched.test
GODEBUG=schedtrace=100,scheddetail=0 /tmp/cloudlab-sched.test -test.run='^$' -test.bench=BenchmarkWork/cpu -test.cpu=1,4
rm /tmp/cloudlab-sched.testM5 Pro / Go 1.26.2 本轮 CPU 组从 36870 到 9827 ns/op,wait 组从 1137371 到 283137 ns/op。这是总耗时除以完成次数,不是单个定时器变为 0.28ms。看 schedtrace 的 idleprocs 和 runqueue,并跟 trace 的 runnable/waiting 交叉判断:CPU 满且 runnable 堆长,才考虑增加执行资源;等待数据库连接时先查池与后端。**追问:**有一个系统调用卡住,需要把整个 P 留给这个线程吗?解释 M 与 P 的移交,再指出 cgo/磁盘 I/O 和可轮询套接字不同。