Skip to content

元数据卡

  • 前置知识:Vol 7 分布式系统基础(分布式并发 vs 单机并发的区别)、Vol 9 第2章(线程与内存关系)
  • 预计时间:50 分钟
  • 核心难度:进阶
  • 阅读模式:高度专注
  • 可选跳过:STM 部分若难度过高可略读,不影响后续
  • 完成标志:能说出 Actor / CSP / STM 三种模型的核心差异;能对比 Java 锁模型和 Go 的 goroutine+channel 模式

你的进度

你推开第三扇门,发现墙上挂满了图纸——不是电路的布线图,而是线程之间怎么说话的设计蓝图。每个文明都在追求一个目标:多个人干活,不打架,不误伤。但他们的方法千差万别:有的修了一座消息走廊,有的建了一个共享广场,有的发明了一台 ATM 机。

你的任务

并发编程是所有语言都必须面对的问题。死锁、竞态条件、内存一致性问题——这些不是 bug,是共享可变状态的必然结果。不同语言选择了不同的并发模型,每种模型都是在回答同一个问题:多个执行体怎么安全地读写数据。

本章分层

  • 必读:Actor 模型、CSP 模型、共享内存 + 锁,三者的核心区别
  • 选读:STM 软件事务内存
  • 进阶:在 Java 中模拟 Actor/CSP 模式

破局 · 溯源

你用一个全局计数器统计网站访问量:

java
private static int counter = 0;

// 多个线程同时调用
public void increment() {
    counter++;  // 不是原子操作!
}

counter++ 在字节码是三步:读 counter、加 1、写回 counter。两个线程读都读到 0,各自加 1 写回 1——丢了 1 次访问。

你加上了 synchronized

java
public synchronized void increment() {
    counter++;
}

问题解决了。但现在 1000 个线程排队等一把锁——这是共享内存 + 锁模型:数据共享,访问受锁保护。

锁模型逻辑清晰。但它的问题也很明显:死锁怎么办?锁粒度大了性能差,锁粒度小了容易漏。你试过把锁细化,结果陷入了一个"加锁还是解锁"的泥潭。有没有不需要显式锁的方案?


Actor 模型:没有共享,只有消息

Actor 模型的核心原则:任何东西都不共享。每个 Actor 是一个独立的计算单元,有自己的私有状态。Actor 之间只能通过异步消息通信,不共享任何内存。

Actor A ----(消息)----> Actor B
    |                       |
  自己的状态              自己的状态

Erlang 是这个模型最著名的实践者。Java 版有一个流行的实现叫 Akka。

java
// 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:

go
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 的区别

ActorCSP
通信方式异步消息同步 channel(默认无缓冲)
发送方发送后继续执行(不阻塞)直到对方接收才继续(阻塞)
通道Actor 地址(必须有对方引用)channel 作为中介(解耦)
容错内置(监督树)显式处理,没有内置监督

CSP 的 channel 是一个显式的独立对象——不像 Actor 那样每个 Actor 都有自己的邮箱。这带来一个好处:channel 可以像值一样传递,被多个 goroutine 共享。

go
// 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 选择的是最直接的路线——共享内存,用 synchronizedReentrantLockvolatile 等保护访问。

java
// 精细化的锁
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

优点:最灵活、性能上限最高、符合直觉(大家都改同一个变量)。

缺点:最容易被自己绊倒。

java
// 死锁的经典配方
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 包提供了大量"帮你写好锁"的工具——ConcurrentHashMapAtomicIntegerCountDownLatch——这些都是为了减少你直接操作锁的机会。语言的趋势是"隐藏锁的复杂性",而不是"让你成为锁大师"。

锁模型没有"理论上更好"的方案——它把控制权完全交给你,给了你最大犯错空间。


STM:让并发像数据库事务一样简单

软件事务内存(STM)把数据库事务的概念引入内存操作:

clojure
;; 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 锁方案

java
class Counter {
    private AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); }
    public int get() { return count.get(); }
}

Java Actor 方案(Akka 伪代码)

java
// 每个操作发消息,Actor 串行处理
counter.tell(new Increment(), ...);

Go CSP 方案

go
func counter(ch chan int) {
    count := 0
    for { count += <-ch }
}
// ch <- 1  // 外部通过 channel 通信

Clojure STM 方案

clojure
(def counter (ref 0))
(defn inc-counter [] (dosync (alter counter inc)))

四种方案,四种哲学:

ActorCSPSTM
共享状态
显式锁
单点串行条件性
学习曲线低->高(高级用法)
死锁风险无(事务重试)

常见陷阱

  1. "Actor 不会死锁" —— Actor 不会因锁死锁,但会因消息循环死锁(A 等 B 的回复,B 等 A 的回复)
  2. "goroutine 是轻量级线程" —— 准确说是"用户态线程",跟 OS 线程不是一回事;1000 个 goroutine 比 1000 个 OS 线程轻得多
  3. "无共享 = 高性能" —— 消息序列化/传递也有成本,共享读比消息传递快(读缓存友好),但共享写需要锁

通关挑战

  • 热身:画一张图,展示 Actor/CSP/锁三种模型在 "数据在哪"、"怎么访问"两个维度上的差异
  • 动手:在 Java 中分别用 synchronizedAtomicInteger 实现计数器,运行 100 个线程各加 10000 次,比较时间
  • 观察:运行 Go 的 race detector (go run -race),故意制造数据竞争,看检测器怎么报告

旅人笔记

Actor 消灭了共享,CSP 消灭了直接引用,锁消灭了不可控——没有消灭的是并发本身的复杂性,只是在不同的地方转移了它。

下一站预告

并发模型处理的是"怎么做才能不出错",但高级语言的另一个分支思考的是"能不能根本不出错"——函数式编程。推开第四扇门,看看不可变性和纯函数怎么改变你对"编程"这件事的看法。

Built with VitePress | Software Systems Atlas