Go 高并发实战(1):GMP 调度器、网络轮询与容量模型
并发设计的第一步不是
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 的完整状态变化
按事件顺序推演:
- accept 到连接后,handler G 进入 runnable queue;它存在,但尚未得到 CPU。
- P 选中 G,M 执行请求解析;CPU 时间从这里开始计入业务执行。
- 网络读尚未就绪,G 进入 waiting,M/P 转而执行其他 G。
- 内核通知 fd 可读,netpoller 把 G 放回 runnable;它仍需排队等待 P。
- 如果 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、TCP | G 挂起,M 可执行其他 G | socket、内存和下游容量 |
| 可识别 syscall | 文件、部分系统调用 | runtime 可能让 P 转交给其他 M | OS 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 四张预算表
- CPU:每请求 CPU 时间 × RPS,不应长期超过 CPU 配额。
- 内存:在途请求 × 每请求保留对象,再加 cache、heap 碎片与安全余量。
- 下游:实例并发 × 副本数不能超过数据库、缓存、第三方 API 的总容量。
- 延迟:入口 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 临界区。紧密计算循环会带来:
- 当前 P 上其他 runnable goroutine 等待更久;
- request deadline 已到,但 CPU 函数不检查 Context;
- GC 标记辅助和调度工作与业务争用 CPU;
- 容器只有 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/op与B/op; - heap goal、live heap、GC CPU fraction;
- 请求 in-flight 和排队时间;
- P99 与超时后的重试量。
使用 go test -bench=. -benchmem 先识别热路径分配,再在完整服务压测中确认。微基准降低 2 次分配,不代表线上在锁或数据库瓶颈下会变快。
八、容量计算工作表示例
| 项目 | 公式 | 示例 |
|---|---|---|
| 平均入口 in-flight | RPS × 平均响应时间 | 10,000 × 0.05 = 500 |
| P99 风险窗口 | RPS × P99 | 10,000 × 0.25 = 2,500 |
| 下游 in-flight | RPS × fan-out × 下游耗时 | 10,000 × 3 × 0.02 = 600 |
| 请求保留内存 | in-flight × 每请求 live bytes | 2,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 高并发实战路线。