第 02 章 进程与线程:执行流的抽象与它的账单

上一章我们讲了”内存在哪”。这一章讲一个更根本的问题:“谁在用这些内存执行代码?”

答案有两个层次:进程是”资源的容器”,线程是”执行的流”。

这句话是整个并发世界的起点。但它不是免费的——每一次抽象,都有一张账单。


引子:同一个服务,两种死法

先给你看两个真实的线上事故,它们指向同一个问题,但死法完全不同。

事故 A: 一个 Go 服务,运行了三个月,突然在某个深夜 CPU 飙到 100%,所有请求超时。登录上去一看,top 显示负载 800,但是 CPU 用户态只有 5%,剩下 95% 都在内核态 sy。vmstat 显示每秒 150 万次上下文切换。

事故 B: 一个 Java 服务,压测时 QPS 卡在 8000 上不去,CPU 使用率 30%,但是每次请求的 P99 延迟高达 200ms。排查发现,每个请求内部要发 5 个下游调用,用 CompletableFuture 并发,但下游响应只要 20ms。剩下 180ms 花在哪了?

这两个事故,一个死于”上下文切换太多”,一个死于”线程池太小导致排队”。

它们的共同点是:都栽在了”执行流”这个抽象上。

进程和线程,是操作系统给”执行流”做的两次抽象。理解了这两次抽象的设计意图和账单,你就理解了:

  • 为什么 Go 能用几十 MB 内存跑 100 万个并发任务,而 Java 做不到
  • 为什么”线程池”这个看似简单的设计,调参是个玄学
  • 为什么协程、async/await、绿色线程这些概念,本质上是同一件事的三种叫法
  • 为什么你的服务会出现”CPU 没满但 QPS 上不去”

本章路线图

第一幕:为什么要拆成进程和线程
  Q1  一个"正在运行的程序"到底需要哪些东西?
  Q2  进程和线程,边界到底画在哪?
  Q3  单线程的进程不也挺好?为什么要引入线程?

第二幕:上下文切换 —— 抽象的账单
  Q4  一次上下文切换,到底切换了什么?
  Q5  切换的"隐藏账单"是什么?(缓存、TLB、分支预测)
  Q6  进程切换 vs 线程切换,代价差在哪?

第三幕:协程 —— 用一次切换换一次切换
  Q7  为什么协程能比线程轻一个数量级?
  Q8  Go 的 GMP 模型到底解决了什么问题?
  Q9  协程是万能的吗?什么时候线程反而更好?

第四幕:并发模型的代价
  Q10 100 万个 goroutine,为什么可能 OOM?
  Q11 cgo 调用为什么会拖垮 Go 调度器?
  Q12 线程安全到底在防什么?

第五幕:执行流的困境
  Q13 这套抽象面临哪些结构性的问题?

第一幕:为什么要拆成进程和线程

Q1 一个”正在运行的程序”到底需要哪些东西?

先把视角拉回最朴素的定义。假设你现在写了一个程序,main() 一执行,它就是个”正在运行的程序”了。

这个”正在运行的程序”,在操作系统眼里,需要哪些东西?

我列一个清单,你感受一下:

1. 一段代码(指令序列)—— 要执行的东西
2. 一块内存 —— 放数据的地方(堆、栈、全局变量)
3. 一组寄存器 —— 程序执行到一半的状态(PC、SP、通用寄存器)
4. 一些系统资源 —— 打开的文件、网络连接、信号处理器
5. 一个身份 —— PID,别人好找到它、管它、杀它
6. 一块"时间片" —— 从 CPU 那里分到的一点点执行时间

关键观察:这 6 样东西,性质是不一样的。

1、2、4、5 是相对静态的——代码不变,内存在一段时间内稳定,文件描述符是慢慢打开的,身份是固定的。它们描述的是”这是一个什么样的程序”。

3、6 是绝对动态的——寄存器每个时钟周期都在变,时间片是转瞬即逝的。它们描述的是”这个程序此刻执行到哪了”。

这就是进程和线程区分的根源:

进程 = 静态的那部分(资源容器):代码、内存、文件、身份 线程 = 动态的那部分(执行流):寄存器 + 执行时间

或者换个更形象的说法:

进程是一栋房子,线程是住在房子里的人。

  • 房子(进程):有地址(地址空间)、有家具(打开的文件)、有门牌号(PID)
  • 人(线程):在房子里走动(执行代码)、有自己站的位置(寄存器)、有一天的行程(指令流)

一栋房子可以只住一个人(单线程进程),也可以住一大家子(多线程进程)。一家人共享家具,但每个人站在不同的位置。

这个比喻的关键在于最后一句:多线程共享”家具”(内存、文件),但各自有”位置”(寄存器、栈)。

共享,是线程最大的优势,也是线程最大的噩梦。我们后面会反复回到这一点。

Q2 进程和线程,边界到底画在哪?

用技术语言把刚才的比喻说清楚。

进程独占的资源(线程不独占):

┌─────────────────────────────────────┐
│  进程 = 资源容器                      │
│                                     │
│  ┌───────────────────────────────┐  │
│  │  虚拟地址空间(页表)           │  │
│  │  打开的文件描述符表             │  │
│  │  信号处理器表                   │  │
│  │  进程控制块 PCB                │  │
│  │   (pid、状态、优先级、账户信息) │  │
│  └───────────────────────────────┘  │
│                                     │
│  ┌─────────┐  ┌─────────┐          │
│  │ 线程 T1  │  │ 线程 T2  │          │
│  │ ┌─────┐ │  │ ┌─────┐ │          │
│  │ │寄存器│ │  │ │寄存器│ │ ← 各线程独占│
│  │ │栈   │ │  │ │栈   │ │ ← 各线程独占│
│  │ └─────┘ │  │ └─────┘ │          │
│  │ 共享:堆、全局变量、文件、页表    │  │
│  └─────────┘  └─────────┘          │
└─────────────────────────────────────┘

Linux 的实现细节(这个细节很能说明问题):

在 Linux 内核里,“进程”和”线程”用的是同一个数据结构 task_struct。

内核眼里没有”线程”这个概念,只有”任务”(task)。区别只在于创建时用了什么参数:

// 创建进程:CLONE_SIGHAND 之外的大部分资源都复制
clone(CLONE_FS | CLONE_FILES | CLONE_VM | ...)   // 复制一套新资源
 
// 创建线程:共享几乎所有资源
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...)
//     ↑ 共享地址空间    ↑ 共享文件表     ↑ 共享信号处理器

看到没有?进程和线程的区别,不是”两种不同的东西”,而是”共享程度不同的同一类东西”。

CLONE_VM 共享地址空间、CLONE_FILES 共享文件表、CLONE_SIGHAND 共享信号处理、CLONE_THREAD 共享线程组 ID……

这个设计很有启发性:

与其发明两种抽象,不如发明一种抽象 + 一个”共享什么”的参数。

你在工程里也常看到这种选择。比如:

  • Go 的 context:一个接口 + 各种 withCancel/withTimeout/withValue 参数化变体
  • Docker 的容器:一个”进程” + --network=host/--pid=host/--ipc=host 等共享参数
  • git 的分支:本质上都是 commit 指针 + 不同的合并方式

“一种机制,多种策略”(mechanism, not policy)是 Unix 的核心设计哲学,这是它的第一个体现。

Q3 单线程的进程不也挺好?为什么要引入线程?

历史角度:最早的系统只有进程,没有线程。那为什么要引入线程?

理由一:共享数据的成本问题。

多个进程要协作,必须用进程间通信(IPC):管道、消息队列、共享内存、socket。这些都有成本——要么拷贝数据,要么需要显式地维护共享内存的同步。

而线程天然共享地址空间,协作就是读写同一个变量(当然要加锁)。对于”多个执行流紧密协作”的场景,线程的通信成本比进程低一个数量级。

理由二:等待 I/O 时的资源浪费。

考虑一个 Web 服务器:每个请求要读数据库。单线程的话,一个请求在等数据库返回(20ms),这 20ms 里整个进程什么也干不了——CPU 空转。

