两个人同时往同一个杯子里倒水,水会溢出。两个厨师同时往同一道菜里加盐,菜会咸死。这不是计算机问题,而是现实问题——两件事同时修改同一个东西,会出错。
并发模型的全部意义,就是回答一个问题:如何让”同时”变得安全?答案只有三种,对应三种哲学立场,造就了截然不同的语言设计选择。
一、同时:并发、并行、异步的本质区分
在进入哲学答案之前,需要先厘清三个经常被混用的概念——它们描述的是完全不同的现象。
并发(Concurrency) 是逻辑上的多任务处理。一个厨师同时照看三锅菜——他在任意时刻只做一件事,但通过快速切换让三道菜都在推进。并发的核心是结构:如何把程序拆分成可以独立推进的任务单元。
并行(Parallelism) 是物理上的同时执行。三个厨师各炒一锅——他们在同一时刻真正在做不同的事。并行的核心是执行:需要多个物理执行单元(CPU 核心)。
异步(Async) 是一种调度机制,不是并发模型。当一个操作需要等待(I/O),异步不阻塞当前线程,而是让出控制权,让调度器继续处理其他任务。异步的核心是等待时间的利用——在等咖啡的时间里去做其他事。
graph TD A["并发 Concurrency<br/>程序结构:多个任务可以独立推进"] --> B["并行 Parallelism<br/>多核同时执行"] A --> C["异步 Async<br/>等待时让出控制权"] B --> D["多线程 / 多进程"] C --> E["事件循环 / async-await"] style A fill:#4dabf7,color:#fff style B fill:#69db7c,color:#fff style C fill:#ffa94d,color:#fff
关键洞察:并发不一定需要并行(单核 CPU 也能并发),并行一定是并发的特例,异步是实现并发的手段,不是并发的本质。Python 的 async/await 跑在单线程事件循环上,Rust 的 async/await 默认跑在多线程运行时上——同样的语法,背后是不同的并发哲学。
二、三种哲学答案
并发问题的根源是共享的可变状态(Shared Mutable State)。对这个根源有三种处置方式:
共享的可变状态
↓
答案一:保留共享,限制可变 → 互斥(锁)
答案二:保留可变,消灭共享 → 消息传递(隔离)
答案三:消灭"同时"本身 → 单线程(事件循环)答案一:互斥——保留共享,限制可变
哲学立场:悲观主义——假设会冲突,提前阻止。同一时刻只有一件事能修改共享状态,其他人排队等待。
let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || {
let mut n = c.lock().unwrap(); // 拿到锁才能修改,其他线程阻塞
*n += 1;
}); // 离开作用域,锁自动释放互斥的代价是等待——锁争用会串行化本可并行的操作。更深的代价是死锁:两个线程互相等待对方持有的锁,陷入永久僵局(Coffman 1971 年定义了死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待)。预防死锁的核心手段是统一加锁顺序(破坏循环等待条件)。
互斥还有更细粒度的变体:读写锁(RwLock) 允许多个并发读、独占写;无锁编程(Lock-free) 用硬件原语 CAS(Compare-And-Swap)实现原子操作,避免线程阻塞,代价是极高的实现复杂度(ABA 问题、内存回收、内存序控制)。除非有明确的性能需求,应优先使用标准库的 Mutex/RwLock。
答案二:消息传递——保留可变,消灭共享
哲学立场:隔离主义——你的地盘你做主,我的地盘我做主,有话就发消息。如果不共享状态,就根本不会冲突。
CSP(Communicating Sequential Processes),Tony Hoare 1978 年提出——通过通道(channel)通信,而非共享内存:
ch := make(chan int)
go func() { ch <- compute() }() // 把结果发过去,不共享计算过程中的状态
result := <-chActor 模型,Carl Hewitt 1973 年提出——每个 Actor 有私有状态和邮箱,只通过异步消息通信。Erlang 的 “let it crash” 哲学正是建立在完全隔离上:一个 Actor 崩溃不会污染其他 Actor 的状态,监督者(Supervisor)负责重启失败的 Actor。
CSP vs Actor 的关键区别:CSP 的通道是匿名的、同步的(发送方等待接收方就绪);Actor 的消息是异步的、有明确接收者(发到邮箱,接收方按自己的节奏处理)。
Rust 的 mpsc(多生产者单消费者)channel 是消息传递在系统语言中的工程实现:
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
tx.send(42).unwrap(); // 所有权转移,发送后 tx 不再持有这个值
});
let value = rx.recv().unwrap();所有权转移是关键——发送消息就是转让所有权,发送方不再持有,自然消灭了共享。
答案三:单线程——消灭”同时”本身
哲学立场:取消并发——如果只有一个执行者,根本不存在”同时”,冲突就不可能发生。
JavaScript 是这种哲学最彻底的工程实践:单线程事件循环,每次只处理一个任务,通过非阻塞 I/O 在等待时切换到其他任务:
// 没有其他线程能在 await 之间修改共享状态
const data1 = await fetch(url1); // 等待时让出,但不会有人乱改状态
const data2 = await fetch(url2);单线程为什么能处理万级并发(C10K)?因为等待 I/O 不需要线程——一个线程可以同时监听数千个网络连接的事件,每个连接只消耗少量内存,无需独占线程。
致命弱点:CPU 密集型任务会阻塞整个事件循环。如果厨师在切菜(CPU 计算),就没法同时炒菜(处理其他请求)。这是单线程事件循环架构的内在限制,解决方案是将 CPU 密集工作移到 Worker Threads(独立进程,隔离主义)。
三、共享内存的深水区:内存模型
选择了互斥策略,还必须面对一个更底层的问题:什么叫”同时看到相同的值”?
现代 CPU 和编译器为了性能会重排指令、使用缓存。线程 A 写入了一个值,线程 B 不一定立刻能”看见”这个写入——这是硬件层面的现实。内存模型定义了多线程程序中内存操作的可见性顺序,是并发正确性的基础。
顺序一致性(Sequential Consistency):所有操作看起来按某个全局顺序执行,且每个线程内的顺序与代码顺序一致。最直觉,但代价最高——需要额外的同步屏障阻止 CPU/编译器优化。
获取 - 释放(Acquire-Release):更轻量的保证——Release 写入发生后,Acquire 读取一定能看见之前所有的写入。这是 Rust 的默认内存序:
use std::sync::atomic::{AtomicBool, Ordering};
let flag = AtomicBool::new(false);
flag.store(true, Ordering::Release); // 之前的写操作不会被重排到这之后
let val = flag.load(Ordering::Acquire); // 之后的读操作不会被重排到这之前松散一致性(Relaxed):只保证操作的原子性,不保证与其他操作的顺序关系。适合计数器等只关心最终值的场景。
数据竞争 vs 竞态条件
两个概念经常混淆,但描述的是不同层次的问题:
数据竞争(Data Race):两个线程同时访问同一内存位置,至少一个是写入,且没有同步机制。这是内存安全问题——在 C/C++ 中是未定义行为,程序可能崩溃或产生不可预测的结果。Rust 的所有权系统在编译时消灭了数据竞争。
竞态条件(Race Condition):程序的正确性依赖于线程/事件的执行顺序。即使没有数据竞争,也可能有竞态条件——这是逻辑问题,需要程序员自己处理:
# 竞态条件:没有数据竞争,但有逻辑错误
def withdraw(account, amount):
if account.balance >= amount: # 检查
# 另一个线程可能在这里已经扣过款了
account.balance -= amount # 扣款消除数据竞争(加锁或用 Rust)不等于消除竞态条件。竞态条件的解决需要在更高的抽象层次设计原子操作边界。
四、形式化并发模型
理论上,并发问题可以用多种形式化模型来描述和验证:
STM(Software Transactional Memory),受数据库事务启发——把内存操作包装在事务中,由运行时保证原子性。优势是组合性:多个 STM 操作可以组合成更大的事务,无需手动管理锁顺序。Haskell 的 STM 库是典型实现:
transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amount = do
fromVal <- readTVar from
writeTVar from (fromVal - amount)
toVal <- readTVar to
writeTVar to (toVal + amount)
-- 编译器保证这是原子操作,外部观察者要么看到转账前,要么看到转账后数据流模型(Reactive Streams),把程序看作数据在有向图中的流动——节点是计算单元,边是数据依赖。并发问题转化为数据流的调度问题:
fromEvent(document, 'click').pipe(
map(event => event.clientX),
debounceTime(300),
filter(x => x > 100)
).subscribe(x => console.log(x));适用于 UI 事件处理、实时数据流、复杂异步管道——状态被封装在流的变换中,而非暴露为共享变量。
五、三语对照:不同哲学的工程落地
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 并发哲学 | 三种策略混合 | 单线程事件循环 | 编译时证明安全 |
| 并发单位 | 协程 / 线程 / 进程 | Promise / 回调 / Worker | async Task / 线程 |
| 多线程 | 受限(GIL) | Worker Threads | 原生线程 |
| CPU 并行 | multiprocessing | Worker Threads | 线程 / rayon |
| 数据竞争 | 运行时才发现 | 单线程天然安全 | 编译时防止 |
| 通信方式 | Queue / asyncio.Queue | postMessage / BroadcastChannel | mpsc / crossbeam channel |
| 内存共享 | 进程隔离(跨进程需特殊处理) | 不共享(结构化克隆) | Arc / Mutex / Atomic |
| 异步运行时 | asyncio 事件循环 | libuv 事件循环 | Tokio 工作窃取调度器 |
| 取消机制 | asyncio.Task.cancel() | AbortController | tokio::select! / CancellationToken |
| 死锁风险 | 有 | 单线程无死锁 | 编译时防止部分死锁 |
| 学习曲线 | 低 | 低 | 高 |
Python:三种策略都提供,但有历史包袱。GIL(全局解释器锁)是一个粗粒度的互斥,保证了 CPython 内部的引用计数线程安全,代价是多线程无法真正并行执行 Python 字节码。工程上:I/O 密集用 asyncio(单线程),CPU 密集用 multiprocessing(进程隔离,完全的隔离主义)。Python 3.13+ 的 No-GIL(--disable-gil)是可选的,允许生态逐步迁移,单线程性能代价约 10-30%。GIL 不是设计失误,而是在单线程性能、C 扩展兼容性和实现简单性之间做出的历史权衡。
TypeScript:彻底的单线程事件循环。JavaScript 的设计者做了一个激进的选择:取消多线程,用事件驱动解决 I/O 并发。Worker Threads 用于 CPU 密集工作,但 Worker 之间无共享内存(结构化克隆传递数据),是彻底的隔离主义。这个选择的代价是 CPU 密集场景的天花板,换来的是极低的并发编程心智负担——绝大多数 Web 开发者从不需要思考锁和竞态条件。
Rust:“无畏并发(Fearless Concurrency)“的精确含义是:编译器防止数据竞争,不防止竞态条件。Send(可跨线程传递)和 Sync(可跨线程共享引用)是两个 trait,编译器自动推导——一个类型若包含非线程安全的成分,就自动不是 Send,不能传入其他线程。数据竞争从运行时问题变成了编译时错误,代价是理解所有权和借用规则的学习曲线。“无畏”不是说并发变简单了,而是把不安全的代码变成了无法编译的代码。
这三种选择的深层逻辑与类型系统的归属维度一脉相承:Python 把并发安全的责任交给开发者,TypeScript 通过消灭并发来消灭问题,Rust 通过类型系统把安全性编码为编译时证明。
延伸阅读
- Hoare, C.A.R. Communicating Sequential Processes. Prentice Hall, 1985 — 并发模型奠基
- Hewitt et al. (1973). A Universal Modular ACTOR Formalism for Artificial Intelligence — Actor 模型经典
- Tokio Tutorial — Rust async 运行时官方教程
- Node.js Event Loop 文档 — 事件循环详解
关联 meta 维度
- 01 类型系统 — Send/Sync trait 是类型系统在并发领域的延伸;所有权系统消灭数据竞争
- 04 错误处理 — 并发场景下的错误传播比同步场景更复杂(ExceptionGroup、TaskGroup)
- 06 内存管理 — 并发与内存共享直接相关;内存模型是并发正确性的硬件基础
- O 模型 — 并发模型的 I/O 策略决定了异步机制;事件循环的本质是 I/O 多路复用
- 范式: 响应式与事件驱动 — 数据流模型是事件驱动的高层抽象
- Python: 并发与内存模型 — GIL/free-threading 与 asyncio
- TypeScript: 并发与事件模型 — 事件循环与 Promise