第 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 内存,切换几十纳秒
差距为什么这么大?根本原因只有一个:
线程的切换要经过内核,协程的切换不经过内核。
展开说,有三层原因:
原因一:协程切换是纯用户态的”函数调用级”操作。
线程切换,要:
- 陷入内核(系统调用或中断)
- 内核调度器做决策(遍历运行队列、选下一个)
- 保存/恢复寄存器
- 返回用户态
而协程切换,只是用户态运行时做的:
// 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 纳秒(要切换栈、保存状态、可能进内核)
贵在哪?
- 栈切换:Go 的 goroutine 栈和 C 的栈不同,cgo 要切换到 C 的栈(一般用 M 的系统栈)
- 保存 Go 运行时状态:要记录 goroutine 的调度信息
- 可能触发系统调用:某些 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 不知道这个指针的存在,可能会:
- 把这个对象回收了(C 还在用)→ 野指针
- 移动了这个对象(C 的指针失效)→ 悬空指针
所以 cgo 有个规则:C 代码持有的 Go 指针,必须通过 C.CBytes 复制一份,Go 对象和 C 对象完全隔离。
这又增加了一次拷贝的成本。
总结:cgo 的四大坑:
- 占用 OS 线程(M 膨胀)
- 固定调用开销(50-200ns)
- 阻塞不可控(调度器管不了 C 代码)
- 破坏内存模型(指针要复制)
所以:能用纯 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 + 等待时间/计算时间),但你说了”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. 控制面/数据面通信 → 消息队列
理由:解耦、削峰、可重放
这道题真正考的是什么?
- 能不能识别”负载性质”:I/O 密集和 CPU 密集要分开对待(这是本章的核心)
- 能不能区分”控制面”和”数据面”:调度和执行是两个问题,别混在一起
- 能不能想到”持久化”:执行流的状态是易失的,真正的系统要把状态落到数据库
- 会不会用”消息队列解耦”:执行流的通信要用异步,不能同步耦合
一句话总结:
好的架构不是”选一个模型用到死”,而是”识别每一部分的性质,给它配最合适的模型”。
而这,恰恰是理解”进程/线程/协程各自的账单”之后才能做到的事。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 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 密集→进程池 │
│ │
│ 【一句话】 │
│ 进程和线程的分界 = 静态资源和动态执行的分界; │
│ 协程用"不进内核的切换"把这张账单砍掉一个数量级。 │
│ │
│ 【下一章预告】 │
│ 执行流搞清楚了,但它们会打架—— │
│ 下一章讲它们打架的战场:文件系统, │
│ 以及"一切皆文件"这个抽象到底有多大的野心和代价。 │
└──────────────────────────────────────────────────────────────┘