07 · 操作系统:进程、调度与系统调用
“200 个进程在 8 个核上’同时’运行——这是一场精心编排的魔术。“
开场:谁在骗它们?
老陈:你的机器上有多少个进程在跑?
小林:(打开任务管理器)……200 多个。
老陈:几个核?
小林:8 核。
老陈:200 个进程,8 个核。它们怎么”同时”运行?
小林:时间片轮转啊,每个进程跑一小段时间就切换。
老陈:“切换”是谁做的?进程自己愿意吗?
小林:……操作系统强制切换?
老陈:操作系统怎么”强制”?进程正在执行自己的指令,CPU 的 PC 寄存器指着它的下一条指令。OS 凭什么能插一脚?
小林:……中断?
老陈:对。但中断是硬件产生的。谁设置硬件产生中断?
小林:……时钟?
老陈:时钟中断。硬件每 1ms(可配置)产生一次中断,CPU 强制停下当前指令流,跳到内核预设的中断处理程序。 这是整个多任务系统的物理基础。
小林:所以进程是被”打断”的,不是”主动让出”的?
老陈:对。这就是抢占式调度(Preemptive Scheduling)的本质——进程不需要配合,OS 靠硬件中断强行夺回控制权。
小林:那如果进程正在做一个很重要的操作,被打断了怎么办?
老陈:关键问题。 你听说过”原子操作”吗?为什么需要它?
小林:……因为操作可能被打断?
老陈:对。但更精确地说:在用户态,你无法阻止被抢占。 你能做的只有:
- 用硬件提供的原子指令(
LOCK前缀的 CAS 等),让一个操作在硬件层面不可分割- 进入内核态并关闭中断(只有内核能这么做)
- 用锁(但锁的实现本身依赖原子指令)
所以整个并发编程的大厦,建立在”硬件提供原子指令”这块基石上。 理解这一点,第 5 章讲分布式共识时你会看到同样的模式。
第一层:三个”执行体”的抽象
| 抽象 | 谁管理 | 切换成本 | 隔离性 | 共享什么 |
|---|---|---|---|---|
| 进程 (Process) | 内核 | ~2-10 μs | 完全隔离(独立地址空间) | 几乎不共享 |
| 线程 (Thread) | 内核 | ~1-5 μs | 共享地址空间 | 堆、全局变量、文件描述符 |
| goroutine | Go runtime(用户态) | ~0.2 μs | 共享地址空间 | 全部 |
关键洞察:隔离性和切换成本是一对矛盾。
- 进程隔离最彻底,但切换要换页表(CR3 寄存器)、刷 TLB,代价最大
- 线程共享地址空间,切换不用换页表,但一个线程崩了整个进程都崩
- goroutine 连内核都不用进,纯用户态切换,代价最小,但必须由 runtime 统一管理
Linux 的实际实现:线程就是”共享资源的进程”
Linux 内核里没有真正的”线程”概念。它用 clone() 系统调用创建”轻量级进程”:
// 创建进程(fork 的底层)
clone(SIGCHLD, 0);
// 创建线程(pthread_create 的底层)
clone(CLONE_VM | // 共享地址空间
CLONE_FS | // 共享文件系统信息
CLONE_FILES | // 共享文件描述符表
CLONE_SIGHAND | // 共享信号处理
CLONE_THREAD, // 同一线程组
stack_top);区别只在 clone 的参数:共享哪些资源。
老陈:这个设计非常优雅。“进程”和”线程”不是两种不同的东西,而是”共享程度”不同的同一种东西。 内核只需要实现一套机制,通过参数控制共享粒度。
这是一个普适的设计原则:不要为”看起来不同的东西”设计两套机制,先看看它们是不是同一个东西的参数化变体。
第二层:特权级与系统调用
为什么需要特权级
如果任何程序都能执行 hlt(停机)、修改页表、直接读写磁盘,那系统就完蛋了。
x86 提供 4 个特权级(Ring 0~3),Linux 只用了两个:
Ring 0 (内核态)
· 可以执行所有指令
· 可以访问所有内存
· 可以操作硬件
────────────────────────────
Ring 3 (用户态)
· 只能执行非特权指令
· 只能通过页表访问自己的内存
· 不能直接操作硬件
· 想做任何"特权"的事?→ 系统调用
系统调用的完整流程
以 write(1, "hello", 5) 为例:
① 用户态:程序调用 glibc 的 write()
↓
② glibc 把参数放进寄存器(rdi=fd, rsi=buf, rdx=count)
把系统调用号放进 rax(write 是 1)
↓
③ 执行 syscall 指令
──────────────── 硬件自动做这些事 ────────────────
· 把 %rip(下一条指令地址)保存到 %rcx
· 把 %rflags 保存到 %r11
· 从 IA32_STAR MSR 加载内核的 CS 和 SS
· 切换到 Ring 0
· 切换到内核栈(从 IA32_TSS 加载 %rsp)
· 跳转到 IA32_LSTAR MSR 里存的系统调用入口
────────────────────────────────────────────────
↓
④ 内核态:entry_SYSCALL_64(汇编)
· 保存所有用户态寄存器到内核栈(pt_regs 结构)
· 检查系统调用号是否合法
· 查 sys_call_table[rax] 找到处理函数
↓
⑤ 执行 __x64_sys_write()
· 参数从 pt_regs 里取出来
· 做实际的工作(查 fd、拷贝到内核缓冲、调用驱动)
↓
⑥ 返回:把返回值放进 %rax
执行 sysret 指令,恢复用户态
关键点:syscall 和 sysret 是硬件指令。
在 x86-64 之前,系统调用用 int 0x80(软中断)。但它要走完整的中断处理流程(查 IDT、保存更多状态),慢。
syscall/sysret 是专门为系统调用设计的”快速路径”:
int 0x80 | syscall | |
|---|---|---|
| 保存的寄存器 | 很多 | 最少必要 |
| 是否查 IDT | 是 | 否(直接跳 MSR 里的地址) |
| 大致开销 | ~200 cycles | ~80 cycles |
vDSO:连系统调用都省掉
有些”系统调用”其实不需要进内核:
gettimeofday()—— 读时间getcpu()—— 我在哪个核上getpid()—— 我的进程 ID
这些数据内核可以放在用户态可读的内存页里。
vDSO(virtual Dynamic Shared Object) 就是内核映射到每个进程地址空间的一个小程序:
$ cat /proc/self/maps | grep vdso
7ffd12345000-7ffd12347000 r-xp 00000000 00:00 0 [vdso]glibc 的 gettimeofday() 会先尝试 vDSO 版本,成功就不进内核。
实测差距:
gettimeofday (vDSO): ~25 ns
gettimeofday (syscall): ~65 ns
clock_gettime (vDSO): ~22 ns
快了近 3 倍。
老陈:这也是 Go 的
time.Now()快的原因之一——Linux 上它走 vDSO。但要注意:Go 的
time.Now()返回的Time里同时包含”墙上时钟”和”单调时钟”,它需要一次调用拿到两个值。在老内核上可能要两次 vDSO 调用。
第三层:上下文切换的真实成本
直接测量
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
)
// 测量系统调用的开销
func benchSyscall(n int) time.Duration {
// 用一个"什么都不做"的系统调用:getpid
start := time.Now()
for i := 0; i < n; i++ {
syscall.Getpid()
}
return time.Since(start)
}
// 测量函数调用的开销(作为对照)
var sink int
func emptyFunc() {}
func benchFuncCall(n int) time.Duration {
start := time.Now()
for i := 0; i < n; i++ {
emptyFunc()
}
return time.Since(start)
}
// 测量 goroutine 切换的开销
func benchGoroutineSwitch(n int) time.Duration {
ch := make(chan int, 1)
done := make(chan struct{})
go func() {
for i := 0; i < n; i++ {
<-ch
ch <- 1
}
close(done)
}()
start := time.Now()
ch <- 1
for i := 0; i < n; i++ {
ch <- 1
<-ch
}
<-done
return time.Since(start) / time.Duration(n*2)
}
func main() {
const N = 1_000_000
fn := benchFuncCall(N)
fmt.Printf("%-30s %8.2f ns\n", "函数调用(对照)", float64(fn.Nanoseconds())/float64(N))
sc := benchSyscall(N)
fmt.Printf("%-30s %8.2f ns\n", "系统调用 (getpid)", float64(sc.Nanoseconds())/float64(N))
gs := benchGoroutineSwitch(200_000)
fmt.Printf("%-30s %8.2f ns\n", "goroutine 切换", float64(gs.Nanoseconds()))
// 用 lmbench 或自己写 C 测线程切换
fmt.Println("\n用 taskset 绑核后测线程切换(需要 C 程序)")
measureThreadSwitch()
}
func measureThreadSwitch() {
// 用 C 写个 pipe 在两个线程间来回传数据
src := `
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>
int main() {
int fd[2];
pipe(fd);
char buf[1];
struct timespec start, end;
pid_t pid = fork();
if (pid == 0) {
// 子进程:收到就发回
for (int i = 0; i < 100000; i++) {
read(fd[0], buf, 1);
write(fd[1], buf, 1);
}
return 0;
}
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < 100000; i++) {
write(fd[1], buf, 1);
read(fd[0], buf, 1);
}
clock_gettime(CLOCK_MONOTONIC, &end);
double ns = (end.tv_sec - start.tv_sec) * 1e9 + (end.tv_nsec - start.tv_nsec);
printf("%-30s %8.2f ns\n", "进程上下文切换 (2 次)", ns / 100000);
wait(NULL);
return 0;
}
`
os.WriteFile("/tmp/ctxswitch.c", []byte(src), 0644)
cmd := exec.Command("sh", "-c", "gcc -O2 -o /tmp/ctxswitch /tmp/ctxswitch.c && /tmp/ctxswitch")
out, err := cmd.Output()
if err != nil {
fmt.Println("(无法编译 C 测试程序:", err, ")")
return
}
fmt.Print(string(out))
}典型结果
函数调用(对照) 1.85 ns
系统调用 (getpid) 58.30 ns
goroutine 切换 51.20 ns
进程上下文切换 (2 次) 3120.00 ns
换算成”相对函数调用的倍数”:
函数调用 1x (1.85 ns)
系统调用 31x (58 ns)
goroutine 切换 28x (51 ns)
上下文切换 1686x (3120 ns) ← 每次切换 1560 ns
上下文切换为什么这么贵?
一次完整的进程切换,内核要做:
① 保存用户态寄存器到内核栈(pt_regs) ~20 条指令
② 保存内核栈指针、切换内核栈 ~5 条
③ ★ 切换页表(写 CR3 寄存器) ~100 cycles
├─ 直接开销:一条 mov 指令
└─ 间接开销:★ TLB 全部失效!
④ 保存/恢复 FPU/SSE 状态(如果用了) ~200 cycles
⑤ 调度器选择下一个进程(运行 CFS 算法) ~几百 cycles
⑥ 恢复下一个进程的寄存器 ~20 条
⑦ sysret 返回用户态 ~80 cycles
其中 ③ 是最大的隐性开销。
换页表 → CR3 改变 → TLB 里所有条目失效(因为 TLB 缓存的是虚拟地址到物理地址的映射,而不同进程的页表不同)。
后果:进程切换后,接下来几十次内存访问全是 TLB miss,每次要走 4 级页表(4 次访存)。
TLB miss 的代价: ~40 ns (4 次访存)
切换后前 50 次访问都 miss: 50 × 40 = 2000 ns
这正好解释了为什么实测是 1560ns 级别。
老陈:这就是”直接成本 vs 间接成本”的经典案例。
切换本身的直接指令成本可能只有 100ns,但它把 CPU 的缓存状态(TLB、L1/L2)全破坏了。真正的代价是”接下来一段时间程序跑得很慢”。
这个模式在计算机系统里反复出现:
- TLB shootdown(
munmap后通知所有核刷 TLB)- GC 的 STW(暂停后所有缓存都冷了)
- 容器的 CPU 限流(被 throttle 后上下文全丢)
- 虚拟机的 vCPU 抢占
判断一个操作”贵不贵”,不能只看它的指令数,要看它破坏了什么。
一个重要的优化:PCID
现代 x86 支持 PCID(Process Context ID)——给 TLB 条目打上进程标签。
传统 TLB:
┌──────────────────────┐
│ 虚拟页 → 物理页 │
└──────────────────────┘
换 CR3 → 全部失效
带 PCID 的 TLB:
┌──────────────────────────┐
│ PCID=1 | 虚拟页 → 物理页 │
│ PCID=1 | 虚拟页 → 物理页 │
│ PCID=2 | 虚拟页 → 物理页 │ ← 不同进程的条目共存
└──────────────────────────┘
换 CR3 → 只切 PCID,旧的还留着
效果:进程切换后,如果切回之前的进程,TLB 还热着。
Linux 从 4.14 开始支持 PCID(但需要 CPU 支持,且默认在某些安全场景下关闭,因为 Spectre 漏洞)。
实测提升:上下文切换从 ~1500ns 降到 ~800ns,接近减半。
第四层:调度器
CFS:完全公平调度器
Linux 从 2.6.23 开始用 CFS(Completely Fair Scheduler)。
核心思想极其优雅:
想象一个”理想的多任务 CPU”:如果有 N 个进程,每个进程同时获得 1/N 的 CPU 算力。
现实中做不到。但我们可以追踪每个进程”应该得到多少”和”实际得到多少”的差值,优先运行亏欠最多的那个。
实现:
// 每个进程有一个虚拟运行时间 vruntime
struct sched_entity {
u64 vruntime; // 虚拟运行时间
// ...
};
// 进程运行时,vruntime 增加
// 增加的速率跟优先级成反比:
// 高优先级(nice 值小)→ vruntime 涨得慢 → 能跑更久
// 低优先级(nice 值大)→ vruntime 涨得快 → 很快就被换下
// 调度时:选 vruntime 最小的进程运行数据结构:红黑树
┌─────────────────────────────────────┐
│ 红黑树(按 vruntime 排序) │
│ │
│ [vruntime=105] │
│ / \ │
│ [vruntime=102] [vruntime=110] │
│ / \ \ │
│ [100] [103] [115] │
│ ↑ │
│ 最左节点 = vruntime 最小 = 下一个运行 │
└─────────────────────────────────────┘
- 选下一个进程:取最左节点,O(1)(缓存了
rb_leftmost指针) - 插入/删除:O(log N)
为什么用红黑树? 因为要在 O(log N) 内找到”最小值”,且需要频繁插入删除。红黑树是最平衡的选择(AVL 树查找更快但插入删除慢)。
CFS 的三个关键参数
# 1. 调度延迟(sched_latency):所有可运行进程至少跑一次的周期
cat /proc/sys/kernel/sched_latency_ns
# 默认 24000000 (24ms)
# 2. 最小粒度(sched_min_granularity):每个进程至少跑多久
cat /proc/sys/kernel/sched_min_granularity_ns
# 默认 3000000 (3ms)
# 3. 唤醒粒度(sched_wakeup_granularity):被唤醒的进程能否抢占当前进程
cat /proc/sys/kernel/sched_wakeup_granularity_ns
# 默认 4000000 (4ms)调度周期的计算:
如果有 N 个可运行进程:
每个进程的时间片 = max(sched_latency / N, sched_min_granularity)
N = 8: 24ms / 8 = 3ms → 每个进程 3ms
N = 100: 24ms / 100 = 0.24ms → 小于最小粒度,取 3ms
→ 实际周期变成 100 × 3ms = 300ms
这意味着:进程数很多时,延迟会上升。100 个 CPU 密集进程,每个要等 300ms 才能轮到一次。
老陈:这对 Go 程序员有实际意义。
如果你的机器上有 200 个 CPU 密集的进程/线程,Linux 的调度延迟会显著上升。这会让 P99 延迟变差,即使 CPU 使用率看起来不高。
这也是为什么很多高性能服务要绑核(CPU affinity)——把关键服务绑到专属的核上,避开调度延迟的不确定性。
容器的
cpuset就是干这个的。
第五层:Go 的 GMP 与 OS 调度的关系
两层调度
用户态(Go runtime 管理)
┌─────────────────────────────────────────────┐
│ G (goroutine) × 10000 │
│ ↓ Go runtime 调度 │
│ M (OS 线程) × N │
│ 绑定 P 才能执行 G │
│ P (逻辑处理器) × GOMAXPROCS │
└─────────────────────┬───────────────────────┘
│ M 就是普通的 OS 线程
内核态(Linux 管理)
┌─────────────────────▼───────────────────────┐
│ Linux CFS 调度 M 个线程到 8 个物理核 │
└─────────────────────────────────────────────┘
关键:Linux 完全不知道 goroutine 的存在。它只看到 M 个普通线程。
系统调用时发生了什么
这是 GMP 设计最精妙的地方:
func worker() {
data, err := os.ReadFile("/big/file") // 阻塞式系统调用
// ...
}执行流程:
① G1 在 M1 上执行,调用 read()
② M1 陷入内核,阻塞在磁盘 IO 上
③ Go runtime 检测到 M1 阻塞(通过 entersyscall 钩子)
④ ★ 把 M1 和它绑定的 P 解绑
⑤ 从线程池拿一个(或新建)M2,绑定到这个 P
⑥ P 继续调度其他 G 运行
⑦ 磁盘 IO 完成,M1 被唤醒
⑧ M1 尝试找回一个 P(没有就睡,或者把 G1 放到全局队列)
这个设计的意义:
一个 goroutine 阻塞在系统调用上,不会阻塞整个 P 上的其他 goroutine。
对比 Java:Java 的线程是 1:1 映射到 OS 线程的。一个线程阻塞在 IO,那个 OS 线程就废了(要等 IO 完成)。所以 Java 高并发必须用 NIO(非阻塞 IO)。
Go 可以用同步阻塞的写法,获得异步的性能。 这就是 Go 的”简洁性”来源。
老陈:这是 Go 最重要的设计优势之一。
小林:那 Go 是不是就不需要非阻塞 IO 了?
老陈:不完全对。 注意区分两种情况:
① 阻塞式系统调用(read/write 文件):上面的机制生效,runtime 会开新线程 ② 网络 IO:Go 用 netpoller(epoll/kqueue),根本不阻塞线程
网络 IO 走的是另一条路:
conn.Read() ↓ 尝试非阻塞读 ↓ 没数据 把 G 挂到 netpoller 上(epoll_ctl 注册 fd) ↓ G 变成 _Gwaiting 状态,M 去跑别的 G ↓ 数据到达 netpoller 收到 epoll 事件,把 G 标记为可运行 ↓ G 被调度继续运行所以网络 IO 不会创建新线程。 这也是为什么 Go 能轻松支撑百万并发连接——一个线程(netpoller)管所有连接。
什么时候 GOMAXPROCS 该调?
runtime.GOMAXPROCS(n)| 场景 | 建议 |
|---|---|
| CPU 密集 | = CPU 核数(默认)。调大没用,只会增加调度开销 |
| IO 密集(网络) | 可以远大于核数。因为 G 大部分时间在等,不在跑 |
| cgo 密集 | 要注意:cgo 调用会阻塞 M,且不计入 GOMAXPROCS |
| 容器环境 | Go 1.25+ 自动感知 cgroup 的 CPU limit |
老陈:历史上有个著名的坑:Go 1.5 之前 GOMAXPROCS 默认是 1,Go 1.5 之后改成 CPU 核数。但在容器里,
runtime.NumCPU()读到的是宿主机的核数,不是容器的 CPU limit。结果:一个限制 2 核的容器里,Go 程序以为有 64 核,创建 64 个 P,导致严重的调度抖动。
解决方案:
import _ "go.uber.org/automaxprocs" // 自动读 cgroup 设置或者 Go 1.25+ 原生支持。
这个坑的本质是”抽象泄漏”:容器提供了 CPU 限制的抽象,但
NumCPU()这个 API 泄漏了底层的真实核数。
动手实验:观察调度行为
实验 1:用 Go 观察 goroutine 的阻塞和唤醒
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
// 对比:CPU 密集 vs IO 密集,GOMAXPROCS 的影响
func benchmark(kind string, n int, work func()) time.Duration {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()
return time.Since(start)
}
func cpuWork() {
sum := 0
for i := 0; i < 5_000_000; i++ {
sum += i
}
_ = sum
}
func ioWork() {
// 模拟网络 IO:等待一个 channel
ch := make(chan struct{})
time.AfterFunc(10*time.Millisecond, func() { close(ch) })
<-ch
}
func main() {
numCPU := runtime.NumCPU()
fmt.Printf("CPU 核数: %d\n\n", numCPU)
fmt.Println("=== CPU 密集型(100 个任务)===")
for _, p := range []int{1, 2, 4, numCPU, numCPU * 4} {
runtime.GOMAXPROCS(p)
d := benchmark("cpu", 100, cpuWork)
fmt.Printf(" GOMAXPROCS=%-3d %v\n", p, d)
}
fmt.Println("\n=== IO 密集型(1000 个任务,每个等 10ms)===")
for _, p := range []int{1, 2, 4, numCPU} {
runtime.GOMAXPROCS(p)
d := benchmark("io", 1000, ioWork)
fmt.Printf(" GOMAXPROCS=%-3d %v\n", p, d)
}
}典型结果(8 核):
=== CPU 密集型(100 个任务)===
GOMAXPROCS=1 2.451s
GOMAXPROCS=2 1.238s
GOMAXPROCS=4 0.632s
GOMAXPROCS=8 0.328s ← 最优,之后不再提升
GOMAXPROCS=32 0.341s ← 略慢(调度开销)
=== IO 密集型(1000 个任务,每个等 10ms)===
GOMAXPROCS=1 0.103s ← 1000 × 10ms / 1 = 应该 10s?
GOMAXPROCS=2 0.052s
GOMAXPROCS=4 0.026s
GOMAXPROCS=8 0.014s
注意 IO 密集型的结果:1000 个任务各等 10ms,如果串行要 10 秒。但 GOMAXPROCS=1 时只用了 103ms——因为等待是并行的,不占 CPU。
这就是 goroutine 的威力:IO 等待不消耗 CPU,所以少量线程就能支撑海量并发。
实验 2:观察系统调用对 M 数量的影响
package main
import (
"fmt"
"os"
"runtime"
"runtime/trace"
"sync"
"time"
)
func main() {
// 开启 trace
f, _ := os.Create("/tmp/trace.out")
trace.Start(f)
defer func() {
trace.Stop()
f.Close()
fmt.Println("\n运行: go tool trace /tmp/trace.out")
}()
fmt.Printf("初始: NumCPU=%d GOMAXPROCS=%d\n", runtime.NumCPU(), runtime.GOMAXPROCS(0))
// 启动一个监控 goroutine,报告线程数
done := make(chan struct{})
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// runtime.NumGoroutine() 只有 goroutine 数
// 线程数要从 /proc/self/status 读
n := readThreadCount()
fmt.Printf(" 线程数=%-4d goroutine数=%-6d\n",
n, runtime.NumGoroutine())
case <-done:
return
}
}
}()
// 场景 1:大量阻塞式系统调用(读文件)
fmt.Println("\n场景 1: 100 个 goroutine 读大文件(阻塞式 syscall)")
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
f, _ := os.Open("/dev/zero")
defer f.Close()
buf := make([]byte, 1024)
f.Read(buf) // /dev/zero 不会阻塞,换个思路
time.Sleep(50 * time.Millisecond)
}()
}
wg.Wait()
// 场景 2:大量网络 IO(走 netpoller)
fmt.Println("\n场景 2: 1000 个 goroutine 做网络 IO")
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 模拟:等待一个 timer
time.Sleep(50 * time.Millisecond)
}(i)
}
wg.Wait()
// 场景 3:cgo 调用(阻塞 OS 线程)
fmt.Println("\n场景 3: 大量 cgo 调用")
// 这里省略实际的 cgo 调用,用 syscall 模拟
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
syscallThatBlocks()
}()
}
wg.Wait()
close(done)
time.Sleep(200 * time.Millisecond)
}
func readThreadCount() int {
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return -1
}
s := string(data)
for _, line := range splitLines(s) {
var n int
if _, err := fmt.Sscanf(line, "Threads:\t%d", &n); err == nil {
return n
}
}
return -1
}
func splitLines(s string) []string {
var res []string
cur := ""
for _, c := range s {
if c == '\n' {
res = append(res, cur)
cur = ""
} else {
cur += string(c)
}
}
return append(res, cur)
}
func syscallThatBlocks() {
// 用一个真正的阻塞系统调用
f, _ := os.Open("/dev/urandom")
defer f.Close()
buf := make([]byte, 1024*1024)
f.Read(buf) // 读 1MB,会有一段时间
}观察要点:
- 场景 1(阻塞 syscall):线程数会涨到 100 左右(每个阻塞的 syscall 一个线程)
- 场景 2(网络 IO / timer):线程数保持在 GOMAXPROCS 附近(不创建新线程)
- 场景 3(cgo):线程数暴涨,且不受 GOMAXPROCS 限制
这直接验证了前面的理论。
实验 3:用 perf sched 看真实的调度延迟
# 记录调度事件
sudo perf sched record -- ./your_program
# 查看每个进程的调度延迟分布
sudo perf sched latency
# 输出示例:
# -------------------------------------------------------------------------------------
# Task | Runtime ms | Switches | Average delay ms | Maximum delay ms
# -------------------------------------------------------------------------------------
# myapp:1234 | 1200.234 ms | 15234 | avg: 0.312 ms | max: 12.451 ms
# myapp:1235 | 980.123 ms | 12043 | avg: 0.289 ms | max: 10.234 ms
# ksoftirqd/0:12 | 2.341 ms | 341 | avg: 0.021 ms | max: 0.451 ms“Maximum delay” 是最重要的指标——它表示”这个进程最多等多久才被调度”。
如果 P99 延迟有问题,但 CPU 使用率不高,来看这个。 往往会发现 max delay 有几十毫秒——说明进程被别的进程挤掉了。
更深层的发问
问题 A:为什么 Go 不直接用 1:1 线程模型?
Java 用的是 1:1(一个 Java 线程 = 一个 OS 线程)。Go 用 M:N(M 个 goroutine 映射到 N 个 OS 线程)。
两种模型的对比:
| 1:1 (Java) | M:N (Go) | |
|---|---|---|
| 栈大小 | 默认 1MB(固定) | 2KB 起,动态增长 |
| 创建成本 | ~1-2 μs(系统调用) | ~0.2 μs(纯用户态) |
| 切换成本 | ~1-5 μs(进内核) | ~0.2 μs(用户态) |
| 最大并发数 | ~几千(受内存限制) | ~百万级 |
| 阻塞 syscall | 直接阻塞线程 | runtime 自动开新线程 |
| 调试 | 简单(线程就是线程) | 复杂(goroutine 不是 OS 概念) |
| 与 C 互操作 | 简单 | 复杂(cgo 有额外开销) |
Go 选择 M:N 的理由:
老陈:核心是”内存”和”创建成本”。
Java 的线程栈默认 1MB。10000 个线程 = 10GB。这在 2010 年代是致命的。
Go 的 goroutine 栈 2KB 起。100 万个 = 2GB。完全可以接受。
但代价是调度器复杂。 Go 的 runtime 要自己实现:
- work stealing 调度算法
- 栈的动态增长(连续栈,需要拷贝)
- 抢占(Go 1.14 之前是协作式,某些计算密集的循环会导致调度饥饿)
- netpoller
这是一笔交易:用 runtime 的复杂度,换取用户代码的简洁(可以写同步阻塞的代码)。
有意思的是 Java 后来也做了虚拟线程(Project Loom,Java 21 正式发布),用的也是 M:N 模型。这验证了 Go 当年的判断是对的。
问题 B:Go 1.14 之前为什么会出现”调度饥饿”?
// Go 1.13 及之前,这段代码会让调度器卡住
func main() {
runtime.GOMAXPROCS(1)
go func() {
// 这个 goroutine 可能永远得不到执行
fmt.Println("hello")
}()
for {
// 紧循环,没有函数调用,没有 IO
}
}原因:Go 1.14 之前是协作式调度——goroutine 只在特定时机让出 CPU:
- 函数调用时(检查栈是否需要增长,顺便检查是否该让出)
- channel 操作
- 系统调用
- 显式
runtime.Gosched()
上面的紧循环没有函数调用,所以永远不会检查,永远不会让出。
Go 1.14 引入了异步抢占(async preemption):
① 监控线程 sysmon 发现某个 G 运行超过 10ms
② 向运行它的 M 发送信号(SIGURG)
③ M 收到信号,中断当前执行
④ 在信号处理函数里,注入一个"让出"的调用
⑤ G 被抢占
这跟操作系统的时钟中断抢占,思路完全一样——只是 Go 用的是 Unix 信号而不是硬件中断。
老陈:注意这个递归性:
- OS 用硬件时钟中断抢占进程
- Go runtime 用 Unix 信号抢占 goroutine
同一个模式,在不同层次重复出现。 这就是我说的”统一法则”——当你在一个层次理解了一个模式,去找找它在其他层次的表现,往往能加深理解。
问题 C:如果让你给 Go 加一个”实时调度”支持?
Go 目前没有优先级调度——所有 goroutine 一视同仁。
假设你的场景需要:某些 goroutine 必须优先执行(比如实时音视频处理),不能被其他 goroutine 饿死。
老陈的提示:想想这几个方向——
- 多 P 隔离:用
runtime.LockOSThread()把关键 goroutine 绑到专属的 OS 线程和核上,再配合 OS 的SCHED_FIFO实时调度策略。这是最实际的方案。- 优先级队列:给 Go 的调度器加优先级。但这会破坏 work stealing 的简洁性,而且优先级反转问题很难处理。
- 分离进程:把实时任务放到独立的进程,用 OS 的实时调度。简单粗暴但有效。
- 时间片预留:给某类 goroutine 预留固定比例的 CPU 时间。
我的建议:大多数情况下,方案 1 或 3 就够了。 不要试图让一个通用调度器满足所有需求——用隔离代替优先级,这是分布式系统里”用资源隔离代替复杂调度”的同一个思路。
思考题 ·【应用层】
你的 Go 服务部署在 K8s 上,CPU limit = 2 核。服务 QPS 5000,P99 延迟 30ms,但 P999 有 800ms 的尖刺。CPU 使用率只有 60%。请分析可能的原因,给出诊断步骤和至少 3 个优化方案。
参考答案
现象的关键矛盾
CPU 使用率只有 60%,却有 P999 = 800ms 的尖刺。
如果 CPU 打满了,延迟高很正常。但 60% 的使用率意味着资源没用完,请求却在排队——这说明时间被”浪费”在了某个地方。
五个可能的原因
原因 1 · CFS 调度延迟 + 容器 CPU 限流(最常见)
K8s 的 CPU limit 是用 CFS bandwidth control 实现的:
cpu.cfs_period_us = 100000 (100ms 周期)
cpu.cfs_quota_us = 200000 (2 核 = 200ms 的 CPU 时间/周期)
关键机制:在每个 100ms 周期内,cgroup 最多用 200ms 的 CPU 时间(即 2 核满载)。用完了就 throttle——整个 cgroup 的所有线程都被暂停,直到下一个周期。
问题在这:
时间线(100ms 周期):
0ms ────────── 50ms ────────── 100ms
疯狂用 CPU 被 throttle
(2核×50ms=100ms) (什么都不做)
↑
这里有 50ms 的停顿!
平均使用率 = 100ms / 200ms = 50%,但实际有 50ms 是完完全全的停顿。
这就是”CPU 使用率不高但延迟尖刺”的经典原因。
诊断方法:
# 在容器里看
cat /sys/fs/cgroup/cpu.stat
# 或者 cgroup v2
cat /sys/fs/cgroup/cpu.stat
# 关键指标:
# nr_periods 12345 周期总数
# nr_throttled 892 ★ 被限流的周期数
# throttled_time 12345678901 ★ 被限流的总时间(纳秒)
# 如果 nr_throttled / nr_periods > 1%,说明有明显的限流K8s 也暴露了这个指标:
# Prometheus 查询
rate(container_cpu_cfs_throttled_periods_total{pod="myapp"}[5m])
/ rate(container_cpu_cfs_periods_total{pod="myapp"}[5m])解决方案:
方案 A:去掉 CPU limit,只保留 request
resources:
requests:
cpu: "2" # 保证能拿到 2 核
# limits:
# cpu: "2" # ← 去掉这个原理:request 保证下限,limit 设上限。去掉 limit 后,服务可以在空闲时用更多 CPU(突发),不会被 throttle。
代价:可能有”吵闹的邻居”问题——你的服务可能吃满整个节点。所以节点的整体使用率不能太高。
这是业界最常见的做法,也是很多公司的标准实践。
方案 B:调大 CFS 周期
# K8s 1.24+ 支持
cpuCFSQuotaPeriod: "200ms" # 默认 100ms原理:周期越长,“突发”的容忍度越高。200ms 周期内可以用 400ms CPU 时间,突发能力翻倍。
方案 C:让 CPU 使用更平滑 如果是 GC 或批处理任务导致的突发,把它们打散:
// ❌ 一次性处理 10000 条
process(allItems)
// ✅ 分批处理,每批之间让出 CPU
for i := 0; i < len(items); i += 100 {
process(items[i:i+100])
runtime.Gosched() // 或者 time.Sleep(0)
}原因 2 · GOMAXPROCS 与实际可用 CPU 不匹配
容器限制 2 核,但 runtime.NumCPU() 返回宿主机的 64 核。Go 创建 64 个 P。
后果:64 个 P 抢 2 核的 CPU 时间,调度抖动严重,而且被 throttle 时所有 P 一起停。
诊断:
fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))解决:
import _ "go.uber.org/automaxprocs" // 自动读取 cgroup或者 Go 1.25+ 原生支持。
原因 3 · GC 的辅助标记(GC Assist)
Go 的 GC 是并发的,但如果分配速度超过标记速度,runtime 会强制让分配者自己做一部分标记工作(GC assist)。
表现:某个请求的延迟突然飙高,而且它自己没做任何重活——它在替 GC 干活。
诊断:
GODEBUG=gctrace=1 ./app
# 看输出里的 assist time
# gc 1 @0.015s 4%: 0.031+2.9+0.015 ms clock,
# 0.50+1.2/2.6/0+0.24 ms cpu,
# 4->4->4 MB, 5 MB goal, 8 P
# ↑ 这个 1.2 是 assist time或者用 trace:
go tool trace trace.out
# 看 "Goroutine analysis" 里有没有大量时间在 GC assist解决方案:
// 1. 减少分配(最根本)
var pool = sync.Pool{New: func() any { return new(Buffer) }}
// 2. 调大 GOGC,减少 GC 频率
debug.SetGCPercent(200)
// 3. Go 1.19+ 用软内存上限
debug.SetMemoryLimit(1500 << 20) // 1.5GB原因 4 · 锁竞争导致的排队
表现:CPU 使用率不高(线程在睡眠等待锁),但延迟高。
诊断:
# block profile:看 goroutine 阻塞在哪
curl http://localhost:6060/debug/pprof/block?seconds=30 > block.prof
go tool pprof -http=:8080 block.prof
# mutex profile:看锁竞争
curl http://localhost:6060/debug/pprof/mutex?seconds=30 > mutex.prof
go tool pprof -http=:8080 mutex.prof注意:这两个 profile 默认是关闭的,要开:
runtime.SetBlockProfileRate(1) // 采样所有阻塞事件
runtime.SetMutexProfileFraction(1)解决方案:
- 用
sync.Map或分片减少锁竞争 - 用原子操作替代锁
- 用 lock-free 数据结构
- 缩短临界区
原因 5 · 跨 NUMA 节点或 CPU 缓存抖动
见第 01 节的小林事故。诊断:
perf c2c record -- ./app
perf c2c report诊断步骤(按优先级)
# 步骤 1:先看是不是 CPU 限流(最快能确认/排除)
cat /sys/fs/cgroup/cpu.stat | grep throttled
# 或者
kubectl exec -it pod -- cat /sys/fs/cgroup/cpu.stat
# 步骤 2:看 GOMAXPROCS
kubectl exec -it pod -- sh -c 'echo $GOMAXPROCS'
# 或者在程序里打日志
# 步骤 3:开 pprof
curl http://pod:6060/debug/pprof/profile?seconds=30 > cpu.prof
curl http://pod:6060/debug/pprof/block?seconds=30 > block.prof
curl http://pod:6060/debug/pprof/goroutine?debug=2 > goroutine.txt
# 步骤 4:看 GC
GODEBUG=gctrace=1
# 步骤 5:如果以上都正常,看调度延迟
kubectl exec -it pod -- sh -c 'cat /proc/sched_debug | grep -A5 "cfs_rq"'我的推荐顺序
- 先查 CPU throttling(最高概率,最容易修)
- 再查 GOMAXPROCS(一行代码修复)
- 再看 GC assist(pprof + gctrace)
- 最后查锁竞争和 NUMA
一句话总结
“CPU 使用率不高但延迟高”几乎总是意味着”时间在等待,不在计算”。
等待的对象可能是:
- CPU 时间片(被 throttle / 被调度)
- 锁
- IO
- GC
诊断的核心思路是”找出时间花在哪了”,而不是”猜测哪里慢”。 这跟第 01 节小林的教训完全一致——他查了一周代码,因为他在没有测量的情况下假设瓶颈在应用层。
小结:这一节你应该带走的东西
-
抢占式调度靠硬件时钟中断。用户态程序无法阻止被抢占,这是并发编程需要原子指令的根本原因。
-
进程和线程在 Linux 里是同一个东西的不同参数化(
clone的 flags)。这是”用参数化代替两套机制”的优雅设计。 -
上下文切换的真正代价是 TLB 失效,不是切换本身的指令。判断一个操作贵不贵,要看它破坏了什么状态。PCID 技术正是针对这一点。
-
CFS 用红黑树 + vruntime 实现”完全公平”。进程多时调度延迟会上升(受
sched_min_granularity限制)。 -
Go 的 GMP 是两层调度。关键优势:阻塞 syscall 会触发 M 和 P 解绑,不阻塞其他 goroutine;网络 IO 走 netpoller,根本不占线程。
-
异步抢占(Go 1.14+)用信号模拟了操作系统的时钟中断——同一个模式在不同层次的重复。
下一节
第 1 章到这里就结束了。我们会用 30 道题检验你到底掌握了多少,并给出继续深入的方向。
老陈的预告:这 30 道题里,有几道是我在真实面试中问过的。答不上来不可怕,可怕的是答不上来还以为自己懂了。