你的 8 核 CPU,可能只有 1 核在干活。不是硬件不行,是软件跟不上。欢迎来到并发的世界——一个充满陷阱的世界。
摩尔定律的终结
2005 年,Intel 宣布放弃继续提升 CPU 主频。
在那之前,程序员很幸福:代码不用改,等两年换台新电脑,程序自动变快。这就是摩尔定律的红利——晶体管数量每 18 个月翻一番,主频从 100MHz 涨到 1GHz,再涨到 3GHz。
然后,物理定律说:不行了。
主频再高,芯片就要融化。Intel 的解决方案是:不涨频率,涨核心数。
CPU 演进:
2000 年:单核 1GHz
2005 年:单核 3GHz(极限了)
2010 年:4 核 3GHz
2020 年:8 核 3.5GHz
2024 年:16 核 4GHz
主频几乎不涨,核心数翻了 16 倍
问题来了:你的程序能用上这 16 个核心吗?
大多数程序的答案是:不能。
写一个单线程程序,在 16 核机器上跑,和在单核机器上跑,速度一样。那 15 个核心在看戏。
这就是为什么你需要学并发:不是为了炫技,是为了不浪费你花钱买的 CPU。
并发 vs 并行:先搞清楚概念
这两个词经常混用,但它们不一样。
一个类比:咖啡店的服务员。
并发(Concurrency):
一个服务员,同时服务 3 桌客人
服务员 → 1号桌点单 → 2号桌送咖啡 → 3号桌结账 → 1号桌送咖啡 → ...
看起来在"同时"服务,实际上在快速切换
核心:处理多个任务的能力
并行(Parallelism):
三个服务员,各服务 1 桌客人
服务员A → 1号桌
服务员B → 2号桌
服务员C → 3号桌
真正的同时执行
核心:同时执行多个任务
| 概念 | 资源 | 本质 | 例子 |
|---|---|---|---|
| 并发 | 单核 | 时间切片,交替执行 | 一个人边吃饭边看手机 |
| 并行 | 多核 | 真正同时执行 | 两个人各吃各的 |
关键洞察:
- 并发是能力:程序能处理多个任务
- 并行是执行:多个任务真的同时跑
你可以写一个并发程序,但只在单核上跑(并发但不并行)。你也可以写一个多进程程序,每个进程单线程(并行但不并发)。
本系列的主题:如何写出正确的并发程序——让它既能利用多核,又不会出错。
共享状态:万恶之源
并发编程难在哪?
一句话:多个执行流同时访问共享状态。
噩梦一:竞态条件
假设你在写一个计数器(伪代码):
static mut COUNT: i32 = 0; // 全局可变状态
fn increment() {
COUNT = COUNT + 1;
}
两个线程同时调用 increment(),各调用 1 次。COUNT 最后是多少?
你以为是 2,但可能是 1。
真相:COUNT = COUNT + 1 不是原子操作
它实际上是三步:
1. 读取 COUNT 的值
2. 加 1
3. 写回 COUNT
两个线程的执行可能交错:
时间线:
T1: 线程 A 读取 COUNT = 0
T2: 线程 B 读取 COUNT = 0
T3: 线程 A 计算 0 + 1 = 1
T4: 线程 B 计算 0 + 1 = 1
T5: 线程 A 写回 COUNT = 1
T6: 线程 B 写回 COUNT = 1
结果:COUNT = 1(丢失了一次更新)
这就是竞态条件(Race Condition):程序的结果取决于执行顺序,而执行顺序是不确定的。
Rust 的态度:上面的代码在 Rust 中需要 unsafe 才能编译,安全的 Rust 根本不允许多线程同时修改未保护的共享变量。这正是 Rust 的设计哲学——在编译期阻止问题,而不是在运行时祈祷。
但在 C/C++/Java/Go 等语言中,这种代码可以正常编译,bug 不是每次都出现,可能跑 1000 次才出一次。测试很难发现,生产环境突然爆炸。
噩梦二:死锁
你学会了用锁保护共享状态。然后你写出了这样的代码:
use std::sync::Mutex;
static LOCK_A: Mutex<()> = Mutex::new(());
static LOCK_B: Mutex<()> = Mutex::new(());
fn process1() {
let _guard_a = LOCK_A.lock().unwrap();
// 做一些事情...
let _guard_b = LOCK_B.lock().unwrap(); // 等待 LOCK_B
// 需要同时持有两把锁
}
fn process2() {
let _guard_b = LOCK_B.lock().unwrap();
// 做一些事情...
let _guard_a = LOCK_A.lock().unwrap(); // 等待 LOCK_A
// 需要同时持有两把锁
}
两个线程同时运行,一个调 process1,一个调 process2。
时间线:
T1: process1 获取 LOCK_A ✓
T2: process2 获取 LOCK_B ✓
T3: process1 尝试获取 LOCK_B... 等待(process2 持有)
T4: process2 尝试获取 LOCK_A... 等待(process1 持有)
↓
process1: 我等 process2 释放 LOCK_B
process2: 我等 process1 释放 LOCK_A
谁也不让谁,永远等下去
这就是死锁(Deadlock):两个或多个执行流互相等待对方释放资源,谁也无法继续。
类比:两辆车在单行道上狭路相逢,都不愿意倒车,永远堵着。
注意:这段 Rust 代码可以编译通过!Rust 能防止数据竞争,但不能防止死锁——死锁是逻辑问题,不是内存安全问题。
噩梦三:内存可见性
这个最隐蔽。考虑这样的逻辑(伪代码):
static mut READY: bool = false;
static mut DATA: i32 = 0;
fn producer() {
DATA = 42;
READY = true;
}
fn consumer() {
while !READY {
// 等待
}
println!("{}", DATA); // 你以为打印 42?
}
consumer 看到 READY = true 后,打印 DATA。一定是 42 吗?
不一定。可能是 0。
原因:CPU 缓存和指令重排
现代 CPU 有多级缓存:
CPU 核心 1 ─── L1 缓存 ─── L2 缓存 ───┐
├── L3 缓存 ─── 内存
CPU 核心 2 ─── L1 缓存 ─── L2 缓存 ───┘
核心 1 写入 DATA = 42,可能只写到自己的 L1 缓存
核心 2 读取 DATA,读的是自己缓存里的旧值
更糟的是,编译器和 CPU 可能重排指令:
原本:DATA = 42; READY = true;
重排后:READY = true; DATA = 42;
consumer 可能看到 READY = true 但 DATA 还是 0
这不是 bug,这是优化。 CPU 和编译器为了性能会做各种重排,在单线程下没问题,多线程下就爆炸。
Rust 的解决方案:使用 std::sync::atomic 提供的原子类型和内存序(Ordering),强制程序员显式指定同步语义。这段伪代码中的 static mut 在 Rust 中需要 unsafe 块才能访问,正是为了提醒你:这里有并发风险。
为什么这么难?
让我们总结一下并发编程的核心困难:
| 问题 | 原因 | 类比 |
|---|---|---|
| 竞态条件 | 多个线程同时读写共享变量 | 两个人同时编辑同一个文档 |
| 死锁 | 多个线程互相等待对方持有的锁 | 狭路相逢,都不让 |
| 活锁 | 线程不断重试但永远无法成功 | 两人让路但一直撞到一起 |
| 饥饿 | 某线程永远抢不到资源 | 插队的人太多,老实人永远排不上 |
| 内存可见性 | CPU 缓存导致看到过期数据 | 你改了白板,我看的是昨天拍的照片 |
| 指令重排 | 编译器/CPU 优化改变执行顺序 | 我说的是 ABC,你听到的是 BCA |
核心矛盾:
单线程思维 vs 多线程现实
你的代码看起来是顺序执行的,但实际上可能被打断、重排、缓存。你以为的原子操作,其实不是原子的。你以为的顺序,其实不保证。
人类的反击
面对这些困难,计算机科学家们发明了各种并发模型:
| 模型 | 核心思想 | 代表语言 |
|---|---|---|
| 线程 + 锁 | 共享内存,用锁保护 | Java, C++, Rust |
| 协程 | 用户态轻量级线程 | Go, Python |
| Actor | 不共享内存,用消息传递 | Erlang, Elixir |
| CSP | 通过 Channel 通信 | Go, Rust |
| 所有权 | 编译期防止数据竞争 | Rust |
注意:Rust 出现在多个行中,因为它提供了多种并发工具(锁、Channel、async),但核心特色是所有权系统——无论用哪种方式,都有编译期的安全检查。
每种模型都在回答一个问题:如何让多个执行流安全地协作?
- 线程 + 锁:允许共享,但用锁保护
- Actor:禁止共享,只能发消息
- CSP:禁止共享,通过管道传数据
- Rust 所有权:编译器帮你检查,不安全的代码编译不过
没有银弹。 每种模型都有自己的复杂性和适用场景。
一个真实的故事
2012 年,Knight Capital 在 45 分钟内亏损 4.4 亿美元。
事故简述:
- 部署新代码时,8 台服务器中有 1 台没更新
- 旧服务器上有一个废弃功能 Power Peg(测试用)
- 新代码复用了 Power Peg 的标志位,含义变了
- 旧服务器收到带此标志的请求,触发了测试逻辑
- 系统疯狂下单,45 分钟亏掉 4.4 亿美元
根本原因:
部署不一致 + 废弃代码未清理 + 标志位复用
严格来说,这不是典型的「并发 bug」,而是分布式系统中多个组件状态不一致的问题。但它说明了一个更广泛的道理:当多个执行实体(无论是线程、进程还是服务器)对同一状态的理解不一致时,灾难就会发生。
总结
为什么并发这么难?
- 共享状态是万恶之源:多个线程同时读写,结果不可预测
- 原子性是幻觉:你以为的一步操作,可能是多步
- 顺序性是幻觉:编译器和 CPU 会重排你的代码
- 可见性是幻觉:你写的值,别的线程可能看不到
- 测试不可靠:bug 可能跑 1000 次才出一次
核心认知:
并发编程不是「多开几个线程」那么简单。你需要一种模型来约束和指导你如何处理共享状态。
接下来的几篇文章,我们会逐一探索这些模型:
- 线程 + 锁:最直接,也最危险
- 协程:更轻量,但不解决根本问题
- Actor:不共享,只传消息
- CSP:Channel 是一等公民
- Rust:让编译器帮你检查
下一篇,我们从最经典的模型开始:线程与锁——它能解决问题,但也能创造更多问题。
下一篇:线程与锁:最直接也最危险
本系列: