一个内部 RPC 服务,平均延迟 2ms,p99 每隔十几秒跳到 40ms 左右。CPU 使用率稳定在 40%,GC 暂停不到 1ms,看板上一片正常。最后把 runtime/trace 抓下来,问题出在调度器的工作窃取上。
模型先摆清楚
Go 的程序由三类东西组成:G(goroutine)、M(内核线程)、P(处理器上下文)。G 只有在绑定到 P 之后才能运行,M 是真正执行代码的线程。每个 P 有一条本地运行队列,长度上限 256;放不下的 G 进全局队列。
当一个 P 的本地队列空了,它不会立刻休眠,而是先进入 spinning 状态,尝试从别的 P 的队列尾部偷一半任务。如果全局队列和所有本地队列都没有活,它才去 netpoll 里找可运行的 G,最后才让 M 睡眠。
这套设计的目的是让线程尽量不闲下来,代价是:G 会在 P 之间迁移,迁移意味着缓存失效,而窃取本身也要拿锁。
尾延迟是怎么被抬起来的
把 trace 展开后能看到一条很规整的因果链:
- 每个请求会派生若干个小 goroutine,其中一个负责解析大 JSON,运行时间明显长于其他 goroutine。
- 长任务所在 P 的本地队列被它占住,队列里排着的短任务无法被执行。
- 空闲 P 来偷任务时,偷走的是本地队列尾部的一半。短任务被搬到另一个 P,原本在 L1/L2 里的数据全部失效。
- 短任务在新 P 上重新预热缓存,单个耗时从几微秒涨到几十微秒;大量短任务叠加,p99 就被抬起来。
一个容易忽略的细节是 sync.Pool。它按 P 分片,任务被偷走后,原来 P 上缓存的临时对象在新 P 上用不到,等于每次迁移都多了一次分配和一次 GC 压力。
怎么确认
go tool trace 里看两条:Scheduler latency profile 和 Goroutine analysis。前者给出“G 从可运行到真正运行”的等待分布,后者列出运行时间最长的 goroutine。如果等待分布的长尾和慢请求的时间点对得上,基本可以确定是调度排队,而不是下游依赖。
再看 runtime/metrics 的 /sched/latencies:seconds,它把调度延迟做成了直方图,适合长期监控:
samples := []runtime.MetricSample{{Name: "/sched/latencies:seconds"}}runtime.ReadMetrics(samples)h := samples[0].Value.Float64Histogram()for i, c := range h.Counts { fmt.Printf("%.6f..%.6f: %d\n", h.Buckets[i], h.Buckets[i+1], c)}三个改动
把长短任务分开
解析大 JSON 的 goroutine 不再和请求处理共用同一个 P 的本地队列,而是丢进一个有界工作池。池的 worker 数量固定,队列长度有上限,满载时上游直接返回 503,而不是继续堆积。
type Pool struct { jobs chan func() wg sync.WaitGroup}
func NewPool(workers, queue int) *Pool { p := &Pool{jobs: make(chan func(), queue)} for i := 0; i < workers; i++ { p.wg.Add(1) go func() { defer p.wg.Done() for job := range p.jobs { job() } }() } return p}
func (p *Pool) Submit(job func()) bool { select { case p.jobs <- job: return true default: return false // 有界队列,满了就拒绝 }}缩小单次任务
短任务如果只有几十微秒,窃取和迁移的成本占比就很高。把同一批小对象合并成一次处理,减少 goroutine 数量,往往比调 GOMAXPROCS 有效。
给缓存留位置
sync.Pool 里的对象不要在跨 goroutine 传递后继续复用;按请求生命周期重新取,比让对象跟着任务迁移更便宜。
结论
调度器的工作窃取是为了吞吐,不是为了尾延迟。当任务粒度不均、缓存局部性又重要时,窃取会把缓存打散。trace 里的调度等待直方图能直接定位这件事,修法通常是控制并发形状,而不是继续加机器。