幕 II · 机器CH 07进度 07/25

线程:一个 8MB 的承诺,和 1 微秒的税

「线程很贵」是一句所有人都会说、但很少有人能拆开算的话。它贵在三个地方,而这三个地方恰好解释了协程/goroutine 存在的全部理由 —— 它们不是「更快的线程」,它们是把这三笔账里的每一笔都绕过去了。这一章把账算清:栈为什么要 8MB、一次上下文切换到底在切什么、以及为什么线程池从 32 调到 64,吞吐反而会掉。

上下文切换C10K线程池怎么调Little 定律

线程是什么:内核眼里的一个结构体

在 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 pthread8 MB(= ulimit -s虚拟地址空间,不是立刻占物理内存 —— 按页 lazy 分配
JVM 线程1 MB-Xss,64 位默认)JVM 自己管,比 pthread 保守
Windows1 MB
goroutine2 KB可增长!栈满了就分配一块更大的,把内容拷过去(morestack)
Kotlin 协程没有栈状态存在堆上的一个对象里,几十~几百字节(第 14 章)
Python Task几 KBcoroutine frame + Task 对象
⚙ 机器 · 「8MB 虚拟内存」到底占不占真内存

常见的一个反驳是:「8MB 是虚拟地址空间,实际只有用到的页才会分配物理内存,所以没那么贵。」

这话对,但只对一半。

  • 物理内存上:对。一个刚创建的线程可能只真的用了 8~16KB(一两个页)。
  • 虚拟地址空间上:不对。64 位下地址空间管够,但 32 位下 10000 个线程 × 8MB = 80GB,直接爆掉 4GB 的地址空间。(这是 C10K 时代的真实约束。)
  • 更要命的是:栈会被用起来。一个跑着业务逻辑的线程(Spring 那种 50 层调用栈),实际占用几十上百 KB 很正常。1 万个线程 × 100KB = 1GB 真实内存,全部用来存那些正在睡觉的栈

所以「栈很贵」这句话依然成立 —— 只是没有 8MB × N 那么夸张。真正的杀手是下一笔账。

第二笔账:上下文切换

这才是真正的税。一次线程切换,内核要做:

  1. 陷入内核态(syscall / 时钟中断)—— 保存用户态寄存器,切到内核栈
  2. 保存旧线程的寄存器到它的 task_struct
  3. 跑调度器(Linux 的 CFS / EEVDF)—— 选下一个该跑谁。队列越长,这一步越慢
  4. 恢复新线程的寄存器
  5. 切回用户态

直接开销:约 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,恢复就是一次 invokeCPU 从头到尾都在正常执行函数调用,它甚至不知道发生了「并发」。

把账算给你看

把并发数拖到 10 万,看四种并发单元各要多少:

第三笔账:调度器自己会崩

前两笔账是线性的(N 个线程 = N 倍的栈 + N 倍的切换)。第三笔账是超线性的。

当线程数远超核数:

  • 调度器的运行队列变长,每次选下一个跑谁的开销上升
  • 缓存彻底失效 —— 一个线程等它下次被调度到,可能已经过了几毫秒,它的数据早被冲光了
  • 锁竞争加剧 —— 更多线程抢同一把锁 → 更多线程被唤醒 → 更多上下文切换 → 更多锁竞争(正反馈
  • 惊群 —— 一个事件唤醒了 100 个等待的线程,99 个发现没活干,又睡回去
线程数 vs 吞吐量:那条掉头向下的曲线8 核机器
8 线程 干活切换
64 线程 干活切换 + 缓存 miss
512 线程 干活切换锁竞争
4096 线程 干活上下文切换锁竞争 / 调度器自旋
这就是「调优」时最诡异的现象 CPU 使用率 100%,但吞吐量在下降。
你以为 CPU 在干活 —— 它确实很忙,忙着在线程之间跳来跳去
诊断方法:vmstat 1cs(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-maxpid_max。)然后用 goroutine 做同样的事,开 100 万个。这个对比会让你永远记住这一章。

3. 把你项目里所有的 newCachedThreadPool 和无界队列找出来。每一个都是一枚定时炸弹。

下一章:现在我们知道线程是什么、贵在哪了。接下来是它们最擅长的事 —— 互相踩踏。三个不同的 bug,被统称为一个词。