线程:一个 8MB 的承诺,和 1 微秒的税
「线程很贵」是一句所有人都会说、但很少有人能拆开算的话。它贵在三个地方,而这三个地方恰好解释了协程/goroutine 存在的全部理由 —— 它们不是「更快的线程」,它们是把这三笔账里的每一笔都绕过去了。这一章把账算清:栈为什么要 8MB、一次上下文切换到底在切什么、以及为什么线程池从 32 调到 64,吞吐反而会掉。
线程是什么:内核眼里的一个结构体
在 Linux 上,线程和进程是同一个东西(都是 task_struct),区别只在于 clone() 时共享了多少东西:
/* 创建一个进程 */
clone(SIGCHLD, ...) /* 什么都不共享 */
/* 创建一个线程 —— 就是 clone 时多传几个标志 */
clone(CLONE_VM | /* 共享地址空间(同一份内存!) */
CLONE_FS | /* 共享文件系统信息 */
CLONE_FILES | /* 共享 fd 表 */
CLONE_SIGHAND | /* 共享信号处理 */
CLONE_THREAD, ...) /* 同一个线程组 */
/* 所以:「线程是轻量级进程」这句话在 Linux 上是字面意义上的真理。
内核调度的单位从来都是 task_struct —— 它不区分线程和进程。 */
这解释了两件事:
- 为什么线程之间「共享内存」 —— 因为
CLONE_VM,它们用的是同一份页表。这是并发的全部威力,也是全部危险。 - 为什么线程切换比进程切换便宜一点 —— 同一个地址空间,不用换页表、不用刷 TLB。但也只便宜「一点」。
第一笔账:栈
每个线程都要有自己的调用栈。因为函数调用要压栈帧,而两个线程不能共用一个栈。
| 平台 | 默认线程栈 | 说明 |
|---|---|---|
| Linux pthread | 8 MB(= ulimit -s) | 虚拟地址空间,不是立刻占物理内存 —— 按页 lazy 分配 |
| JVM 线程 | 1 MB(-Xss,64 位默认) | JVM 自己管,比 pthread 保守 |
| Windows | 1 MB | |
| goroutine | 2 KB | 可增长!栈满了就分配一块更大的,把内容拷过去(morestack) |
| Kotlin 协程 | 没有栈 | 状态存在堆上的一个对象里,几十~几百字节(第 14 章) |
| Python Task | 几 KB | coroutine frame + Task 对象 |
常见的一个反驳是:「8MB 是虚拟地址空间,实际只有用到的页才会分配物理内存,所以没那么贵。」
这话对,但只对一半。
- 物理内存上:对。一个刚创建的线程可能只真的用了 8~16KB(一两个页)。
- 虚拟地址空间上:不对。64 位下地址空间管够,但 32 位下 10000 个线程 × 8MB = 80GB,直接爆掉 4GB 的地址空间。(这是 C10K 时代的真实约束。)
- 更要命的是:栈会被用起来。一个跑着业务逻辑的线程(Spring 那种 50 层调用栈),实际占用几十上百 KB 很正常。1 万个线程 × 100KB = 1GB 真实内存,全部用来存那些正在睡觉的栈。
所以「栈很贵」这句话依然成立 —— 只是没有 8MB × N 那么夸张。真正的杀手是下一笔账。
第二笔账:上下文切换
这才是真正的税。一次线程切换,内核要做:
- 陷入内核态(syscall / 时钟中断)—— 保存用户态寄存器,切到内核栈
- 保存旧线程的寄存器到它的
task_struct - 跑调度器(Linux 的 CFS / EEVDF)—— 选下一个该跑谁。队列越长,这一步越慢
- 恢复新线程的寄存器
- 切回用户态
直接开销:约 1~5 微秒。听起来不多。但真正的成本在第六步 —— 那个不在清单上的一步:
新线程跑起来的时候,它要用的数据不在 L1/L2 缓存里(那里面全是上一个线程的数据)。
于是接下来几千个 CPU 周期,全是 cache miss。回忆第 6 章那张表:L1 是 1ns,主内存是 100ns。
这笔账不出现在任何 vmstat 的统计里,但它是真实的 —— 学术测量显示,算上缓存污染,一次上下文切换的「实际」成本可能高达数十微秒(取决于工作集大小)。
这就是为什么「线程数 = 核数」在 CPU 密集场景下是最优的:多一个线程,就多一次缓存被冲刷。
而协程/goroutine 的「切换」,成本完全不在一个量级:
| OS 线程切换 | goroutine 切换 | Kotlin 协程「切换」 | |
|---|---|---|---|
| 要陷内核吗 | 要 | 不要(纯用户态) | 不要 |
| 要换页表 / 刷 TLB 吗 | 同进程内不用换页表,但仍有开销 | 不用 | 不用 |
| 要保存多少寄存器 | 全部(+ FPU / SIMD 状态) | 3 个(PC、SP、DX) | 0 个 —— 它根本不是「切换」 |
| 本质是什么 | 内核调度 | 用户态改几个指针 | 一次普通的方法返回 + 一次普通的方法调用(第 14 章) |
| 成本 | ~1–5 µs(含缓存污染更高) | ~0.1–0.2 µs | ~几十纳秒 |
看最后一列。Kotlin 协程的「切换」之所以能便宜到几十纳秒,是因为它根本不是切换 —— 挂起就是一次 return,恢复就是一次 invoke。CPU 从头到尾都在正常执行函数调用,它甚至不知道发生了「并发」。
把账算给你看
把并发数拖到 10 万,看四种并发单元各要多少:
第三笔账:调度器自己会崩
前两笔账是线性的(N 个线程 = N 倍的栈 + N 倍的切换)。第三笔账是超线性的。
当线程数远超核数:
- 调度器的运行队列变长,每次选下一个跑谁的开销上升
- 缓存彻底失效 —— 一个线程等它下次被调度到,可能已经过了几毫秒,它的数据早被冲光了
- 锁竞争加剧 —— 更多线程抢同一把锁 → 更多线程被唤醒 → 更多上下文切换 → 更多锁竞争(正反馈)
- 惊群 —— 一个事件唤醒了 100 个等待的线程,99 个发现没活干,又睡回去
你以为 CPU 在干活 —— 它确实很忙,忙着在线程之间跳来跳去。
诊断方法:
vmstat 1 看 cs(context switch)那一列。如果它是几十万,而你的 QPS 只有几千 —— 找到凶手了。
那么,线程池到底该开多大?
这是每个后端工程师都被问过、而且大概率抄了一个数字的问题。它其实有公式。
① CPU 密集的池:
线程数 = 核数(或核数 + 1) # 为什么不是核数 × 2? # 因为没有等待。多一个线程 = 多一次缓存冲刷,纯亏。 # Kotlin: Dispatchers.Default(默认就是核数) # Go: runtime.NumCPU() 个 worker # Java: ForkJoinPool.commonPool()(默认 核数-1)
② IO 密集的池:
线程数 = 核数 × (1 + 等待时间 / 计算时间) # 例:8 核,每个请求等 100ms、算 5ms # → 8 × (1 + 100/5) = 8 × 21 = 168 个线程 # 或者直接用 Little 定律倒推: # 并发数 L = 到达率 λ × 平均耗时 W # 目标 2000 QPS,平均 105ms → L = 2000 × 0.105 = 210 个在飞的请求 # → 你至少需要 210 个并发单元
但注意:如果算出来的数字是「几千」—— 那你不该用线程,你该用协程。这就是这本书存在的理由。
Executors.newCachedThreadPool()—— 无界。来一个任务建一个线程。流量一冲,几万个线程,直接 OOM。阿里的 Java 规约明令禁止它,理由就是这个。newFixedThreadPool(n)配无界队列 —— 线程数有界了,但队列是无界的(LinkedBlockingQueue默认无上限)。任务堆积 → OOM。这是「把背压推迟到 OOM」的教科书案例(第 21 章)。- 线程池死锁 —— 池里的任务 A 在等池里的任务 B 完成,而池满了,B 永远排不进来。典型现场:在一个线程池里
submit()子任务然后get()等它。(第 23 章会详细解剖。)
正确的线程池永远有三个有界的东西:线程数有界、队列有界、拒绝策略明确。
虚拟线程:把栈也搬到堆上
最后提一个重要的近期发展:Java 21 的虚拟线程(JEP 444)。它选择了和协程完全不同的一条路:
| 协程(Kotlin / Python / Go) | 虚拟线程(Java 21+) | |
|---|---|---|
| 在哪层做 | 编译器(Kotlin/Python)或 runtime + 编译器(Go) | 纯 JVM 运行时 —— 语言一个字没改 |
| 怎么做的 | 把执行状态变换成堆上的状态机 | 挂起时,把整个栈从载体线程上「卸下来」搬到堆上;恢复时再装回去 |
| 函数染色 | 有(suspend / async) | 没有 —— 这是它最大的胜利 |
| 老代码怎么办 | 要改(改成 suspend / async) | 一行不用改。Thread.sleep()、JDBC、InputStream.read() —— 全都自动变成非阻塞的 |
| 结构化并发 | Kotlin 内建 | StructuredTaskScope(JDK 25 里仍是预览,JEP 505) |
| Android 能用吗 | Kotlin 协程:能 | 不能 —— ART 不是 HotSpot |
公平地说,虚拟线程在「无染色」这一点上确实更优雅 —— 它让几十年的阻塞式代码一夜之间变得可扩展。你的 Spring Boot 应用换个配置就能扛十万连接,代码一行不改。
但它没有免费的午餐:「钉住」(pinning)问题曾经很致命 —— 虚拟线程在 synchronized 块里挂起时,会把载体线程一起钉死(因为 monitor 是绑定线程的)。JDK 24(JEP 491)修掉了这个,这是个很大的进步。但如果你在用 JNI 或者老的 native 库,钉住问题依然存在。
1. 跑一次 vmstat 1,看 cs 列。再对比你的 QPS。如果 cs 是 QPS 的几十倍 —— 你的线程池太大了。
2. 写一个小实验:一个循环里 new Thread().start(),看能建到几个才崩。(Linux 上还会撞到 threads-max 和 pid_max。)然后用 goroutine 做同样的事,开 100 万个。这个对比会让你永远记住这一章。
3. 把你项目里所有的 newCachedThreadPool 和无界队列找出来。每一个都是一枚定时炸弹。
下一章:现在我们知道线程是什么、贵在哪了。接下来是它们最擅长的事 —— 互相踩踏。三个不同的 bug,被统称为一个词。