多进程的话,可以用多个进程处理请求。但每个进程都要一份完整的内存(代码、堆、栈),100 个进程就是 100 份代码段在内存里(虽然代码段可以共享,但堆、连接池、缓存都得各配一份)。

多线程的话,100 个线程共享一个进程的内存,只需要 1 份堆、1 份连接池、1 份缓存。省内存,也省去了进程间同步缓存的麻烦。

理由三:并行利用多核。

现代 CPU 是多核的。单线程进程只能用 1 个核。要多核并行,就需要多个执行流。线程是最轻量的”多个执行流”。

那代价是什么?

引入线程,等于把”资源隔离”这个进程的核心保证给破坏了一部分。线程之间没有隔离——一个线程写飞了指针,整个进程一起崩。

用那个房子比喻:进程之间的隔离是”墙”,线程之间没有墙。

  • 一个进程崩了,其他进程没事(墙挡住了)
  • 一个线程崩了(比如段错误),整个进程全崩(没有墙)

这就是为什么很多语言和框架开始重新审视线程模型,搞出了”绿色线程”、“协程”、“actor”这些在用户态重建隔离的方案。我们后面会讲到。

停下来想一个问题:线程共享内存是”特性”还是”缺陷”?

答案是:既是特性,也是缺陷。

特性的一面:协作方便,通信廉价。 缺陷的一面:一个 bug 会污染所有共享数据,而且这种污染是非确定性的——今天不出问题,明天出问题;本地不出问题,线上出问题。

理解了”共享是双刃剑”,你就理解了后面第 5、6、7 章所有关于锁、死锁、内存模型的内容。 它们都是在处理”共享”这个特性带来的问题。


第二幕:上下文切换 —— 抽象的账单

Q4 一次上下文切换,到底切换了什么?

现在我们来算这张账单。这是理解”为什么线程有开销”的关键。

上下文(context),就是一个线程”执行到一半的状态”。它包含:

1. 通用寄存器    rax, rbx, rcx, rdx, rsi, rdi, rbp, r8~r15
2. 程序计数器 PC  rip(下一条要执行的指令地址)
3. 栈指针 SP     rsp(当前栈顶)
4. 标志寄存器    rflags(进位、零、溢出等)
5. 浮点/向量寄存器  xmm0~xmm15(SSE/AVX,这个很贵!)
6. 特权级信息    (用户态/内核态的栈指针)

一次上下文切换的完整流程:

时间点 1:线程 A 正在运行
    │
    │  定时器中断(或者线程 A 主动让出,比如等 I/O)
    ↓
1. CPU 陷入内核(保存用户态上下文到内核栈)
2. 内核决定:切换到线程 B
3. 保存线程 A 的完整寄存器状态 → A 的内核栈
4. 切换到内核态栈指针(B 的内核栈)
5. 恢复线程 B 的寄存器状态
6. 更新 MMU 相关的状态(如果是进程切换,还要切页表)
7. 返回用户态,线程 B 继续运行
    │
时间点 2:线程 B 开始执行

直接成本(Direct Cost)是多少?

业界通用的测量:

线程切换(同进程):约 1-2 微秒(保存/恢复寄存器 + 内核调度逻辑)
进程切换(不同进程):约 3-5 微秒(额外要切页表、可能刷 TLB)

在 3GHz 的 CPU 上:
1 微秒 = 3000 个时钟周期
2 微秒 = 6000 个时钟周期

1-2 微秒听起来不多?那我们算一笔总账。

回到开头的事故 A:每秒 150 万次上下文切换。

1,500,000 次 × 1.5 微秒 = 2.25 秒

每秒花 2.25 秒在上下文切换上。 而机器可能只有 4 个核,总共每秒才 4 秒的 CPU 时间。

也就是说,一半以上的 CPU 时间被切换吃掉了,真正干活的时间不到一半。

这就是为什么”上下文切换多”是个严重的性能问题。

Q5 切换的”隐藏账单”是什么?(缓存、TLB、分支预测)

1-2 微秒只是”显性账单”。还有一张”隐性账单”,它可能比显性的贵 10 倍,而且无法直接测量。

隐性账单一:CPU 缓存失效。

线程 A 跑了一段时间,它的数据(热数据、栈、代码)都加载在 L1/L2/L3 缓存里。切换到线程 B 后,B 的数据是冷的,不在缓存里。

B 要重新加载它的数据,而加载的过程会不断踢掉 A 的数据。等 A 再回来,A 的数据又冷了。

A 运行 → A 的数据在缓存里(热)
切换到 B → B 的数据冷,开始加载,挤掉 A 的数据
B 运行 → B 的数据热了
切换回 A → A 的数据又冷了,重新加载,挤掉 B 的数据
...无限循环,缓存永远热不起来

这个现象叫 缓存抖动(cache thrashing)。

代价有多大?看数据:

L1 命中:    ~4 周期
L2 命中:    ~12 周期
L3 命中:    ~40 周期
主存命中:   ~200-300 周期

一次缓存失效,从 L1 掉到主存,代价是 50-75 倍。如果线程切换太频繁,几乎所有访问都在缓存失效,程序整体慢 5-10 倍。

隐性账单二:TLB 失效。

第 1 章讲过,TLB 是页表缓存。进程切换(注意:是进程切换,不是线程切换)会切页表,导致 TLB 失效。即使有 ASID 优化,冷启动的 TLB miss 也要几百个周期。

隐性账单三:分支预测器失效。

现代 CPU 的分支预测器记录着”这个分支历史上往哪边走”,它让 CPU 能提前取指、推测执行。线程切换后,分支预测器的状态是上一个线程的,对新线程来说全是错误预测。

一次分支预测失败,代价约 15-20 个周期(要冲刷流水线)。

隐性账单四:预测器的其他状态。

现代 CPU 还有数据预取器、内存访问模式学习器,全都依赖”连续执行同一个程序”这个前提。切换会重置这些学习状态。

把这些隐性账单加起来:

显性账单(寄存器保存/恢复):        1-2 微秒
隐性账单(缓存 + TLB + 分支预测):  5-10 微秒(取决于工作集大小)
总计:                              6-12 微秒

一个经验法则:一次上下文切换的真实成本,大约在 10 微秒这个量级。

这意味着什么?

如果线程切换之间的”有效工作时间”小于 10 微秒,那你就是在做负功——切换花的钱,比干活挣的多。

Q6 进程切换 vs 线程切换,代价差在哪?

刚才说了,线程切换 1-2 微秒,进程切换 3-5 微秒。差在哪?

差在”共享的东西越多,切换要动的越少”。

回想 Q2 的图:

切换类型要保存/恢复什么要刷新什么
线程切换(同进程)寄存器 + 栈几乎不用(地址空间、文件表都共享)
进程切换寄存器 + 栈 + 页表基址TLB(可能全刷)、L1/L2/L3(全冷)

进程切换多出来的部分:

1. 切换页表基址(CR3 寄存器)
   → 所有虚拟地址的翻译都要用新页表
   → 即使有 ASID,新进程的 TLB 条目大概率是冷的

2. 整个缓存都是冷的
   → 线程切换:两个线程可能共享一些热数据(全局变量)
   → 进程切换:两个进程的数据完全无关,缓存全冷

3. 可能触发调度器的额外工作
   → 跨 NUMA 节点迁移(如果换到了另一个 socket 的核)

所以进程切换的真实成本(含隐性账单)可能是线程切换的 2-5 倍。

这就是为什么”多线程”比”多进程”在频繁切换的场景下更优——但也正是为什么”线程不安全”的代价那么高。 少切换的代价,就是”共享带来的危险”。

一个反直觉的工程事实:

Nginx 用多进程(master-worker),Redis 用单线程(历史上),它们都故意避开了”多线程 + 共享状态”。

Nginx 的 worker 进程之间几乎不共享状态(通过共享内存和锁做少量同步),所以可以多进程跑满多核,又不用承受线程安全的复杂度。

Redis 单线程,直接不并发,彻底没有线程安全问题。

这两个例子说明:真正的架构高手,不是”会管理并发”,而是”会避免不必要的并发”。 减少共享状态,比管理共享状态高明一个层次。


