元数据卡
- 前置知识:Vol 7 分布式系统基础(分布式并发 vs 单机并发的区别)、Vol 9 第2章(线程与内存关系)
- 预计时间:50 分钟
- 核心难度:进阶
- 阅读模式:高度专注
- 可选跳过:STM 部分若难度过高可略读,不影响后续
- 完成标志:能说出 Actor / CSP / STM 三种模型的核心差异;能对比 Java 锁模型和 Go 的 goroutine+channel 模式
你的进度
你推开第三扇门,发现墙上挂满了图纸——不是电路的布线图,而是线程之间怎么说话的设计蓝图。每个文明都在追求一个目标:多个人干活,不打架,不误伤。但他们的方法千差万别:有的修了一座消息走廊,有的建了一个共享广场,有的发明了一台 ATM 机。
你的任务
并发编程是所有语言都必须面对的问题。死锁、竞态条件、内存一致性问题——这些不是 bug,是共享可变状态的必然结果。不同语言选择了不同的并发模型,每种模型都是在回答同一个问题:多个执行体怎么安全地读写数据。
本章分层
- 必读:Actor 模型、CSP 模型、共享内存 + 锁,三者的核心区别
- 选读:STM 软件事务内存
- 进阶:在 Java 中模拟 Actor/CSP 模式
破局 · 溯源
你用一个全局计数器统计网站访问量:
private static int counter = 0;
// 多个线程同时调用
public void increment() {
counter++; // 不是原子操作!
}counter++ 在字节码是三步:读 counter、加 1、写回 counter。两个线程读都读到 0,各自加 1 写回 1——丢了 1 次访问。
你加上了 synchronized:
public synchronized void increment() {
counter++;
}问题解决了。但现在 1000 个线程排队等一把锁——这是共享内存 + 锁模型:数据共享,访问受锁保护。
锁模型逻辑清晰。但它的问题也很明显:死锁怎么办?锁粒度大了性能差,锁粒度小了容易漏。你试过把锁细化,结果陷入了一个"加锁还是解锁"的泥潭。有没有不需要显式锁的方案?
Actor 模型:没有共享,只有消息
Actor 模型的核心原则:任何东西都不共享。每个 Actor 是一个独立的计算单元,有自己的私有状态。Actor 之间只能通过异步消息通信,不共享任何内存。
Actor A ----(消息)----> Actor B
| |
自己的状态 自己的状态Erlang 是这个模型最著名的实践者。Java 版有一个流行的实现叫 Akka。
// Akka Actor 示例
public class CounterActor extends AbstractActor {
private int count = 0; // 私有状态,不共享
@Override
public Receive createReceive() {
return receiveBuilder()
.match(Increment.class, msg -> {
count++; // 只有自己能改自己的状态
})
.match(GetCount.class, msg -> {
getSender().tell(count, getSelf());
})
.build();
}
}
// 使用
ActorRef counter = system.actorOf(Props.create(CounterActor.class));
counter.tell(new Increment(), ActorRef.noSender());
counter.tell(new GetCount(), actorRef); // 异步返回不用锁是怎么做到的? 因为每段代码只操作自己的私有状态。如果需要其他 Actor 的数据,你发消息等回复——就像两个国家之间不能直接改对方的法律,只能发外交文书。
代价:单个 Actor 内部是串行的。如果一个 Actor 需要频繁跟 100 个其他 Actor 通信,消息就是瓶颈。另外,消息序列化/反序列化有性能开销。
Akka Java vs 原始 Erlang:Erlang 的 Actor 是语言层面内置的,进程(轻量级 Actor)由 BEAM 虚拟机调度。Akka 是在 JVM 上通过库实现的——Actor 仍然运行在 JVM 线程之上。两者的理念完全一致,但 Erlang 的 Actor 更"原汁原味"——容错、热更新、进程监控都在 VM 层面。
CSP 模型:不共享数据,通信数据
CSP(Communicating Sequential Processes)经常跟 Actor 混为一谈。它们确实相似——都推崇"不通过共享内存通信,通过通信共享内存"。但有一个关键区别。
Go 的 goroutine + channel 是 CSP:
func counter(ch chan int) {
count := 0
for {
select {
case <-ch: // 收到 increment 信号
count++
case ch <- count: // 有人要查当前值
// 发出去
}
}
}
func main() {
ch := make(chan int)
go counter(ch) // 启动一个 goroutine
ch <- 1 // 发送 increment 信号
ch <- 1
result := <-ch // 获取当前 count
}Actor vs CSP 的区别:
| Actor | CSP | |
|---|---|---|
| 通信方式 | 异步消息 | 同步 channel(默认无缓冲) |
| 发送方 | 发送后继续执行(不阻塞) | 直到对方接收才继续(阻塞) |
| 通道 | Actor 地址(必须有对方引用) | channel 作为中介(解耦) |
| 容错 | 内置(监督树) | 显式处理,没有内置监督 |
CSP 的 channel 是一个显式的独立对象——不像 Actor 那样每个 Actor 都有自己的邮箱。这带来一个好处:channel 可以像值一样传递,被多个 goroutine 共享。
// channel 作为一等公民传递
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
// 创建 channel 并在 goroutines 间共享
jobs := make(chan int, 100) // 带缓冲的 channel
results := make(chan int, 100)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results) // 多个 goroutine 共享 channel
}同步 channel 为什么不是性能杀手? 两件事一推一拉就绪时,Go 运行时直接交接——不经过内核调度。这跟 Actor 的异步消息队列在性能层面没有绝对的优劣势,只是设计哲学不同。
共享内存 + 锁:你正在用的模型
Java 选择的是最直接的路线——共享内存,用 synchronized、ReentrantLock、volatile 等保护访问。
// 精细化的锁
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}优点:最灵活、性能上限最高、符合直觉(大家都改同一个变量)。
缺点:最容易被自己绊倒。
// 死锁的经典配方
class A {
synchronized void methodA(B b) { b.methodX(); }
synchronized void methodX() { }
}
class B {
synchronized void methodB(A a) { a.methodY(); }
synchronized void methodY() { }
}
// 线程1: a.methodA(b) 线程2: b.methodB(a)
// 两人都在等对方释放锁 —— 永远卡死Java 的 java.util.concurrent 包提供了大量"帮你写好锁"的工具——ConcurrentHashMap、AtomicInteger、CountDownLatch——这些都是为了减少你直接操作锁的机会。语言的趋势是"隐藏锁的复杂性",而不是"让你成为锁大师"。
锁模型没有"理论上更好"的方案——它把控制权完全交给你,给了你最大犯错空间。
STM:让并发像数据库事务一样简单
软件事务内存(STM)把数据库事务的概念引入内存操作:
;; Clojure 的 STM 示例
(def counter (ref 0))
(defn increment []
(dosync
(alter counter inc)))dosync 块内部的所有操作是原子的、隔离的。如果两个线程 alter,其中一个会重试。
像不像数据库? 几乎一样——ACID (缺持久化)。STM 的底层通过 MVCC(多版本并发控制)实现,跟数据库的事务引擎思路一致。
STM 在主流语言中并不普及。Java 有第三方的 Multiverse 库,但生产环境用得少。Clojure 是 STM 最知名的代言人,它运行在 JVM 上。
为什么不普及? 两点:一、STM 需要运行时支持(事务日志、冲突检测),增加了开销;二、STM 内部的事务也需要锁(写锁),只是锁的粒度在事务层面。
对比窗口:实现一个"并发计数器"的四种方案
Java 锁方案:
class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); }
public int get() { return count.get(); }
}Java Actor 方案(Akka 伪代码):
// 每个操作发消息,Actor 串行处理
counter.tell(new Increment(), ...);Go CSP 方案:
func counter(ch chan int) {
count := 0
for { count += <-ch }
}
// ch <- 1 // 外部通过 channel 通信Clojure STM 方案:
(def counter (ref 0))
(defn inc-counter [] (dosync (alter counter inc)))四种方案,四种哲学:
| 锁 | Actor | CSP | STM | |
|---|---|---|---|---|
| 共享状态 | 是 | 否 | 否 | 是 |
| 显式锁 | 是 | 否 | 否 | 否 |
| 单点串行 | 否 | 是 | 条件性 | 否 |
| 学习曲线 | 低->高(高级用法) | 中 | 中 | 中 |
| 死锁风险 | 有 | 无 | 无 | 无(事务重试) |
常见陷阱
- "Actor 不会死锁" —— Actor 不会因锁死锁,但会因消息循环死锁(A 等 B 的回复,B 等 A 的回复)
- "goroutine 是轻量级线程" —— 准确说是"用户态线程",跟 OS 线程不是一回事;1000 个 goroutine 比 1000 个 OS 线程轻得多
- "无共享 = 高性能" —— 消息序列化/传递也有成本,共享读比消息传递快(读缓存友好),但共享写需要锁
通关挑战
- 热身:画一张图,展示 Actor/CSP/锁三种模型在 "数据在哪"、"怎么访问"两个维度上的差异
- 动手:在 Java 中分别用
synchronized和AtomicInteger实现计数器,运行 100 个线程各加 10000 次,比较时间 - 观察:运行 Go 的
race detector(go run -race),故意制造数据竞争,看检测器怎么报告
旅人笔记
Actor 消灭了共享,CSP 消灭了直接引用,锁消灭了不可控——没有消灭的是并发本身的复杂性,只是在不同的地方转移了它。
→ 下一站预告
并发模型处理的是"怎么做才能不出错",但高级语言的另一个分支思考的是"能不能根本不出错"——函数式编程。推开第四扇门,看看不可变性和纯函数怎么改变你对"编程"这件事的看法。