07 · 操作系统:进程、调度与系统调用

“200 个进程在 8 个核上’同时’运行——这是一场精心编排的魔术。“


开场:谁在骗它们?

老陈:你的机器上有多少个进程在跑?

小林:(打开任务管理器)……200 多个。

老陈:几个核?

小林:8 核。

老陈:200 个进程,8 个核。它们怎么”同时”运行?

小林:时间片轮转啊,每个进程跑一小段时间就切换。

老陈:“切换”是谁做的?进程自己愿意吗?

小林:……操作系统强制切换?

老陈:操作系统怎么”强制”?进程正在执行自己的指令,CPU 的 PC 寄存器指着它的下一条指令。OS 凭什么能插一脚?

小林:……中断?

老陈:对。但中断是硬件产生的。谁设置硬件产生中断?

小林:……时钟?

老陈:时钟中断。硬件每 1ms(可配置)产生一次中断,CPU 强制停下当前指令流,跳到内核预设的中断处理程序。 这是整个多任务系统的物理基础。

小林:所以进程是被”打断”的,不是”主动让出”的?

老陈:对。这就是抢占式调度(Preemptive Scheduling)的本质——进程不需要配合,OS 靠硬件中断强行夺回控制权。

小林:那如果进程正在做一个很重要的操作,被打断了怎么办?

老陈:关键问题。 你听说过”原子操作”吗?为什么需要它?

小林:……因为操作可能被打断?

老陈:对。但更精确地说:在用户态,你无法阻止被抢占。 你能做的只有:

  1. 用硬件提供的原子指令(LOCK 前缀的 CAS 等),让一个操作在硬件层面不可分割
  2. 进入内核态并关闭中断(只有内核能这么做)
  3. 用锁(但锁的实现本身依赖原子指令)

所以整个并发编程的大厦,建立在”硬件提供原子指令”这块基石上。 理解这一点,第 5 章讲分布式共识时你会看到同样的模式。


第一层:三个”执行体”的抽象

抽象谁管理切换成本隔离性共享什么
进程 (Process)内核~2-10 μs完全隔离(独立地址空间)几乎不共享
线程 (Thread)内核~1-5 μs共享地址空间堆、全局变量、文件描述符
goroutineGo 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 0x80syscall
保存的寄存器很多最少必要
是否查 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. 场景 1(阻塞 syscall):线程数会涨到 100 左右(每个阻塞的 syscall 一个线程)
  2. 场景 2(网络 IO / timer):线程数保持在 GOMAXPROCS 附近(不创建新线程)
  3. 场景 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 饿死。

老陈的提示:想想这几个方向——

  1. 多 P 隔离:用 runtime.LockOSThread() 把关键 goroutine 绑到专属的 OS 线程和核上,再配合 OS 的 SCHED_FIFO 实时调度策略。这是最实际的方案。
  2. 优先级队列:给 Go 的调度器加优先级。但这会破坏 work stealing 的简洁性,而且优先级反转问题很难处理。
  3. 分离进程:把实时任务放到独立的进程,用 OS 的实时调度。简单粗暴但有效。
  4. 时间片预留:给某类 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"'

我的推荐顺序

  1. 先查 CPU throttling(最高概率,最容易修)
  2. 再查 GOMAXPROCS(一行代码修复)
  3. 再看 GC assist(pprof + gctrace)
  4. 最后查锁竞争和 NUMA

一句话总结

“CPU 使用率不高但延迟高”几乎总是意味着”时间在等待,不在计算”。

等待的对象可能是:

  • CPU 时间片(被 throttle / 被调度)
  • 锁
  • IO
  • GC

诊断的核心思路是”找出时间花在哪了”,而不是”猜测哪里慢”。 这跟第 01 节小林的教训完全一致——他查了一周代码,因为他在没有测量的情况下假设瓶颈在应用层。


小结:这一节你应该带走的东西

  1. 抢占式调度靠硬件时钟中断。用户态程序无法阻止被抢占,这是并发编程需要原子指令的根本原因。

  2. 进程和线程在 Linux 里是同一个东西的不同参数化(clone 的 flags)。这是”用参数化代替两套机制”的优雅设计。

  3. 上下文切换的真正代价是 TLB 失效,不是切换本身的指令。判断一个操作贵不贵,要看它破坏了什么状态。PCID 技术正是针对这一点。

  4. CFS 用红黑树 + vruntime 实现”完全公平”。进程多时调度延迟会上升(受 sched_min_granularity 限制)。

  5. Go 的 GMP 是两层调度。关键优势:阻塞 syscall 会触发 M 和 P 解绑,不阻塞其他 goroutine;网络 IO 走 netpoller,根本不占线程。

  6. 异步抢占(Go 1.14+)用信号模拟了操作系统的时钟中断——同一个模式在不同层次的重复。


下一节

08 · 章节总结与能力自测

第 1 章到这里就结束了。我们会用 30 道题检验你到底掌握了多少,并给出继续深入的方向。

老陈的预告:这 30 道题里,有几道是我在真实面试中问过的。答不上来不可怕,可怕的是答不上来还以为自己懂了。