第三幕:协程 —— 用一次切换换一次切换

Q7 为什么协程能比线程轻一个数量级?

先给数据:

线程栈:默认 1MB(Linux,可通过 ulimit -s 调),Java 默认 1MB,Go 线程栈 8MB(历史值)
goroutine 栈:初始 2KB,可增长到 1GB

线程切换:~1-2 微秒(要陷入内核)
goroutine 切换:~几十纳秒(纯用户态,就是改几个寄存器和栈指针)

10 万个线程:需要 100GB 栈内存,切换起来要几百微秒
10 万个 goroutine:几十 MB 内存,切换几十纳秒

差距为什么这么大?根本原因只有一个:

线程的切换要经过内核,协程的切换不经过内核。

展开说,有三层原因:

原因一:协程切换是纯用户态的”函数调用级”操作。

线程切换,要:

  1. 陷入内核(系统调用或中断)
  2. 内核调度器做决策(遍历运行队列、选下一个)
  3. 保存/恢复寄存器
  4. 返回用户态

而协程切换,只是用户态运行时做的:

// goroutine 切换的本质(极度简化)
func gosched() {
    // 1. 把当前 goroutine 的寄存器保存到它自己的栈上
    // 2. 换到下一个 goroutine 的栈
    // 3. 恢复那个 goroutine 的寄存器
    // 全程没有系统调用,没有陷入内核
}

没有内核参与,就没有特权级切换,就没有那一堆内核态的开销。

原因二:协程栈是按需增长的,线程栈是预留的。

线程栈 1MB 是预留的——即使你只用了几 KB,那 1MB 的虚拟地址空间也占着(物理页按需分配,但虚拟空间是预留的)。

goroutine 栈初始 2KB,用满了再翻倍增长。所以:

10 万线程 × 1MB = 100GB 虚拟内存预留
10 万 goroutine × 2KB = 200MB(假设都只用 2KB)

差了 500 倍。

原因三:协程是”协作式”调度,线程是”抢占式”调度。

线程可能在任何时刻被内核抢占(时间片到了),这需要内核维护复杂的状态。

协程是协作式的:一个 goroutine 只有在”让出点”(比如读 channel、等锁、runtime.Gosched())才会被切换。这让切换变得简单——你总是从一个”安全的点”切走。

但注意:“协作式”既是优点也是缺点。

优点:切换简单、快速。 缺点:一个 goroutine 如果写了死循环又不让出,会霸占整个 P(逻辑核),其他 goroutine 饿死。

Go 的解法是”抢占式调度”(1.14 引入异步抢占)——在 goroutine 运行超过 10ms 时,强制在安全的点(函数入口)插入抢占检查。这是”协作式为主 + 抢占式兜底”的混合策略。我们下一问细讲。

Q8 Go 的 GMP 模型到底解决了什么问题?

Go 的调度器是理解”协程怎么和线程、CPU 配合”的最好案例。它的核心是 GMP 三个字母:

G = Goroutine  —— 协程本身(你要跑的代码)
M = Machine    —— 操作系统线程(真正占用 CPU 的东西)
P = Processor  —— 逻辑处理器(调度上下文,G 和 M 之间的"调度器")

三者的关系:

        全局运行队列(G 的等待区)
              │
              │ 窃取
              ↓
┌─────┐   ┌─────┐   ┌─────┐
│  P  │   │  P  │   │  P  │    ← 每个 P 有自己的本地运行队列
└──┬──┘   └──┬──┘   └──┬──┘
   │         │         │
┌──▼──┐   ┌──▼──┐   ┌──▼──┐
│  M  │   │  M  │   │  M  │    ← 每个 M 绑定一个 P,占用一个 OS 线程
└──┬──┘   └──┬──┘   └──┬──┘
   │         │         │
┌──▼──┐   ┌──▼──┐   ┌──▼──┐
│ CPU0│   │ CPU1│   │ CPU2│    ← 真正执行
└─────┘   └─────┘   └─────┘

关键设计一:P 的数量 = GOMAXPROCS = 逻辑核数。

P 的数量是固定的(默认等于逻辑核数)。一个 M 必须绑定一个 P 才能运行 G。

为什么要有 P?

因为 M 是 OS 线程,可能因为系统调用而阻塞(比如读磁盘、time.Sleep)。如果一个 M 阻塞了,它绑定的 P 会解绑,P 去找一个新的 M(或者创建一个),这样其他 goroutine 不会因为一个线程阻塞而全部卡住。

没有 P 的世界(只有 G 和 M):
  一个 M 阻塞在系统调用 → 这个 M 上的所有 G 都卡住

有 P 的世界:
  M1 阻塞 → P 从 M1 解绑 → P 绑定 M2 → P 上的其他 G 继续跑
  ↑ 这就是为什么 Go 里一个 goroutine 做阻塞系统调用,不会拖垮所有 goroutine

关键设计二:本地运行队列 + work stealing(工作窃取)。

每个 P 有自己的本地运行队列(存 G)。新创建的 G 优先放到当前 P 的本地队列。

当一个 P 的本地队列空了,它去别的 P 的队列里偷一半 G 过来跑。

P0: [G1, G2, G3, G4, G5, G6]      P1: [](空了)
                                    │
                                    │ 从 P0 偷一半
                                    ↓
P0: [G1, G2, G3]                  P1: [G4, G5, G6]

为什么用窃取而不是全局队列?

因为全局队列需要加锁(所有 P 竞争一个锁),而本地队列无锁。窃取是”无锁的负载均衡”——大部分时候各 P 用自己的本地队列(无锁),只有空的时候才去偷(偶尔加锁)。

这个设计又回到了第 1 章说过的那个模式:分层 + 本地缓存 + 批量流转。

全局队列 → 中央(有锁),本地队列 → 每 P 缓存(无锁),窃取 → 批量流转(一次偷一半)。

你会在第 5 章锁、第 9 章缓存、以及无数高性能系统的设计里反复看到它。它是解决”多核共享资源”的标准答案。

关键设计三:阻塞系统调用的处理(netpoller)。

当一个 goroutine 发起阻塞的 I/O(比如读 socket),Go 不会真的让线程阻塞。它会:

1. 把 G 交给 netpoller(一个基于 epoll/kqueue 的异步 I/O 引擎)
2. G 被挂起,从 P 的队列移除
3. M 继续跑其他 G
4. I/O 就绪后,netpoller 把 G 唤醒,放回运行队列

所以 Go 里写阻塞式的代码(conn.Read()),底层是异步的。这就是为什么 Go 能用很少的线程(M 数量 = 核数)支撑几十万个并发连接——大部分 goroutine 都在 netpoller 里等着,不占线程。

关键设计四:syscall 的处理。

conn.Read() 能被 netpoller 接管,是因为它底层是 epoll。但真正的阻塞系统调用(比如读一个没有异步化的设备、os.File.Read 读普通文件)不行。

处理方式是:

1. M 发起阻塞 syscall
2. M 和 P 解绑(P 去找别的 M 或新建 M)
3. M 阻塞在 syscall 里
4. syscall 返回,M 尝试重新找一个空闲的 P
   - 找到了 → 继续
   - 没找到 → G 放回全局队列,M 休眠

这就是为什么 os.File.Read(读普通文件)比 net.Conn.Read(读 socket)更容易造成线程数量膨胀。 读普通文件不能异步化,每个阻塞的读都会占用一个 M,M 不够了就新建 OS 线程。

这也是为什么 Go 不适合做”大量并发读普通文件”的场景(除非用 io_uring,Go 1.25 之后才逐步支持)。而 socket I/O 是 Go 的主场,因为 netpoller 能完全异步化。

Q9 协程是万能的吗?什么时候线程反而更好?

协程不是银弹。它有几个真实的短板:

短板一:协程需要运行时支持,不是所有语言都有。

C、C++ 没有原生协程(C++20 的 coroutine 是编译器层面的,很别扭)。Java 的虚拟线程(Loom,JDK 21)是后来才加的。Rust 的 async 是”显式状态机”模型,写起来心智负担大。

在这些语言里,线程仍然是主力。

