你的 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」,而是分布式系统中多个组件状态不一致的问题。但它说明了一个更广泛的道理:当多个执行实体(无论是线程、进程还是服务器)对同一状态的理解不一致时,灾难就会发生。

总结

为什么并发这么难?

  1. 共享状态是万恶之源:多个线程同时读写,结果不可预测
  2. 原子性是幻觉:你以为的一步操作,可能是多步
  3. 顺序性是幻觉:编译器和 CPU 会重排你的代码
  4. 可见性是幻觉:你写的值,别的线程可能看不到
  5. 测试不可靠:bug 可能跑 1000 次才出一次

核心认知

并发编程不是「多开几个线程」那么简单。你需要一种模型来约束和指导你如何处理共享状态。

接下来的几篇文章,我们会逐一探索这些模型:

  • 线程 + 锁:最直接,也最危险
  • 协程:更轻量,但不解决根本问题
  • Actor:不共享,只传消息
  • CSP:Channel 是一等公民
  • Rust:让编译器帮你检查

下一篇,我们从最经典的模型开始:线程与锁——它能解决问题,但也能创造更多问题。


下一篇:线程与锁:最直接也最危险

本系列:

  1. 为什么并发这么难(本篇)
  2. 线程与锁:最直接也最危险
  3. 协程:用户态的轻量级线程
  4. Actor 模型:不要共享,要传递
  5. CSP 模型:Channel 是一等公民
  6. Rust 的第三条路:编译期消灭数据竞争
  7. 实战选型:没有银弹,只有场景