跳到主要内容

Go 高并发实战(1):GMP 调度器、网络轮询与容量模型

Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
Rainy
9 MIN READ... VIEWS

并发设计的第一步不是 go func(),而是知道工作在等待什么、占用什么,以及允许多少工作同时存在。

本期建立全系列的成本模型:CPU 密集、网络 I/O、同步阻塞和排队分别如何影响 Go runtime,并把目标 RPS 转成 goroutine、连接和内存预算。

一、并发、并行与吞吐

  • 并发:多个任务的生命周期重叠;单核也能并发。
  • 并行:多个任务在同一时刻实际执行;受 CPU 配额约束。
  • 吞吐:单位时间完成的工作量。
  • 延迟:单个请求从到达到完成的时间,必须看 P50/P95/P99。

I/O 服务可以有远多于 CPU 核数的并发,因为 task 大量时间在等待;CPU 服务把并发开到核数的十倍,通常只会增加调度、缓存失效和排队。

二、GMP:谁在运行 goroutine

  • G 保存 goroutine 栈、状态和执行位置;
  • M 对应 OS thread;
  • P 持有运行 Go 代码所需的调度资源,本地 run queue 降低全局争用;
  • GOMAXPROCS 限制同时执行 Go 代码的 P 数量,不限制 goroutine 总数;
  • worker 会从其他 P 偷取工作,避免局部队列饥饿。

网络 socket 不必为每个连接永久占一条线程。goroutine 在网络 I/O 未就绪时可被挂起,netpoller 在 epoll/kqueue/IOCP 等机制通知后把它重新放回可运行队列。

2.1 从一次网络请求看 G 的完整状态变化

按事件顺序推演:

  1. accept 到连接后,handler G 进入 runnable queue;它存在,但尚未得到 CPU。
  2. P 选中 G,M 执行请求解析;CPU 时间从这里开始计入业务执行。
  3. 网络读尚未就绪,G 进入 waiting,M/P 转而执行其他 G。
  4. 内核通知 fd 可读,netpoller 把 G 放回 runnable;它仍需排队等待 P。
  5. 如果 run queue 很长,即使下游已经返回,scheduled-to-running 延迟仍会抬高 P99。

因此“下游只花 20 ms”不等于请求只花 20 ms:

T_total = T_admission + T_scheduler + T_pool + T_io + T_cpu + T_write

只有分别测量这些阶段,才能判断应该增加 CPU、缩短临界区、调整连接池还是直接拒绝。增加 goroutine 通常只会改变排队位置。

2.2 三种“阻塞”不能混为一谈

类型例子runtime 行为主要风险
网络轮询等待HTTP、TCPG 挂起,M 可执行其他 Gsocket、内存和下游容量
可识别 syscall文件、部分系统调用runtime 可能让 P 转交给其他 MOS thread 增长、syscall 长尾
Go 代码不让出大循环、重计算持续占用 P,依赖抢占scheduler latency、P99 抬升

cgo、驱动实现和内核状态会改变阻塞行为。排障时看线程栈与 runtime trace,而不是根据函数名猜测。

三、用 Little's Law 算第一版容量

稳定系统近似满足:

L = λ × W

入口 10,000 RPS,每个请求扇出 3 次,下游平均 20 ms:

下游到达率 = 10,000 × 3 = 30,000 calls/s
平均在途下游调用 = 30,000 × 0.020 = 600

600 是平均值,不是最终限额。把 P99 200 ms 代入,故障窗口可能需要面对 6,000 个在途调用。正确策略不是准备 6,000 个数据库连接,而是给系统设置 600~900 左右的许可、短等待 deadline 与明确降级。

3.1 四张预算表

  1. CPU:每请求 CPU 时间 × RPS,不应长期超过 CPU 配额。
  2. 内存:在途请求 × 每请求保留对象,再加 cache、heap 碎片与安全余量。
  3. 下游:实例并发 × 副本数不能超过数据库、缓存、第三方 API 的总容量。
  4. 延迟:入口 deadline 必须覆盖排队和各阶段工作,但每个子 deadline 要小于剩余预算。

示例:每个请求在等待期间保留 32 KiB,5,000 个在途请求仅业务对象就约 156 MiB;若还有 5,000 个 goroutine、响应 buffer 和 tracing 字段,RSS 会更高。

四、实验:观察调度而不是猜

package main

import (
"runtime"
"runtime/metrics"
"time"
)

func main() {
println("gomaxprocs", runtime.GOMAXPROCS(0))

samples := []metrics.Sample{
{Name: "/sched/goroutines:goroutines"},
{Name: "/sched/latencies:seconds"},
{Name: "/cpu/classes/gc/total:cpu-seconds"},
}

ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
metrics.Read(samples)
// 实际服务中把值导出到监控系统,不在热路径逐条打印。
println("goroutines", samples[0].Value.Uint64())
}
}

配合调度器日志:

GODEBUG='schedtrace=1000,scheddetail=1' go run .

重点看 runnable goroutine 是否长期堆积、线程数是否异常、空闲 P 与 syscall 状态。短时间调试日志信息量很大,不适合在全部生产实例长期开启。