短板二:协程的”轻”依赖”栈按需增长”,而栈增长有代价。

goroutine 栈用满了要增长(翻倍 + 搬移)。如果一段代码在栈边界反复横跳,会频繁触发栈增长,反而变慢。

而且栈增长是”连续栈”方案。早期 Go 用”分段栈”(分段栈有热分裂问题),后来改成连续栈。连续栈的搬移要复制整个栈 + 修正所有指向栈的指针,这个操作在深栈时也不便宜。

短板三:协程的调度策略是通用的,无法针对特定负载优化。

Go 的调度器对所有 goroutine 一视同仁。但你的服务里,可能”处理用户请求的 goroutine”和”后台批处理的 goroutine”优先级完全不同。通用调度器无法表达这种优先级。

(线程也一样有这个问题,但有 nice 值、实时优先级等。Go 目前没有 goroutine 优先级,只能靠 runtime.GOMAXPROCS 和手动限流来间接控制。)

短板四:协作式调度的”公平性”问题。

协程只有到让出点才会切换。一个 goroutine 里如果有段没有让出点的长循环(比如纯计算的 for 循环),会霸占 P。Go 1.14 的异步抢占缓解了,但抢占点只在”函数调用边界”,一段超长的无函数调用循环(内联展开后)仍可能延迟抢占。

短板五:跨语言的 FFI(cgo)问题。

这是协程(以及所有用户态调度)的固有缺陷:OS 线程看不到你的协程,但 cgo 调用要用 OS 线程。

一个 goroutine 调 cgo,它绑定的 M(OS 线程)可能被 C 代码阻塞,导致 P 失联。我们下一问详细讲。

那么什么时候线程反而更好?

适合线程(或混合):
  - 计算密集型、没有 I/O 等待的(协程的"轻量切换"优势用不上)
  - 需要精确控制调度优先级的(实时系统)
  - 大量 cgo / FFI 调用的
  - 语言没有好的协程支持(C/C++)

适合协程:
  - 大量 I/O 等待的(网络服务)→ 协程主场
  - 需要海量并发任务(10 万+)的
  - 任务生命周期短、切换频繁的

一句话:协程解决的是”高并发 + 大量 I/O 等待”,线程解决的是”真并行 + 精确控制”。

它们不是替代关系,是分工关系。Go 的策略其实是”协程 + 少量线程”(GMP 里 M 就是线程),用线程数 = 核数,让每个核都满载,用协程数 = 任务数,让每个 I/O 等待都不浪费线程。


第四幕:并发模型的代价

Q10 100 万个 goroutine,为什么可能 OOM?

先纠正一个流传很广的错误说法:“Go 可以轻松开 100 万个 goroutine”。

能开,但要看每个 goroutine 干什么。

算一笔账,100 万个 goroutine 的内存开销:

1. 栈:每个 goroutine 初始 2KB
   1,000,000 × 2KB = 2GB

2. 如果每个 goroutine 用了稍微多一点栈(比如 8KB)
   1,000,000 × 8KB = 8GB

3. goroutine 本身的结构体(g 结构)约 300-500 字节
   1,000,000 × 400B = 400MB

4. 如果每个 goroutine 还持有引用(闭包、channel、变量)
   这部分无上限

所以:

  • 100 万个”空转”的 goroutine(go func(){ select{} }()):约 2-3GB,能开
  • 100 万个”干活的” goroutine(每个处理一个请求、读 socket、持有 1MB 数据):直接 OOM

为什么会 OOM?因为栈会增长。

goroutine 栈初始 2KB,用满了翻倍:2KB → 4KB → 8KB → … → 最大 1GB。

一个 goroutine 处理 HTTP 请求:
  - 解析请求 → 栈涨到 16KB
  - 读 body → 涨到 64KB
  - 做 JSON 序列化 → 涨到 256KB
  - 调用一个有深递归的库 → 涨到 1MB

100 万个 goroutine,平均栈 256KB,就是 256GB。任何机器都扛不住。

更隐蔽的 OOM 来源:goroutine 泄漏。

func handler() {
    ch := make(chan int)
    go func() {
        // 等一个永远等不到的信号
        v := <-ch
        process(v)
    }()
    // ch 没关闭,goroutine 永远阻塞
    // 每次请求泄漏一个 goroutine,一天就是几百万个
}

goroutine 泄漏的可怕之处在于:它不会立刻 OOM,而是慢慢累积,直到某天服务突然 OOM,你回滚、重启,以为是偶发,结果过几天又 OOM。

诊断方法:

// 看 goroutine 数量
import _ "net/http/pprof"
// curl http://localhost:6060/debug/pprof/goroutine?debug=1
// 数一下有多少个,以及它们都卡在哪个调用栈
 
// 或者运行时获取
fmt.Println(runtime.NumGoroutine())
 
// 看 goroutine 的栈
// curl http://localhost:6060/debug/pprof/goroutine?debug=2
// 会打印每个 goroutine 的完整栈,一眼就能看出谁在泄漏

一个真实的判断标准:goroutine 数量应该和”活跃请求数”成正比。

如果你的 QPS 是 1000,平均处理时间 100ms,那同时活跃的请求数 = 1000 × 0.1 = 100 个。goroutine 数量应该在几百这个量级。

如果 goroutine 数量远大于活跃请求数,就是泄漏。

工程建议:监控 goroutine 数量,把它当核心指标,和 QPS、内存、GC 一起看。goroutine 数量异常增长,往往比内存异常增长更早暴露问题。

Q11 cgo 调用为什么会拖垮 Go 调度器?

cgo 是 Go 调用 C 代码的机制。它是个好东西,但也是个著名的坑。

问题一:cgo 调用要占用 OS 线程(M)。

GMP 模型里,M 的数量默认最多 10000(runtime/debug.SetMaxThreads 可调,默认 10000),但实际会被系统资源限制。

当一个 goroutine 发起 cgo 调用:

1. 这个 goroutine 被"钉"在它当前的 M(OS 线程)上
2. M 进入 C 代码执行(Go 调度器管不了 C 代码)
3. 如果 C 代码阻塞(比如 C 库里有个慢 I/O)
   → 这个 M 被阻塞
   → 如果此时 P 没有空闲 M,P 会新建一个 M
   → 新建 M 需要创建 OS 线程(约 20-50 微秒 + 8MB 栈预留)

如果大量 goroutine 同时调 cgo,且 C 代码阻塞,Go 会疯狂创建 OS 线程,每个 8MB 栈(Go 的 M 栈),1000 个就是 8GB,而且创建线程本身有开销。

问题二:cgo 调用的固定开销。

一次 cgo 调用的成本远高于一次纯 Go 调用:

纯 Go 函数调用:    约 1-5 纳秒(一次 call 指令)
cgo 调用:          约 50-200 纳秒(要切换栈、保存状态、可能进内核)

贵在哪?

  1. 栈切换:Go 的 goroutine 栈和 C 的栈不同,cgo 要切换到 C 的栈(一般用 M 的系统栈)
  2. 保存 Go 运行时状态:要记录 goroutine 的调度信息
  3. 可能触发系统调用:某些 cgo 操作需要进内核

所以 cgo 只适合”调用成本 >> 切换成本”的场景(比如一次 C 调用处理几百微秒的数据)。如果频繁调用轻量 C 函数,cgo 开销会吃掉所有收益。

问题三:cgo 阻塞会阻塞整个 P 吗?

不会完全阻塞 P(P 会去找新 M),但如果 C 代码长时间阻塞,会导致:

大量 goroutine 都卡在 cgo 里
→ M 数量膨胀到上限(10000)
→ 新 goroutine 没有 M 可用
→ 即使这些 goroutine 本来可以做纯 Go 的工作,也被卡住
→ 整个服务"假死"

这是一个经典的”cgo 滥用事故”:一个服务用 cgo 调了某个有慢 I/O 的 C 库,高峰期大量请求都卡在 C 库里,M 数量涨到 1 万,然后所有请求都进不来了。

问题四:cgo 破坏内存隔离。

