两个人同时往同一个杯子里倒水,水会溢出。两个厨师同时往同一道菜里加盐,菜会咸死。这不是计算机问题,而是现实问题——两件事同时修改同一个东西,会出错

并发模型的全部意义,就是回答一个问题:如何让”同时”变得安全?答案只有三种,对应三种哲学立场,造就了截然不同的语言设计选择。

一、同时:并发、并行、异步的本质区分

在进入哲学答案之前,需要先厘清三个经常被混用的概念——它们描述的是完全不同的现象。

并发(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 := <-ch

Actor 模型,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 事件处理、实时数据流、复杂异步管道——状态被封装在流的变换中,而非暴露为共享变量。

五、三语对照:不同哲学的工程落地

维度PythonTypeScriptRust
并发哲学三种策略混合单线程事件循环编译时证明安全
并发单位协程 / 线程 / 进程Promise / 回调 / Workerasync Task / 线程
多线程受限(GIL)Worker Threads原生线程
CPU 并行multiprocessingWorker Threads线程 / rayon
数据竞争运行时才发现单线程天然安全编译时防止
通信方式Queue / asyncio.QueuepostMessage / BroadcastChannelmpsc / crossbeam channel
内存共享进程隔离(跨进程需特殊处理)不共享(结构化克隆)Arc / Mutex / Atomic
异步运行时asyncio 事件循环libuv 事件循环Tokio 工作窃取调度器
取消机制asyncio.Task.cancel()AbortControllertokio::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 维度