「同时」是一种错觉:从一杯咖啡说起
你的电脑「同时」在放音乐、下载文件、跑编译器、刷新浏览器。但如果它只有 8 个核,而任务管理器里有 400 个进程 —— 那个「同时」显然是假的。它是被制造出来的。制造它的手段有两种,成本天差地别,而几乎所有人都在混用它们的名字。这一章不定义任何术语,先做八道题:如果你能全部答对,这本书对你可能只是一次复习;如果你错了三道以上 —— 恭喜,接下来 24 章是给你写的。
先做八道题
不用查资料。凭直觉答,然后点开对答案。每道题都对应后面的一章。
Q1我的服务器有 8 个核。我用 asyncio 把代码全改成异步了。CPU 密集的接口会变快吗?
不会。一点都不会,甚至会更慢。
asyncio 是单线程的。异步买到的东西只有一样:在等待的时候去干别的活。而 CPU 密集的任务根本没有等待 —— 它从头到尾都在算。你把一个没有空隙的东西塞进一个专门用来填空隙的机制里,只会白付一笔调度开销。
更糟的是:那个跑得正欢的 CPU 密集协程不会主动让出事件循环,于是它会把整个进程的所有请求一起卡住。(第 13 章的 demo 会让你亲眼看到这一幕。)
CPU 密集只有一条路:多核并行。而在 Python 里,因为 GIL,这条路叫 multiprocessing。
Q2「Python 多线程是废的,因为 GIL。」这句话对吗?
只对了一半,而这一半的误传害了很多人。
关键事实:所有阻塞式 IO 在进内核之前都会释放 GIL。socket.recv、time.sleep、文件读写、requests.get —— 全都放手。
所以,用 10 个线程去抓 10 个网页,在 Python 里是真的会快接近 10 倍的。GIL 只在执行 Python 字节码时被持有。
准确的说法是:GIL 让 Python 多线程无法利用多核做计算,但完全不妨碍它并发做 IO。
Q3Go 里的 conn.Read(buf) 会阻塞,直到数据到达。那开 10 万个 goroutine 各自 Read,是不是就有 10 万个线程卡在那儿?
不是。线程可能只有 8 个。
这是 Go 最漂亮的一手:conn.Read() 看起来是阻塞调用,但 runtime 把这个 fd 悄悄注册进了 netpoller(Linux 上就是 epoll),然后把这个 goroutine park 住,OS 线程立刻回去跑别的 goroutine。
你写的是最朴素的阻塞式代码,跑的是 epoll 事件循环。没有 async、没有 await、没有函数染色 —— 编译器和 runtime 把这件事整个吞了。这是 Go 在并发上最大的产品决策。
Q4「同步」和「阻塞」是一回事吗?
不是。它们甚至不在同一个层面上。
- 同步 / 异步说的是调用契约:我发出调用之后,我这条执行线还能不能往下走?
- 阻塞 / 非阻塞说的是线程的下场:在等待期间,那个 OS 线程是被占着不动,还是被交还回去干别的?
它们经常一起出现(「同步阻塞 IO」),但四种组合全都存在。最典型的反例:Kotlin 的 suspend 函数是「同步风格的代码,非阻塞的行为」 —— 代码从上往下读,但线程在挂起点被还回去了。
Q5下面这段 Java 代码,两个线程各跑 1000 次,最后 counter 一定是 2000 吗?
var counter = 0 // 线程 A 和线程 B 各执行 1000 次: counter++
不一定。而且它大部分时候是对的 —— 这正是最恶毒的地方。
counter++ 不是一步,它是三步:LOAD(读到寄存器)、ADD(加一)、STORE(写回)。如果两个线程的 LOAD 挨在一起,它们会读到同一个旧值,然后各自加一、各自写回 —— 两次自增,只生效了一次。
第 8 章的 demo 会真的把这三条指令随机交错跑 1000 次,让你看着它丢。
Q6我的异步服务用了无界队列,来多少收多少,永不拒绝。这是不是很棒?
这是你未来某次 OOM 的完整成因。
只要到达率 λ 长期大于处理率 μ,无界队列就会线性增长,永不回落。它没有消除压力,只是把压力推迟到内存耗尽的那一刻。
而且在 OOM 之前,你的延迟已经先崩了:队列里排着 1 万个请求,新来的那个就得等 1 万个请求的处理时间。一个「永不拒绝」的系统,会先变成一个「永远超时」的系统。
正确的做法叫背压:让队列有界,满了就把压力沿着管道往回传,一路传到源头。
Q7go doWork() 启动了一个 goroutine。函数返回之后,它还在跑吗?谁负责等它?谁负责在出错时取消它?
它还在跑。没有人负责。这就是问题所在。
go f() 让控制流跳出去,再也不回来 —— 从这个角度看,它和 goto 是同一种病。2018 年那篇著名的《Go statement considered harmful》说的就是这件事。
后果:goroutine 泄漏是 Go 最常见的生产事故,而且编译器不会给你任何警告。
解药叫结构化并发:让「启动一个并发任务」变得像调用一个函数一样有始有终。Kotlin 的 coroutineScope、Python 3.11 的 TaskGroup、Java 的 StructuredTaskScope 全是这个思想。而 Go 是三门语言里唯一还没有它的。
Q8我的程序 95% 的部分都能完美并行。给它 100 个核,能快多少倍?
大约 16.8 倍。而不是 95 倍。
Amdahl 定律:加速比 = 1 / ( (1−p) + p/n )。代进去:1 / (0.05 + 0.95/100) ≈ 16.8。
更残酷的是:哪怕你有无限个核,天花板也只有 1/(1−p) = 20 倍。那剩下的 5% 串行部分,多少个核都帮不上忙。
而这还是乐观的估计 —— 它没算争锁和缓存一致性的开销。真实系统里,核加到某个数之后,性能是会掉头向下的。
上面八道题,看起来在问八件不同的事。它们其实在问同一件事:
「等待」的时候,谁在干什么?
CPU 的速度是纳秒级的,网络是毫秒级的 —— 差了六个数量级。一个典型的 Web 请求,CPU 真正在算的时间可能只占 1%,剩下 99% 都在等:等数据库、等下游服务、等磁盘、等用户。
并发的全部意义,就是把这 99% 的「等」的时间,卖给别人。而所有的复杂度、所有的 bug、所有的语言设计分歧,都是在回答「怎么卖」这个问题。
一杯咖啡的三种做法
假设你在一家咖啡店,需要做三杯手冲。每杯的流程是:磨豆 20 秒(你亲自动手)→ 闷蒸 120 秒(你只是站着看)→ 出杯 20 秒(你亲自动手)。
三种做法:
- 做法一(同步阻塞):磨完第一杯的豆,然后站在那里盯着它闷蒸 120 秒,出杯,再开始第二杯。—— 420 秒。
- 做法二(异步并发):磨完第一杯的豆,趁它闷蒸,去磨第二杯、第三杯。哪杯好了就去出哪杯。你还是只有一个人。—— 180 秒。
- 做法三(多核并行):雇三个咖啡师,一人一杯。—— 140 秒。
请注意做法二和做法三的差别 —— 这是本书最重要的一次区分:
- 做法二只用了一个人,靠的是「别站在那儿等」。这叫并发(concurrency)。
- 做法三用了三个人,靠的是「真的有三双手」。这叫并行(parallelism)。
做法二从 420 秒砍到 180 秒,一分钱人工成本都没多花。而做法三多花了两倍人力,只多省了 40 秒。
下面这个模拟器把这三种做法真的跑一遍 —— 它是一个离散时间调度模拟器,不是预录的动画。然后,请把负载类型切到「CPU 密集」,看结论怎么当场翻转。
你刚才应该看到了:
- IO 密集的负载(磨豆 20s、等 120s):并发提速巨大,并行只多赚一点点 —— 而且并行的代价是三个线程。
- CPU 密集的负载(纯算 120s,一次也不等):并发提速为 0(1.0×)。因为没有「等待」可卖。只有并行有救,而且天花板就是核数。
「我该用异步还是多线程」这个问题,答案完全取决于你的时间花在哪。而绝大多数人从来没量过这件事 —— 他们只是凭感觉选了一个,然后花几个月调试后果。
三组正交的问题
现在把话说清楚。这六个词经常被当成三对同义词乱用,但它们其实是三组互不相干的问题,各自问的是完全不同的层面:
| 这一组词 | 回答的问题 | 在哪一层 | 第几章 |
|---|---|---|---|
| 同步 / 异步 | 我发出调用之后,这条执行线还能不能往下走? | 调用契约(我和被调方之间的约定) | 02 |
| 阻塞 / 非阻塞 | 等待期间,那个 OS 线程是被占着,还是被交还? | 资源占用(线程的下场) | 03 |
| 并发 / 并行 | 这些任务是交替推进,还是真的同时推进? | 结构 vs 执行(一个是怎么写,一个是怎么跑) | 04 |
它们正交的意思是:知道其中一个,推不出另外两个。
举几个组合,感受一下:
| 例子 | 同步/异步 | 阻塞/非阻塞 | 并发/并行 |
|---|---|---|---|
普通的 socket.recv() | 同步 | 阻塞 | — |
Kotlin 的 suspend fun | 同步风格的代码 | 非阻塞的行为 | 并发 |
Go 的 conn.Read() | 看起来同步 | 底下是非阻塞的(netpoller) | 并发 + 并行 |
JS 的 await fetch() | 异步 | 非阻塞 | 并发(永远不并行) |
| 8 个线程算矩阵乘法 | — | — | 并行(但几乎不「并发」) |
看第二行和第三行。这就是过去十年整个业界的方向:让代码「读起来是同步的」,同时「跑起来是非阻塞的」 —— 因为人脑擅长读顺序代码,而机器擅长填满空隙。Kotlin 用编译器做到(第 14 章),Go 用 runtime 做到(第 16 章),Python 用 async/await 做到一半(第 13 章,那一半叫「函数染色」)。
- 「我这个接口是异步的,所以更快」 —— 异步不会让单个请求更快。它让同样的资源能扛更多请求。单个请求的延迟通常还会略微变高(多了调度开销)。
- 「我们上了多线程,所以并发上去了」 —— 如果你的瓶颈是数据库,加线程只会让更多线程一起排队等同一个数据库。并发数不是你决定的,是最慢的那一环决定的。
- 「Go 的 goroutine 是并行的」 —— goroutine 提供的是并发。它能并行(因为 GOMAXPROCS > 1),但把
GOMAXPROCS设成 1,你的 goroutine 全都还在正常工作 —— 只是不再并行了。并发是结构,并行是运气。
为什么这些词值得较真
因为用错词的人,会做错决定。
我见过的真实场景,每一条都源自这几个词的混淆:
- 一个团队把 CPU 密集的图像处理服务改成了全 asyncio,花了两个月,性能一点没变(Q1)。
- 一个团队相信「Python 多线程没用」,于是给一个纯 IO 的爬虫上了
multiprocessing,内存翻了 8 倍,速度还慢了(因为进程间要 pickle 搬数据)(Q2)。 - 一个团队为了「支持高并发」把线程池调到了 2000,结果上下文切换把 CPU 吃光了,QPS 反而掉了一半(第 7 章)。
- 一个团队的服务在流量高峰时 OOM,查了两周,最后发现是一个「为了不丢消息」而设的无界队列(Q6)。
这些都不是「高级并发问题」。它们是词典问题。
它们乱得有历史原因。
「同步 / 异步」最早来自通信领域(同步串口 vs 异步串口,说的是要不要共用时钟信号)。「阻塞 / 非阻塞」来自 Unix 的 fcntl(O_NONBLOCK),说的是一次系统调用的行为。「并发 / 并行」来自操作系统与体系结构教材。
三个词源,三个语境。它们在「网络编程」这个话题上撞在一起,被 Richard Stevens 的《UNIX 网络编程》整合成了「五种 IO 模型」(第 5 章)—— 但那本书讲的是系统调用层面的分类,而后来的人把它套到了应用框架甚至业务代码上,于是彻底乱了。
你现在听到的「异步」,可能指五种完全不同的东西:非阻塞 IO、回调、Future、协程、消息队列。所以每次有人说「我们要上异步」,第一件事是问:你说的是哪个?
1. 回到上面那个 demo,把负载切到「CPU 密集」,然后把核数拖到 1。看三种模式的耗时 —— 你会发现它们几乎一样。这一步能治好「异步万能」的幻觉。
2. 打开你手头正在维护的那个服务。问一个问题:它的请求耗时里,CPU 占多少,等待占多少?如果你答不上来 —— 这就是第一个要补的洞。(不要猜,去 profile。)
3. 在你的团队里问一遍 Q4。「同步和阻塞是一回事吗?」我打赌至少一半的人会说是。
下一章:把第一组词钉死。同步和异步,只回答一个问题 —— 谁来等。