Go 的 GC 假设它能自由移动对象(否则怎么做并发标记和压缩)。但 C 代码拿到 Go 对象的指针后,GC 不知道这个指针的存在,可能会:

  1. 把这个对象回收了(C 还在用)→ 野指针
  2. 移动了这个对象(C 的指针失效)→ 悬空指针

所以 cgo 有个规则:C 代码持有的 Go 指针,必须通过 C.CBytes 复制一份,Go 对象和 C 对象完全隔离。

这又增加了一次拷贝的成本。

总结:cgo 的四大坑:

  1. 占用 OS 线程(M 膨胀)
  2. 固定调用开销(50-200ns)
  3. 阻塞不可控(调度器管不了 C 代码)
  4. 破坏内存模型(指针要复制)

所以:能用纯 Go 就别用 cgo。 如果必须用(比如调 RocksDB、OpenSSL、图像库),把它隔离在少数几个 goroutine 里,通过 channel 和主逻辑通信,而不是让每个请求都直接调 cgo。

Q12 线程安全到底在防什么?

这个问题看似简单,但很多 5 年经验的工程师也说不清楚”线程不安全”到底是怎么发生的。

先看一个具体的例子:

var counter int
 
func increment() {
    counter++    // 看起来是一行,实际是三步
}

counter++ 编译后不是原子操作,是三步:

MOVQ  counter(%rip), AX    ; 1. 读 counter 到寄存器 AX
ADDQ  $1, AX              ; 2. AX = AX + 1
MOVQ  AX, counter(%rip)   ; 3. 写回 counter

两个 goroutine 同时执行 increment():

时间    goroutine A          goroutine B          counter
─────────────────────────────────────────────────────────
t1     读 counter = 0                             0
t2     读 counter = 0                             0
t3     AX = 0 + 1 = 1                             0
t4     AX = 0 + 1 = 1                             0
t5     写回 counter = 1                            1
t6     写回 counter = 1                            1

两个 goroutine 各加了 1 次,counter 应该等于 2,结果等于 1。

这就是竞态条件(race condition):多个线程并发访问共享数据,且至少一个是写,结果取决于执行的时序。

线程安全要防的,本质是三件事:

1. 竞态条件(race condition):
   多个操作的相对顺序不确定 → 结果错误
   解法:原子操作、锁

2. 可见性(visibility):
   线程 A 改了变量,线程 B 看不到(缓存不一致)
   解法:内存屏障、volatile、锁的 acquire/release

3. 重排序(reordering):
   编译器/CPU 打乱了代码顺序,导致中间状态被观察
   解法:内存屏障、锁

注意:很多人只知道第 1 个,不知道第 2、3 个。 而第 2、3 个更隐蔽,因为它们在某些平台(x86)上几乎不出现,在另一些平台(ARM)上频繁出现。你在 x86 上测不出 bug,不代表代码是对的。

第 7 章会专门讲内存模型和重排序,这里先埋个伏笔。

Go 的检测工具:

# 用 -race 标志跑测试/程序
go test -race ./...
go run -race main.go
 
# race detector 会在运行时检测竞态,报出:
# WARNING: DATA RACE
# Read at 0x00c000... by goroutine 7:
#   main.increment() ...
# Previous write at 0x00c000... by goroutine 6:
#   main.increment() ...

注意:-race 只能检测”实际发生了的”竞态,如果你的测试没跑出那个交错时序,它检测不到。所以 race 检测通过 ≠ 没有竞态,只是”这次没跑出来”。


第五幕:执行流的困境

Q13 这套抽象面临哪些结构性的问题?

进程/线程/协程这套抽象,有几个从根上解决不了的问题:

问题一:线程数量 vs 核数的矛盾。

这是最根本的。你有 N 个核,理论上 N 个线程并行最理想(不多不少)。但现实里:

  • 线程多了 → 上下文切换浪费
  • 线程少了 → I/O 等待时核空闲

所以你必须猜测一个”合适的线程数”,而这个数取决于负载性质(I/O 密集 vs 计算密集),负载一变,最优值就变。

这就是为什么”线程池大小”是个玄学问题。业界流传着各种公式:

CPU 密集型:线程数 = 核数 + 1
I/O 密集型:线程数 = 核数 × (1 + 等待时间/计算时间)

但”等待时间/计算时间”这个比值,你通常测不准,而且它会随流量变化。

Go 的 GMP 部分解决了这个问题:M(线程)数 ≈ 核数,G(协程)数 = 任务数,用”大量轻量协程 + 少量线程”来解耦”任务数”和”线程数”。

但即使是这样,GOMAXPROCS 该设多少、P 的数量要不要留余量,仍然是个需要调的参数。

问题二:隔离 vs 共享的永恒权衡。

  • 进程:隔离强,共享难(IPC 成本高)
  • 线程:共享易,隔离无(一个线程崩全崩)
  • 协程:共享易,隔离更弱(用户态,连内核都感知不到)

这个光谱上,任何方案都是”往一边靠”。 你要么付出通信成本(进程),要么承受崩溃风险(线程/协程)。

现代的趋势是往中间靠:

  • Erlang/Elixir 的 actor:进程模型 + 极轻量的用户态进程(BEAM 虚拟机上的”进程”),既隔离又轻
  • Rust 的所有权系统:编译期保证”没有数据竞争”,把隔离做到类型系统里
  • 微服务:把”进程隔离”放大到”机器隔离”,用网络通信成本换崩溃隔离

问题三:调度公平性的不可判定。

调度器要回答”下一个该跑谁”,但”谁更重要”这个信息,操作系统不知道。它只知道一些间接信号(优先级、nice 值、运行时间)。

结果是:通用调度器永远无法针对你的业务做最优调度。 它不知道”处理 VIP 用户请求的线程”比”后台清理任务的线程”重要。

这正是为什么很多高性能系统要自己管理执行流:

  • 游戏引擎自己写主循环和任务系统
  • 数据库自己管理后台线程(刷盘、compaction)
  • Go 的 runtime 自己调度 goroutine

当通用机制无法表达你的领域知识时,就自己接管。 但代价是你失去了通用机制的便利(比如内核帮你处理了抢占、公平、信号)。

问题四:栈——这个抽象的最深层问题。

栈是”执行流”最隐秘的代价。它的核心矛盾:

栈要小:   省内存,但容易溢出
栈要大:   不会溢出,但浪费内存(10 万线程 × 1MB = 100GB)

栈要连续: 访问快(局部性好),但增长要搬移
栈要分段: 增长灵活,但有"热分裂"问题

栈要静态: 编译器能精确算偏移(快),但不能动态扩展
栈要动态: 能按需增长,但访问慢(间接寻址)

C/C++ 的栈溢出是安全漏洞(栈上缓冲区溢出可以覆盖返回地址,这就是经典的缓冲区溢出攻击)。Go 用”可增长栈 + 栈边界检查”避免了溢出,但付出了每次函数调用都要检查栈边界的成本(morestack 检查)。

而 async/await(Rust)和回调(Node.js)干脆不用栈——它们把”执行到一半的状态”显式地存在堆上(状态机)。

Go 的 goroutine:  状态隐式存在栈上(栈会增长,状态在栈里)
Rust 的 async:    状态显式存在堆上(编译器把 async fn 变成状态机)
Node 的回调:      状态显式存在闭包里

这三种方案,本质是”执行流的状态存在哪”这个问题的三种答案。 各有代价:

  • 栈:自动、快,但栈大小不确定、增长有成本
  • 状态机:确定、可序列化(这是异步可取消、可恢复的基础),但写起来痛苦(“回调地狱”、“Pin 地狱”)
  • 闭包:灵活,但堆分配多、GC 压力大

问题五:执行流的”可观测性”困境。

进程/线程可以用 ps、top、pidstat 观察。但协程不行——内核不知道 goroutine 的存在,你用 top 看不到每个 goroutine 的状态。

所以 Go 的协程调试只能靠 Go 自己的工具(pprof、runtime 指标),这跟内核提供的观察手段是割裂的。

这是个普遍规律:抽象加得越深,可观测性越差。

  • 进程:ps 全能看
  • 线程:top -H 能看
  • goroutine:只能靠 Go 的 pprof
  • async task:只能靠语言的 tracing