五、GOMAXPROCS 调优边界

  • CPU-bound:从容器可用 CPU 附近开始,压测吞吐和 P99;
  • I/O-bound:提高 goroutine 数可以增加吞吐,但 GOMAXPROCS 不需要跟连接数相等;
  • cgroup CPU quota 与宿主机核数不一致时,要确认 runtime 实际看到的并行度;
  • CPU throttling 会表现成 runtime 莫名“没拿到时间片”,必须同时看容器指标。

不要用线上 GOMAXPROCS 调整掩盖锁竞争或下游排队。加 P 后吞吐不升、mutex delay 上升,说明瓶颈不在可用 CPU。

六、调度器内部:为什么局部队列和 work stealing 重要

每个 P 都有本地 runnable queue,常规 spawn 优先进入本地队列,减少所有 P 争用同一把全局锁。当一个 P 没有工作时,会从全局队列、netpoller 或其他 P 获取任务。这个设计解释了几个常见现象:

  • 一个 goroutine 连续制造大量子任务,短时间内工作可能集中在同一个 P;
  • work stealing 能重新平衡,但迁移会影响 cache locality;
  • runnable 不等于 running,run queue 长意味着 CPU 或 P 已饱和;
  • goroutine 数量正常而 runnable latency 高,仍然是严重调度问题。

6.1 抢占点与长尾

现代 Go 支持异步抢占,但不能因此允许任意长的 CPU 临界区。紧密计算循环会带来:

  1. 当前 P 上其他 runnable goroutine 等待更久;
  2. request deadline 已到,但 CPU 函数不检查 Context;
  3. GC 标记辅助和调度工作与业务争用 CPU;
  4. 容器只有 1~2 核时,偶发长任务直接表现为全站 P99 尖刺。

把 CPU 算法切成可取消的小块:

func HashBatch(ctx context.Context, inputs [][]byte) ([][32]byte, error) {
output := make([][32]byte, len(inputs))
for i, input := range inputs {
if i%64 == 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
output[i] = sha256.Sum256(input)
}
return output, nil
}

检查频率来自取消响应目标和检查开销的 benchmark,不要每处理一个字节都 select,也不要几秒才检查一次。

七、GC、分配率与并发的反馈环

高并发会提高在途对象数,扩大 live heap;live heap 增大又会增加标记工作和内存带宽压力。即使 stop-the-world 很短,GC CPU 也可能挤占业务 CPU。

更高并发 → 更多在途对象 → 更大 live heap
↑ ↓
超时/重试 ← CPU 与内存带宽压力 ← GC work

因此“多开 goroutine”可能在某个拐点之后让吞吐下降。实验时同时记录:

  • 每请求 allocs/opB/op
  • heap goal、live heap、GC CPU fraction;
  • 请求 in-flight 和排队时间;
  • P99 与超时后的重试量。

使用 go test -bench=. -benchmem 先识别热路径分配,再在完整服务压测中确认。微基准降低 2 次分配,不代表线上在锁或数据库瓶颈下会变快。

八、容量计算工作表示例

项目公式示例
平均入口 in-flightRPS × 平均响应时间10,000 × 0.05 = 500
P99 风险窗口RPS × P9910,000 × 0.25 = 2,500
下游 in-flightRPS × fan-out × 下游耗时10,000 × 3 × 0.02 = 600
请求保留内存in-flight × 每请求 live bytes2,500 × 32 KiB ≈ 78 MiB
单实例 DB 连接DB 总预算 ÷ 最大副本数2,000 ÷ 20 = 100

把平均值、峰值和故障值分三列记录。部署前必须回答:哪一个硬限制最先触发,以及触发时返回什么。

九、对照实验设计

编写三个 endpoint:纯 sleep、SHA-256 批量计算、二者各半。分别在 GOMAXPROCS=1/2/4/8 下固定到达率压测。预期:

  • sleep 型吞吐主要受 in-flight/连接限制;
  • CPU 型在接近可用核数后收益快速下降;
  • 混合型若 CPU work 不限并发,会拖累本来很轻的 I/O 请求;
  • 容器 throttling 时,提高 GOMAXPROCS 可能让 P99 更差。

9.1 实验必须记录的证据

输入:固定到达率、请求大小、fan-out、下游延迟分布
配置:GOMAXPROCS、CPU quota、入口许可、连接池
输出:完成 RPS、P50/P95/P99、rejected、timeout
运行时:runnable latency、goroutine、threads、GC CPU、heap live
系统:CPU throttling、run queue、context switch、RSS

每轮只改变一个变量。例如把 GOMAXPROCS 从 2 改成 4 时,不同时扩大 DB pool;否则无法判断吞吐变化来自哪里。

十、本期实战验收

  • 计算目标流量下平均/P99 在途请求、下游调用和内存预算;
  • 分别运行网络等待与 CPU 忙循环,比较吞吐、P99、goroutine 和 scheduler latency;
  • 逐档调整 GOMAXPROCS,找到吞吐不再增长的拐点;
  • 写下服务的 CPU、内存、连接和 deadline 四个硬预算。

下一期:Go-2:结构化并发、Context 与有界 Worker Pool

系列目录:Go 高并发实战路线

参考资料

Logo
RainLib

探索技术、设计与分布式系统的边界。构建面向未来的开发者工具。

留言与建议

© 2026 RainLib. 为未来构建。(Built for the Future)
版权所有。
系统正常