你每加一层抽象,都要配套一层”观察这层抽象的工具”,否则出问题就是睁眼瞎。第 20 章讲可观测性时会回到这个主题。


思考题

思考题 1

面试官问:“Go 的 goroutine 和操作系统的线程是什么关系?goroutine 是跑在线程上的吗?“请用 GMP 模型完整回答,并说明 GOMAXPROCS 的作用和它设大了/设小了的后果。

参考答案

一句话:goroutine 是用户态的执行流,它必须被调度到一个 OS 线程(M)上、绑定一个逻辑处理器(P)才能真正占用 CPU 运行。

完整关系(GMP):

G(goroutine):用户态的协程,只存在于 Go 运行时里,内核看不到它
M(machine):  OS 线程,内核调度的最小单位
P(processor):逻辑处理器,Go 调度器的"调度上下文",数量 = GOMAXPROCS

“goroutine 是跑在线程上的吗?” —— 是,但中间隔了一层 P。

一个 goroutine 要运行,必须满足:G 被调度到某个 M 上,且这个 M 绑定了一个 P。执行时,CPU 上跑的是 M(OS 线程),M 上跑的是当前的 G。

P 的作用(这是关键,很多人答不上来):

P 解决的核心问题是”M 会因为阻塞而卡住,但 G 不应该一起卡住”。

  • 当 M 执行阻塞系统调用(比如读一个不能异步化的文件)时,M 会阻塞在系统调用里
  • 此时 P 会主动和这个 M 解绑,去找另一个空闲的 M(或者新建一个 M)
  • 于是 P 本地队列里的其他 G 可以继续在别的 M 上运行

如果没有 P,G 直接绑 M,那 M 阻塞时,M 上的所有 G 都被卡住,Go 的”高并发”优势就没了。

GOMAXPROCS 的作用:

它决定 P 的数量,也就是同一时刻最多有多少个 goroutine 在真正并行执行(一个 P 绑定一个 M,占一个核)。

默认值 = 机器的逻辑核数。

runtime.GOMAXPROCS(4)    // 或者环境变量 GOMAXPROCS=4

设大了的后果:

GOMAXPROCS = 128(但机器只有 16 核)

1. P 数量 = 128,M 数量最多到 128
2. 128 个线程抢 16 个核 → 每个核上 8 个线程来回切换
3. 上下文切换成本暴涨(前面算过,每次 ~10 微秒)
4. 缓存抖动严重(8 个线程轮流用 1 个核,缓存永远热不起来)
5. 结果:吞吐不升反降,甚至比 GOMAXPROCS=16 还差

典型表现:CPU 使用率 100%,但 QPS 反而下降

设小了的后果:

GOMAXPROCS = 4(但机器有 16 核)

1. 只有 4 个 goroutine 能并行,其余 12 个核空转
2. 计算密集的任务:吞吐直接降到 1/4
3. I/O 密集的任务:影响小一点(因为大部分时间在等 I/O,不占 P)
   → 但如果 I/O 返回后的处理逻辑重,还是会有影响

正确的设置原则:

默认(= 核数)在大多数情况下就是最优的。

特殊情况:
1. 容器里:必须设置,因为容器里的"核数"可能是宿主机的核数
   (Go 1.24 之前 runtime.NumCPU() 会读到宿主机核数,不是容器配额)
   → 用 automaxprocs 库,或者 Go 1.25+ 的运行时自动感知 cgroup

2. 需要给其他进程留核:机器上还跑着别的服务
   → GOMAXPROCS = 核数 - 预留核数

3. 极端 I/O 密集且 CPU 使用率很低:
   → 可以适当调大(因为 P 经常空闲,多几个 P 能提高 I/O 并行度)
   → 但一般不建议,收益有限,风险(切换成本)真实

4. 实时性要求高、要降低延迟抖动:
   → 可以调小,减少抢占,让每个 goroutine 连续跑更久

追问:为什么容器里必须处理 GOMAXPROCS?

容器通过 cgroup 限制 CPU(--cpus=2),但 runtime.NumCPU() 读的是 /proc/cpuinfo,返回的是宿主机核数(比如 64)。

如果 Go 服务在 2 核配额容器里,GOMAXPROCS 默认是 64:

1. Go 认为有 64 个 P,创建 64 个 M
2. 64 个线程抢 2 个核的配额
3. cgroup 会限制 CPU 使用(cpu.cfs_quota_us)
4. 结果是:上下文切换暴增 + CPU 节流(throttling)
5. 表现:延迟抖动严重,P99 飙升

解法:

# 1. 用 uber 的 automaxprocs(自动读 cgroup 配额)
import _ "go.uber.org/automaxprocs"
 
# 2. 手动设置
GOMAXPROCS=2
 
# 3. Go 1.25+ 会自动感知(运行时内置了 cgroup 读取)

这道题的加分项:

能说出”P 的存在是为了在 M 阻塞时保住 G 的调度”,说明你理解了 GMP 的设计意图,而不只是背了三个字母。

能说出”GOMAXPROCS 设大导致上下文切换和缓存抖动”,说明你理解了第 1、2 章的代价模型。

能主动提到”容器里的核数陷阱”,说明你有真实的线上经验。


思考题 2

你的服务每个请求需要调用下游服务,下游 P50 = 20ms,P99 = 200ms。你的服务是 CPU 密集的(处理逻辑重),部署在 8 核机器上。你选择:A)每个请求一个 goroutine,不设上限;B)固定 1000 个 worker 的 goroutine 池 + 有界队列。分析两种方案的优劣,特别是下游变慢(P99 从 200ms 涨到 2s)时的表现。

参考答案

这是一道经典的”无界并发 vs 有界并发”题。核心不是选 A 还是 B,而是理解**“并发度”和”吞吐/延迟/稳定性”的关系**。

先明确关键事实:

利特尔法则(Little's Law):
并发中的请求数 = 到达率 × 平均处理时间

如果下游 P50=20ms,你的服务处理一个请求(含等下游)平均 30ms,
到达率 10000 QPS:
  并发数 = 10000 × 0.03 = 300 个请求同时在处理

方案 A:每个请求一个 goroutine,不设上限。

优点:
  - 代码简单,天然并发
  - 理论上并发度 = 请求数,不会因为池子太小而排队

缺点(下游变慢时爆发):
  下游 P99 从 200ms 涨到 2s(慢了 10 倍)
  → 每个请求占着 goroutine 的时间从 30ms 涨到 2s
  → 并发数 = 10000 × 2 = 20000 个 goroutine 同时挂着
  → 每个 goroutine 有栈(涨到几十 KB)+ 请求上下文 + 缓冲
  → 20000 × 100KB = 2GB 内存(还能扛)
  → 但关键问题:20000 个 goroutine 都在等同一个变慢的下游
  → 下游的容量是有限的(比如连接池 500 个连接)
  → 20000 个请求抢 500 个连接 → 大量排队 → 超时 → 重试 → 雪崩

方案 B:固定 1000 个 worker + 有界队列。

优点(下游变慢时):
  - 并发度有上限(1000),不会无限制堆积
  - 内存可控:最多 1000 个 goroutine 挂着
  - 下游压力可控:最多 1000 个在途请求

缺点:
  - 队列满了怎么办?必须回答这个问题(拒绝?等待?降级?)
  - 队列等待本身有延迟
  - 池子大小怎么定?(这是个玄学问题)

关键区别在下游变慢时暴露:

                    方案 A(无界)              方案 B(有界 1000)
─────────────────────────────────────────────────────────────
下游正常时             都很好                     都很好(队列基本空)
下游变慢(10倍)       并发 300 → 20000           并发 300 → 封顶 1000
                      内存 1GB → 5GB+            内存封顶
                      下游被打爆(2万个连接)      下游最多 1000 在途
                      超时重试放大流量            超时后队列快速排空
                      最终雪崩                    可控降级

方案 A 的致命点:无界并发 + 重试 = 雪崩放大器。

下游变慢 → 大量请求堆积 → 这些请求纷纷超时 → 触发重试 → 重试又产生新请求 → 下游更慢 → 更多超时重试。正反馈循环,直到下游彻底被打死。

方案 B 的价值不在”更快”,在”可控”。

有界队列的本质是背压(backpressure):当处理不过来时,让上游慢下来(排队或拒绝),而不是无限堆积。这是第 16 章的主题。

那方案 B 的队列满了怎么办?这是必答题,答案是分层降级:

1. 队列快满(比如 80%):
   → 开始拒绝低优先级请求(返回"稍后重试")
   → 或者用异步处理(先 ack,后台慢慢做)

2. 队列满了:
   → 快速失败(fail fast),返回 503
   → 让上游(nginx/网关/调用方)做重试和熔断

3. 为什么要快速失败而不是无限等待?
   → 因为无限等待的请求,最终还是会超时
   → 而且它占着资源(goroutine、内存、连接),让系统更慢
   → "快速失败"让资源尽快释放,是过载时最正确的策略

更好的方案:有界并发 + 快速失败 + 熔断。

其实业界的最佳实践是结合体:

1. 用信号量限制"在途请求数"(有界并发)
   sem := make(chan struct{}, 1000)

2. 队列满了立即失败,不排队
   select {
   case sem <- struct{}{}:
       defer func() { <-sem }()
       // 处理请求
   default:
       // 返回 503 或降级
   }

3. 对下游加熔断器
   - 下游错误率 > 阈值 → 打开熔断 → 快速失败
   - 半开状态探测恢复

回到具体问题,我的答案:

方案 B(有界并发),但要做对三件事:

  1. 池子大小 = 核数 × (1 + 等待时间/计算时间),但你说了”CPU 密集”,所以等待时间短,池子接近核数就够了。1000 可能偏大(8 核 CPU 密集,8-16 个 worker 就够了,1000 个 worker 反而增加上下文切换)。等等——这里要澄清一个混淆。

重点澄清:CPU 密集 vs 等下游,是两个不同的东西。

你的描述有点矛盾:说”CPU 密集”,但每个请求又要等下游 20ms。这其实是”混合型”:等下游时(20ms)CPU 闲着,处理逻辑时 CPU 忙。

对混合型:

最优 worker 数 = 核数 × (1 + 等待时间/CPU时间)

假设 CPU 处理 2ms,等下游 20ms(P50):
  最优 = 8 × (1 + 20/2) = 8 × 11 = 88 个 worker

假设下游变慢到 2s(P99):
  最优 = 8 × (1 + 2000/2) = 8 × 1001 = 8008 个 worker

看到没?最优 worker 数随下游延迟变化而变化。 这就是为什么”固定线程池大小”是玄学——最优值不是常数,是负载的函数。

Go 的方案 A(每请求一个 goroutine)其实很聪明:它让”并发度”自动等于”在途请求数”,无需你猜最优值。

所以我的最终答案修正为:

首选:方案 A(Go 的天然模型),但加三个约束:

// 1. 用信号量限制最大在途请求数(防止无界堆积)
var sem = make(chan struct{}, 5000)  // 上限,防止内存/下游被打爆
 
func handler(w http.ResponseWriter, r *http.Request) {
    select {
    case sem <- struct{}{}:
        defer func() { <-sem }()
        handle(w, r)
    default:
        http.Error(w, "too many requests", 503)  // 快速失败
    }
}
 
// 2. 给下游调用设置 context 超时
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
 
// 3. 下游客户端加熔断 + 有限连接池
//    连接池上限控制对下游的压力

为什么不用方案 B 的固定 worker 池?

因为 Go 的 goroutine 足够轻,你不需要”复用 goroutine”来省成本。goroutine 池在 Go 里的主要意义不是”复用”(goroutine 本来就便宜),而是”限流”。

所以正确的心智模型是:

Go 里不要用"线程池"的思维(那是 Java/C++ 为了复用昂贵线程)
Go 里用"信号量/限流"的思维(限制并发上限,防止雪崩)

goroutine 池 = 信号量的一个特例(池大小 = 信号量容量)

总结:

维度方案 A(无界)方案 A + 信号量(推荐)方案 B(固定池)
代码复杂度低低中
下游正常时最优最优好(池子够大时)
下游变慢时雪崩可控降级可控降级
内存控制无有(上限)有
需要调参无1 个(信号量容量)2 个(池大小+队列长度)

元教训:并发控制的核心不是”选池子还是无界”,而是”必须有一个上限 + 一个满载时的行为”。

上限(bound)防止资源耗尽,满载行为(fail-fast 或降级)防止雪崩。这两个东西加起来,就是背压。第 16 章会完整展开。


思考题 3

为什么 Redis 用单线程还能那么快?它单线程,是不是就没法利用多核了?这和本章讲的”执行流”有什么关系?

参考答案

先回答”为什么单线程还快”:

Redis 的核心操作是内存读写,而内存读写的延迟是 ~100ns。真正慢的是 I/O(网络、磁盘)。

Redis 单线程(指命令处理单线程,历史上 6.0 之前),意味着:

1. 没有上下文切换开销:一个线程从头跑到尾
2. 没有锁竞争:单线程天然无并发,不需要加锁
3. 缓存极其友好:数据一直在 L1/L2/L3 里,因为只有一个线程访问
4. 命令是纯内存操作:每个命令 O(1) 或 O(log n),几十微秒内完成

关键洞察:Redis 的瓶颈从来不是 CPU,是内存带宽和网络。

Redis 单线程 QPS 可以达到 10 万+(GET/SET 这种简单命令)
瓶颈分析:
  - 每个命令 ~1 微秒(内存操作 + 解析 + 网络)
  - 10 万 QPS = 每秒 10 万个命令 × 1 微秒 = 0.1 秒 CPU
  - CPU 才用了 10%,远没到瓶颈

既然 CPU 不是瓶颈,多线程就没什么意义——多线程带来的锁竞争和上下文切换,反而会拖慢它。

这就是”为什么要学操作系统”的又一体现:你只有知道”瓶颈在哪”,才能判断”要不要上并发”。 不知道瓶颈,就凭感觉上并发,只会更慢。

再回答”是不是没法利用多核”:

历史上(6.0 之前),Redis 确实只用单核。多核的利用靠的是多实例:

方案:一台 16 核机器,跑 16 个 Redis 实例(每个绑一个核)
- 每个实例单线程,无锁,快
- 16 个实例用满 16 核
- 缺点:数据分片(每个实例只有 1/16 的数据),管理复杂

这是”用多进程代替多线程”的又一次体现(就像 Nginx)。

Redis 6.0 之后引入多线程,但很有讲究:

Redis 6.0 的多线程只用在”网络 I/O”上,命令执行仍然是单线程:

多线程部分:解析协议、读写 socket(这些是纯 I/O,可以并行)
单线程部分:执行命令、操作数据结构(这些要保持顺序,必须单线程)

为什么命令执行坚持单线程?因为:

1. Redis 的数据结构(链表、跳表、哈希)不是线程安全的
2. 加锁会拖慢核心路径
3. 单线程保证了命令的原子性和顺序性(这是 Redis 事务、Lua 脚本的基础)

这个设计和本章的”执行流”有什么关系?

它完美地体现了这一章的核心思想:

1. "避免并发"比"管理并发"更高明
   → Redis 直接单线程,把并发问题从根上消除

2. 只有在"瓶颈确实在 CPU"时才上多线程
   → Redis 的网络 I/O 是瓶颈(解析协议费 CPU),所以只在这里并行

3. "共享状态"是要付出代价的
   → Redis 的数据结构是共享状态,为了不付代价,宁可单线程

4. 用"多进程(多实例)"来实现多核并行
   → 隔离优先,共享用网络(主从复制)而不是共享内存

延伸思考:什么场景下 Redis 的单线程会成为瓶颈?

1. 大 key 操作:一个 key 是几 MB 的列表,DEL 一个 key 要几百毫秒
   → 单线程被这个慢操作卡住,其他命令排队
   → 这是 Redis 的著名"大 key 问题"

2. 慢命令:KEYS、SORT、SMEMBERS(大集合)
   → 这些是 O(n) 命令,大 n 时卡死单线程

3. 持久化:BGSAVE/BGREWRITEAOF 用 fork + 子进程,
   → fork 本身要复制页表,COW 写时复制有开销(第 1 章讲过)

4. 多核场景下,单个实例的 CPU 上限就是单核
   → QPS 到 10 万+ 就到顶了,要更高只能分片/多实例

元教训:

“单线程”在 Redis 里不是落后的标志,是”看清了瓶颈之后的明智选择”。

大部分系统的瓶颈在 I/O 和网络,不在 CPU。在 CPU 不是瓶颈时上多线程,就是”为了解决一个不存在的问题,引入一堆真实的问题”。

这也回答了为什么 Go 用 goroutine、Redis 用单线程、Nginx 用多进程——它们面对的瓶颈不同,所以选择了不同的”执行流”模型。没有谁对谁错,只有”合不合适”。


思考题 4(开放题)

你所在团队要设计一个”任务调度系统”:接收任务,分发给 worker 执行,任务之间有依赖(B 依赖 A 完成)。任务可能耗时几秒到几分钟。你会怎么设计执行流?进程?线程?协程?还是混合?

参考答案

开放题,重点看你的推理过程,而不是结论。我展示一个完整的推理框架。

第一步:明确约束和负载性质。

任务特征:
  - 耗时几秒到几分钟 → 长任务,不是短请求
  - 有依赖关系 → 需要 DAG 调度
  - 可能 I/O 密集(下载、调 API)也可能 CPU 密集(计算)

系统目标:
  - 高吞吐(任务多)
  - 不丢任务(持久化)
  - 可观测(能看到每个任务的状态)

第二步:拆解问题,分成两个子问题。

这个系统其实有两个完全不同的关注点:

关注点 1:任务的分发和调度(控制面)
  - 接收任务、建 DAG、决定谁先跑、处理依赖
  - 这是"调度逻辑",I/O 密集(要读数据库、发消息)

关注点 2:任务的执行(数据面)
  - 真正跑任务逻辑
  - 这是"执行逻辑",可能 CPU 密集,也可能 I/O 密集

第三步:分别选择合适的模型。

控制面(调度器):用协程(Go)。

原因:

  • 调度逻辑主要是”等数据库/等消息/协调”,I/O 密集,协程主场
  • 任务状态、DAG 这些共享状态,用 Go 的 channel + select 管理很自然
  • 需要海量的”等待中的任务”(几万、几十万个任务挂着等依赖),协程轻量,挂几万个没压力

数据面(执行器):按任务性质分。

CPU 密集任务(视频转码、模型推理):
  → 用进程池(或线程池),数量 = 核数
  → 因为要真并行,且要隔离(一个任务崩了别影响调度器)
  → 甚至可以用独立 worker 进程,通过消息队列通信

I/O 密集任务(调 API、爬虫、数据同步):
  → 用协程,海量并发
  → 几千个任务同时等下游响应,协程是最优解

第四步:处理依赖关系。

任务依赖是一个 DAG。核心问题是:怎么知道”B 可以跑了”?

方案一:轮询(poll)
  - B 定期查"A 完成了吗"
  - 简单,但浪费(轮询间隔 × 任务数 的开销)

方案二:事件驱动(callback / channel)
  - A 完成时,通知 B
  - 高效,但要处理"A 完成后 B 还没注册"的时序问题

方案三:用现成的 DAG 调度器(Airflow、Dagster、Argo)
  - 它们内部就是事件驱动 + 状态机

第五步:持久化和容错(这是最容易忽略的部分)。

任务调度系统最怕什么?调度器挂了,任务状态全丢。

所以任务状态必须持久化(数据库),而不是只放在内存里的 goroutine 状态里。这里有个关键设计:

任务状态 = 数据库里的记录(source of truth)
goroutine = 短暂的执行代理(可以随时死,重启后从数据库恢复)

这样:
  - 调度器重启 → 从数据库读任务状态 → 重新调度
  - 任务执行失败 → 更新数据库 → 重试逻辑
  - 幂等性 → 靠"任务状态机 + 唯一任务 ID"保证

第六步:完整的架构(一个合理的答案):

┌─────────────────────────────────────────────┐
│  控制面(Go 调度器,协程模型)                  │
│  - 接收任务 → 写入数据库                      │
│  - 扫描 DAG,找到就绪的任务                    │
│  - 分发给执行器                              │
│  - 监听任务完成事件,触发下游任务               │
└──────────────┬──────────────────────────────┘
               │ 消息队列(解耦控制面和数据面)
┌──────────────▼──────────────────────────────┐
│  数据面(执行器,按任务类型分)                  │
│  - CPU 密集:worker 进程池(= 核数)           │
│  - I/O 密集:协程海量并发                     │
└─────────────────────────────────────────────┘

第七步:回答”进程?线程?协程?”

最终答案:混合,分层使用

1. 控制面(调度)       → 协程(Go)
   理由:I/O 密集、海量挂起、状态管理方便

2. CPU 密集任务执行     → 进程池
   理由:真并行、崩溃隔离、数量可控

3. I/O 密集任务执行     → 协程
   理由:海量并发、等待 I/O 时零开销

4. 任务持久化          → 数据库(不是内存)
   理由:容错、可恢复、可观测

5. 控制面/数据面通信    → 消息队列
   理由:解耦、削峰、可重放

这道题真正考的是什么?

  1. 能不能识别”负载性质”:I/O 密集和 CPU 密集要分开对待(这是本章的核心)
  2. 能不能区分”控制面”和”数据面”:调度和执行是两个问题,别混在一起
  3. 能不能想到”持久化”:执行流的状态是易失的,真正的系统要把状态落到数据库
  4. 会不会用”消息队列解耦”:执行流的通信要用异步,不能同步耦合

一句话总结:

好的架构不是”选一个模型用到死”,而是”识别每一部分的性质,给它配最合适的模型”。

而这,恰恰是理解”进程/线程/协程各自的账单”之后才能做到的事。


一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 02 章 · 进程与线程:执行流的抽象与它的账单                     │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【核心区分】                                                  │
│    进程 = 资源容器(代码、内存、文件、身份)                       │
│    线程 = 执行流(寄存器 + 执行时间)                             │
│    → 内核里它们都是 task_struct,只是"共享程度"不同                │
│    → clone() 的参数决定"共享什么" = 一种机制,多种策略             │
│                                                              │
│  【上下文切换的账单】                                            │
│    显性:寄存器保存/恢复 = 1-2 微秒(线程)/ 3-5 微秒(进程)        │
│    隐性:缓存失效 + TLB 失效 + 分支预测 = 5-10 微秒               │
│    经验法则:切换间隔 < 10 微秒就是在做负功                        │
│                                                              │
│  【协程为什么轻】                                               │
│    切换不经过内核(纯用户态)→ 几十纳秒 vs 1-2 微秒                │
│    栈按需增长(2KB 起)vs 线程预留 1MB                            │
│    协作式调度 vs 抢占式                                          │
│                                                              │
│  【GMP 模型】                                                  │
│    G = 协程,M = OS 线程,P = 逻辑处理器(= GOMAXPROCS)          │
│    P 的价值:M 阻塞时保住 G 的调度                               │
│    work stealing = 无锁的负载均衡                                │
│    netpoller = 阻塞式代码 + 异步底层                             │
│                                                              │
│  【三个可迁移的原则】                                             │
│    1. 避免并发 > 管理并发(Redis 单线程、Nginx 多进程)            │
│    2. 并发度必须有上限 + 满载时有行为(背压)                      │
│    3. 按负载性质选模型:I/O 密集→协程,CPU 密集→进程池             │
│                                                              │
│  【一句话】                                                     │
│    进程和线程的分界 = 静态资源和动态执行的分界;                     │
│    协程用"不进内核的切换"把这张账单砍掉一个数量级。                  │
│                                                              │
│  【下一章预告】                                                  │
│    执行流搞清楚了,但它们会打架——                                 │
│    下一章讲它们打架的战场:文件系统,                                │
│    以及"一切皆文件"这个抽象到底有多大的野心和代价。                  │
└──────────────────────────────────────────────────────────────┘