Reading 21: 并发(Concurrency)

目录 · ← l20 · l22 →

Reading 21: 并发(Concurrency)

说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。

概述

本讲回答三个问题:并发是什么(多个计算同时进行)、并发单元如何组织与通信(共享内存模型 vs 消息传递模型;进程 vs 线程;时间分片)、以及并发为什么危险(交错执行使「顺序执行」的假象被打破,从而产生竞态条件(race condition))。核心结论是:并发是现代软件不可回避的必需品,但它同时是安全性(race condition 极难发现、极难复现)、易理解性(人几乎无法预测交错)与可修改性(共享内存的选择、消息协议的设计会长期约束系统的演化)三个目标的重大威胁。本讲的主要目的之一是「先让你害怕」——后续 Reading 22(异步编程)与 Reading 23(锁与线程安全)会给出有原则的应对方法。

核心概念与设计原则详解

并发(Concurrency)的定义与必要性

  • 定义与目的:并发意味着多个计算同时进行。它不是一个可选的高级特性,而是现代编程的基本处境:网络中的多台计算机、一台机器上的多个应用、一颗芯片上的多个处理器核心,都是并发。写并发程序的能力直接决定系统能否满足性能与交互需求。
  • 直观解释(”它是什么?”):把程序想成一条流水线,单线程的程序只有一条传送带;并发就是同时开动多条传送带,它们可能共享同一个仓库(内存),也可能通过传送消息协作。
  • 关键规则与最佳实践
    • 并发无处不在,无论你喜不喜欢:网站必须同时服务多个用户;移动应用需要把部分处理放到云端的服务器上;GUI 几乎总需要「不打断用户」的后台工作(例如编辑器一边让你改代码一边在后台编译)。
    • 处理器主频不再增长,取而代之的是每一代芯片更多的核心;因此将来想让计算更快,就必须把计算拆成可并发的片段(这是课程给出的对未来程序员的核心判断)。
    • 判断你的程序是否已经并发,不要只看有没有 new Thread:GUI 工具包、Web 服务器、定时器、框架都会在背后创建线程。
    • 并发是手段(为了响应性、吞吐量、利用多核),不是目的;引入并发必须有明确的理由,因为它会显著提高设计与调试成本。

共享内存模型(Shared Memory Model)

  • 定义与目的:在共享内存模型中,并发模块通过读写共享对象来交互。它是最自然、最容易上手的模型(因为「通信」就是普通的读写变量),但也因此把「数据被谁改了、什么时候改的」这一负担留给了程序员。
  • 直观解释(”它是什么?”):几个模块站在同一间房间里,都能看到并改动房间中央的一块白板。图上 A、B 是两个并发模块,蓝色对象是各自私有的(只有一个模块能访问),橙色对象是共享的(两个模块都有它的引用)。
  • 关键规则与最佳实践
    • 典型例子:同一台机器上共享同一块物理内存的两个处理器/核心;共享同一个文件系统的两个程序;同一个程序里共享同一批对象的两个线程
    • 共享内存的危险范围是「所有可达的共享对象」,包括容器里的元素、对象的字段,而不只是那一个显眼的变量。
    • 在 Java 里,线程自动就绪于共享内存:线程共享进程内的全部内存;想要「线程私有」的内存反而需要额外努力。
    • 共享内存模型的正确性依赖于同步机制(锁、原子变量、不可变对象等,见 Reading 23),而不是「小心地写代码」。

消息传递模型(Message Passing Model)

  • 定义与目的:在消息传递模型中,并发模块通过通信通道互相发送消息来交互:模块把消息发出去,到达某个模块的消息被排队等待处理。模块之间不共享可变内存,从结构上减少了「谁什么时候改了共享数据」这类问题。
  • 直观解释(”它是什么?”):模块之间像邮局通信:你寄一封信,对方按顺序拆信、回信;双方各自的房间互不进入。发信人不会停下来等回信——它继续处理自己队列里的其他请求,回信会作为另一条消息到达。
  • 关键规则与最佳实践
    • 典型例子:网络中的两台计算机;浏览器与 Web 服务器;即时通信的客户端与服务器;同一台机器上用管道(pipe)连接输入输出的两个程序,如命令行里的 ls \| grep
    • 进程自动就绪于消息传递:新进程天生带有标准输入输出流(Java 中的 System.in / System.out),可以直接通信。
    • 消息传递并不能消除竞态条件:交错依然存在,只不过交错的是消息的顺序而不是指令的顺序。
    • 关键设计教训:精心选择消息接口的操作粒度。有 get-balancewithdraw 两个操作并不够——需要 withdraw-if-sufficient-funds 这样「判断与修改合为一条消息」的操作。
    • 在 Java 中显式使用消息传递需要自己建立队列数据结构(Reading 24 会讲队列)。

进程(Process)与线程(Thread)

  • 定义与目的:上面两个模型说的是「模块如何通信」,而模块本身有两种:进程线程。理解二者的差异,才能正确判断「共享什么、隔离什么」,从而做出正确的并发设计。
  • 直观解释(”它是什么?”)
    • 进程是运行中程序的实例,与同一台机器上的其他进程相互隔离,尤其拥有自己私有的那一段内存。进程抽象是一台虚拟计算机:程序感觉整台机器只为自己服务,内存是崭新的。任何程序启动时都会创建一个新进程来承载它。
    • 线程是运行中程序内部的控制点(locus of control):可以理解为「程序正在执行的那个位置,加上通向该位置的调用栈」(所以遇到 return 时它能沿栈返回)。线程抽象是一台虚拟处理器:创建线程就像在这台虚拟计算机里新造一个处理器,它运行同一个程序、共享同一片内存。Java 程序启动时有一个线程,它第一步就调用 main(),称为主线程(main thread)
  • 关键规则与最佳实践
    • 进程之间通常不共享内存:一个进程根本无法访问另一个进程的内存或对象;在多数操作系统上共享内存是可能的,但需要特殊手段。跨进程通信天然适合消息传递。
    • 线程之间共享进程内全部内存:「线程本地(thread-local)」的私有内存需要额外努力;要用消息传递则必须显式建立队列。
    • 口诀:进程 ≈ 虚拟计算机,线程 ≈ 虚拟处理器。选进程得到隔离(更安全、更重),选线程得到共享(更快、更危险)。
    • 在 Java 中启动线程:new Thread(runnable).start(),新线程要做的第一件事是调用 Runnable.run();也可以继承 Thread 或使用 lambda。

时间分片(Time Slicing)

  • 定义与目的:当线程数多于处理器数时,并发是通过时间分片「模拟」出来的:处理器在多个线程之间来回切换。它解释了「为什么单核机器上也有并发」,也解释了「为什么交错是不可预测的」。
  • 直观解释(”它是什么?”):课程配图展示了三个线程 T1、T2、T3 在只有两个真实处理器的机器上如何被分片:时间向下流动,起初一个处理器跑 T1、另一个跑 T2,随后第二个处理器切换去跑 T3;T2 就那样暂停,等待它在同一个或另一个处理器上的下一个时间片。图的最右侧显示从每个线程自己的视角看:有时它在处理器上活跃运行,有时它被挂起、等待下一次运行机会。
  • 关键规则与最佳实践
    • 在大多数系统上,时间分片不可预测且不确定(nondeterministically):线程可能在任何时刻被暂停或恢复。
    • 因此不能依赖「这两行相邻的代码之间不会被插入别的线程的动作」——交错可以发生在任意指令边界上。
    • 分片意味着「线程的执行速度」不是程序能控制的东西:负载、调度策略、其他程序、时钟频率都会影响它。
    • 时间片切换点的不可预测性正是竞态条件难以复现的根源(heisenbug 的成因之一)。

交错(Interleaving)与「顺序执行的假象」被打破

  • 定义与目的:交错指并发执行时,实际执行序列会把 A 的操作与 B 的操作任意穿插。(某些操作甚至可能真正同时发生,但本讲先只讨论交错。)理解交错是理解竞态条件的前提。
  • 直观解释(”它是什么?”):高层看起来是「一个动作」的语句,其实会分解成若干条更底层的指令。课程用银行账户的例子展示 deposit()(把余额加一)的内部步骤:读余额(get balance)、加一(add 1)、写回结果(write back)。两个并发的存款操作可能交错成两种结果:
    • 安全交错:A 完整做完(余额 0→1),B 再完整做完(余额 1→2),得 2,两笔存款都在。
    • 危险交错:A 读到 0,B 也读到 0,各自加一,各自写回 1——A 的一块钱丢了。原因是两个操作都基于同一个「过时的读」,写回时没有把对方的更新考虑进去(典型的 read-modify-write 丢失更新)。
  • 关键规则与最佳实践
    • 交错发生在低层指令之间,而不是 Java 语句之间;你看到的一行代码可能对应多条不可分割性未知的机器指令。
    • 「顺序执行的假象」是指:单线程时我们可以按代码顺序推理,而并发下这条推理链失效,正确性变成「对所有可能交错都成立」。
    • 交错的组合数是天文数字,人无法穷举;正确的做法是让代码不依赖交错(用同步或消息把临界区变成不可分割的单位)。
    • 交错不是罕见事件:只要时序合适就会发生,而且往往在负载高的生产环境才暴露。

竞态条件(Race Condition)

  • 定义与目的:竞态条件指程序的正确性(规格的后置条件与不变量的满足)依赖于并发计算 A 与 B 中事件的相对时序。当这种情况发生时,我们说「A 与 B 处在竞态之中」。它是本讲要传达的核心危险。
  • 直观解释(”它是什么?”):有些交错像单进程的顺序执行一样「合法」,会得到正确结果;另一些交错则会产生错误答案——违反规格的后置条件或表示不变量。程序在多数时候跑对,只是因为「运气好」遇上了安全交错。
  • 关键规则与最佳实践
    • 改写法救不了它balance = balance + 1balance += 1++balance 三个版本具有完全相同的竞态。你不能从 Java 源码看出处理器会生成什么指令,也不能判断哪些是原子操作(atomic,不可分割的步骤)。仅仅因为它是「一行 Java」并不意味着它原子;仅仅因为标识符 balance 只出现一次也不意味着它只被触碰一次。典型的现代 Java 编译器为这三个版本生成的代码完全相同
    • 核心教训:你不能靠「看一眼表达式」判断它是否免于竞态
    • 竞态可以出现在共享内存模型的指令交错上,也可以出现在消息传递模型的消息交错上(同一枚硬币的两面)。
    • 处理竞态的正道是设计(不可变性、限制共享、同步、消息接口设计),而不是「多测几次」。

重排序与内存可见性(Reordering)

  • 定义与目的:比交错更糟的是:当使用多个变量与多个处理器时,你甚至不能指望对这些变量的修改按代码顺序出现。编译器与处理器为了性能会在寄存器/缓存里做临时副本,写回(storeback)的顺序可能与代码顺序不同。这直接威胁「用一个标志位通知另一个线程」这类设计的正确性。
  • 直观解释(”它是什么?”):课程的例子中,computeAnswer() 先写 answer = 42,再写 ready = true;另一个线程 useAnswer() 忙等 while (!ready),看到 ready 为真后检查 answer,却发现它仍是 0,于是抛出 RuntimeException("answer wasn't ready!")。原因可以理解为处理器实际上创建了两个临时变量 tmprtmpa 分别摆弄 readyanswer,并且先把 ready 写回、把 answer 留在后面——于是出现了「ready 已置位、answer 还未写入」的窗口。
  • 关键规则与最佳实践
    • 不要用「一个共享标志位」在线程之间传递「准备就绪」信号,除非配合明确的同步原语。
    • 要等待另一个计算完成,使用 Thread.join()、阻塞队列、消息回复、Future.get() 这类有同步语义的机制,而不是忙等。
    • 忙等(beyond 是坏味道)在这里还是错误的:它既不保证可见性,也不保证顺序。
    • 「先写数据、再写标志」这种直觉在单线程里成立,在并发里必须由内存模型与同步手段来保障。

并发单元之间的关系:并发 / 并行 / 交错(Concurrency, Parallelism, Interleaving)

  • 定义与目的:这三个词经常被混用,但它们描述不同层面的事实,区分它们才能准确推理与沟通。
  • 直观解释(”它是什么?”)
    • 并发(concurrency)是「多个计算在时间上重叠发生」这一结构性质:多个计算单元同时存在并推进。单核机器上两个线程也是并发的。
    • 并行(parallelism)是「真正同时在多个处理器/核心上执行」这一物理事实:它需要多个执行资源。并发不要求并行,并行是并发的一种实现方式。
    • 交错(interleaving)是「把多个执行流合并成一个观察到的操作序列」的观察模型:当并行度有限(线程数 > 处理器数)时,时间分片把并行模拟成并发,使得从外部看指令序列像是被任意穿插的。并行执行在观察层面同样可以(在同步点之间)被理解为某种交错。
  • 关键规则与最佳实践
    • 「并发」不等于「更快」:只有可分解为并行片段的工作才因多核而加速;I/O 密集与交互式程序受益于并发是因为响应性,而不是吞吐量。
    • 推理正确性时用的模型是交错(考虑所有可能的操作顺序),而不是「真同时发生」——交错模型更保守也更安全。
    • 线程数多于处理器数时,时间分片是常态;因此即使代码「逻辑上并行」,也可能被分片成任意交错。
    • 进程与线程是并发单元的两种形态(虚拟计算机 vs 虚拟处理器),共享内存与消息传递是通信方式的两种模型——这两组概念相互正交,可以两两组合。

代码示例与对比分析

场景 1:启动一个线程——调用 run() 或忘记 start() vs 正确调用 start()

❌ 错误代码

public class Parcae {
    public static void main(String[] args) {
        Thread nona = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); }
        });
        nona.run();   // bug! 直接调用 run(),没有启动新线程

        Runnable decima = new Runnable() {
            public void run() { System.out.println("measuring"); }
        };
        decima.run(); // bug? 也许本意是要创建一个 Thread?
    }
}

public class Moirai {
    public static void main(String[] args) {
        Thread clotho = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); }
        });
        clotho.start();
        new Thread(new Runnable() {
            public void run() { System.out.println("measuring"); }
        }).start();
        new Thread(new Runnable() {
            public void run() { System.out.println("cutting"); }
        });
        // bug! 这个线程对象被创建了,但从未 start()
    }
}

【错误代码的问题】

  1. nona.run() 完全没有创建新线程:它只是在当前线程(主线程)里同步地执行了 run() 的方法体。输出的 “spinning” 会出现在 main 的执行序列中,与其他代码顺序执行,没有任何并发,也没有交错的可能(程序仍然「看起来对」,所以这个 bug 极其隐蔽)。
  2. 忘记 start()Moirai 里第三个 Thread 对象被创建了却从未启动,于是 “cutting” 永远不会被打印。程序不报错,只是少了一部分行为。这属于「行为悄悄丢失」类 bug。
  3. 可运行线程数被误判Moirai 创建了 3 个 Thread 对象,但只有 2 个线程真正运行;最大同时运行的线程数是 3(主线程 + 两个新线程)而不是 4。对并发规模的错误估计会让性能分析与正确性推理全盘偏离。
  4. 测试假象:在 JUnit 中,如果新线程抛出自旋异常,它不会让测试失败(见场景 3 的解说),这类「线程没跑/线程跑挂了」的错误更难被发现。

✅ 正确代码

public class MoiraiFixed {
    public static void main(String[] args) {
        Thread clotho = new Thread(new Runnable() {
            public void run() { System.out.println("spinning"); }
        });
        clotho.start();   // 启动新线程,异步执行 run()

        new Thread(new Runnable() {
            public void run() { System.out.println("measuring"); }
        }).start();

        // 常见惯用法:把短的、一次性的实现写成 lambda
        new Thread(() -> System.out.println("cutting")).start();
    }
}

【为什么这样更好】 start() 才会真正创建一个新的虚拟处理器:它让新线程异步地调用 run(),并立刻返回,主线程继续执行。这样三个打印分别由三个线程完成,顺序不确定(spinning measuring cutting 的任意排列都可能),这才是本讲想让你看到的真并发。

【代码对比解说】 run()start() 的区别是 Java 并发里最经典的一课:run() 只是一个普通方法,直接调用它得到的是顺序执行start() 才是「创建线程」这个语义动作。还要理解创建 Thread 对象与启动线程是两件事new Thread(...) 只是造了一个对象,start() 才让它跑起来。两处 bug 合起来说明:并发代码的失败模式往往是静默的(少打印一行、顺序变了),而不是抛异常。

【设计原则透视】 这体现了抽象边界Thread 这个抽象承诺「模拟一个新的处理器」,而 run() 是运行在该处理器上的入口点;绕过 start() 直接调 run(),等于越过抽象边界使用了实现细节。也对应 Reading 6(规格说明):start() 的后置条件包含「新线程已经开始执行」,而 run() 只是普通方法调用,两者的规格完全不同。


场景 2:银行账户的共享内存竞态——balance = balance + 1 的三个版本 vs 使读-改-写成为临界区

❌ 错误代码

// 共享内存模型:所有「柜员机」共享同一个账户
// 版本 1
private static int balance = 0;
private static void deposit()  { balance = balance + 1; }
private static void withdraw() { balance = balance - 1; }

// 版本 2
private static void deposit2()  { balance += 1; }
private static void withdraw2() { balance -= 1; }

// 版本 3
private static void deposit3()  { ++balance; }
private static void withdraw3() { --balance; }

// 每个柜员机跑一串「存一块、取一块」的交易,余额本该不变
public static void cashMachine() {
    new Thread(new Runnable() {
        public void run() {
            for (int i = 0; i < TRANSACTIONS_PER_MACHINE; ++i) {
                deposit();    // 放一块钱进去
                withdraw();   // 再取出来
            }
        }
    }).start();
}

public static void main(String[] args) {
    for (int i = 0; i < NUMBER_OF_CASH_MACHINES; ++i) {
        cashMachine();
    }
    // 期望 balance == 0,但常常不是 0
}

【错误代码的问题】

  1. 丢失更新(lost update)deposit 在低层分解为「读 balance → 加 1 → 写回」。两个线程若都先读到 0、各自加一、各自写回 1,则只净增了 1,另一笔存款凭空消失。本题里存入与取出各半,最终余额常偏离 0。
  2. 三个版本完全一样balance + 1+= 1++balance 具有相同的竞态,典型的现代 Java 编译器为三者生成完全相同的代码——所以这不是「写法不够小心」的问题。
  3. 不可判断性:仅凭源码无法得知哪些步骤是原子的;因此无法通过「读代码」来确认安全性。
  4. 难以复现:错误依赖时序,往往在高负载、多核、恰好分片切换的时刻才发生,测试时经常「跑一次对、跑一次错」。

✅ 正确代码

/**
 * 共享内存模型的正确做法(其完整理论在 Reading 23 展开):
 * 让「读-改-写」这个复合动作成为不可分割的临界区,
 * 使得并发的交错无法插入到它内部。
 */
public class Account {
    private int balance = 0;   // 受 this 对象的锁保护

    public synchronized void deposit()  { balance = balance + 1; }
    public synchronized void withdraw() { balance = balance - 1; }
    public synchronized int balance()   { return balance; }

    // Thread safety argument:
    //   the monitor pattern —— rep 由 this 对象的锁保护,
    //   每个公共方法在进入时获取锁、退出时释放;
    //   因此「读 balance — 加一 — 写回」整体对其他线程不可分割。
}
// 补充说明:Java 还提供了原子变量类(java.util.concurrent.atomic),
// 用一条不可分割的指令完成读-改-写(这类 API 的完整讨论超出本讲)
private static final AtomicInteger balance = new AtomicInteger(0);
private static void deposit()  { balance.incrementAndGet(); }
private static void withdraw() { balance.decrementAndGet(); }

【为什么这样更好】 竞态的根源不是「两行代码写得太近」,而是「一个复合动作被别的线程插进来了」。把整个读-改-写放进临界区,交错就只能发生在临界区之间,而不再能插入其内部,于是余额的每一次变化都是不可分割的,任何交错都得到与顺序执行一致的结果。注意:synchronized 是 Java 语言层面的机制,锁、临界区、监视器模式与线程安全论证属于 Reading 23,此处作为「本讲问题的标准答案预告」给出;本讲本身只要求你认识到问题的存在与严重性。

【代码对比解说】 本例的教学价值在于打破一个直觉:你把 deposit()withdraw() 写成两行相邻的代码,并不等于它们会作为整体被执行;甚至 balance = balance + 1 这一行也不等于一个原子动作。课程用「低层指令交错表」把这一点可视化:安全的交错(A 全部做完再做 B)与危险的交错(两边都读到 0)会产生不同结果,而程序无法选择哪一种交错。还有一个重要教训:插入一句 System.out.println(balance) 常常让 bug「消失」——因为打印比算术慢 100–1000 倍,时序被改变了,交错被掩盖了,但并没有修好

【设计原则透视】 这是表示不变量(RI)在并发下的失效:单线程时「余额等于所有已完成交易之和」是一个不变量,竞态会让它被违反。修复的本质是让不变量在「操作的边界」上成立——这正是 Reading 23 中「用锁保护 RI」的思想。也可从抽象边界看:deposit() 的规格承诺「余额增加一元」,这一原子性承诺必须由实现来保证,而不能靠客户端的调用方式。


场景 3:用共享标志位通知「算好了」——忙等 + 重排序 vs join() 或消息传递

❌ 错误代码

// 错误:用共享可变标志位在线程之间传递「准备就绪」,还用了忙等
private boolean ready = false;
private int answer = 0;

// 在线程 1 中运行
private void computeAnswer() {
    // ... 计算很久 ...
    answer = 42;
    ready = true;
}

// 在线程 2 中运行
private void useAnswer() {
    while (!ready) {          // 忙等:既耗费 CPU,也不保证可见性与顺序
        Thread.yield();
    }
    if (answer == 0) {
        throw new RuntimeException("answer wasn't ready!");   // 真的可能抛出来
    }
}

【错误代码的问题】

  1. 重排序导致「标志已置位、数据未写入」:编译器和处理器会在寄存器/缓存里做临时副本,写回顺序可能与代码顺序不同。课程给出的等价画面是:computeAnswer 先把 ready 写回、把 answer 的写回留在后面,于是另一个线程看到 ready == trueanswer 仍是 0,抛出 RuntimeException("answer wasn't ready!")
  2. 可见性没有保证:即便顺序没问题,一个线程的写也不保证被另一个线程及时看到——除非有同步原语建立 happens-before 关系。
  3. 忙等是坏味道也是坏实现while (!ready) Thread.yield() 空转耗费 CPU,且在本例中它不能修复正确性,只是碰运气。
  4. 错误依赖「代码顺序即执行顺序」:这是单线程直觉被错误迁移到并发场景的典型表现,属于易理解性层面的陷阱(代码看起来很对,读代码的人却无法判断它是否对)。

✅ 正确代码

// 正确一:用 join() 等待另一个线程结束,由 JVM 建立同步关系
public class Answer {
    private int answer = 0;   // 只在 compute 线程写、在 join 之后读

    public void compute() {
        // ... 计算很久 ...
        answer = 42;
    }

    public static void main(String[] args) throws InterruptedException {
        Answer a = new Answer();
        Thread worker = new Thread(() -> a.compute());
        worker.start();
        worker.join();               // 阻塞直到 worker 结束;join 返回后能看到它的全部写入
        System.out.println(a.answer);   // 保证打印 42
    }
}
// 正确二:用消息传递代替共享标志位 —— worker 把结果作为消息发回,主线程从队列取
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ArrayBlockingQueue;

public class AnswerByMessage {
    record AnswerMessage(int value) { }   // 消息:结果本身,而不是「结果已就绪」的标志

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<AnswerMessage> inbox = new ArrayBlockingQueue<>(1);
        new Thread(() -> {
            int result = 42;              // ... 计算很久 ...
            inbox.add(new AnswerMessage(result));   // 把答案作为消息发出
        }).start();

        AnswerMessage message = inbox.take();       // 队列的 take() 自带同步语义
        System.out.println(message.value());        // 保证打印 42
    }
}

【为什么这样更好】 两种正确写法都把「等待」交给具备同步语义的原语join() 保证返回之后能看到目标线程的全部写入;BlockingQueueput/take 保证消息的传递伴随可见性与顺序。更重要的是第二种写法在设计上消除了「共享可变标志位 + 共享数据」这个脆弱组合——传的是消息(结果本身),而不是「一个标志说别的地方有数据」。

【代码对比解说】 错误版本有两个独立缺陷:可见性/顺序(需要同步原语)与忙等(浪费 CPU、也让代码更难理解)。正确写法用一个原语同时解决二者。要注意 join()Thread.yield() 的区别:yield() 只是「提示调度器让出时间片」,不建立任何同步关系;join() 是一个有明确规格的同步操作。同理,BlockingQueue.take() 在队列空时会阻塞并挂起线程(不消耗 CPU),这正是消息传递模型里「模块把到达的消息排队等待处理」的 Java 落地方式(队列的深入讨论属于 Reading 24)。

【设计原则透视】 共享内存模型下,「谁先写谁后写」需要同步手段来保证,而不能靠代码顺序;消息传递模型把这件事变成通信本身的语义——用「消息」而不是「共享变量 + 标志」来传递结果,等于把状态限制在单个线程内(线程限制,thread confinement),从而大幅降低竞态面。这也是 Reading 21 结尾强调的:好的并发设计应当让程序员不必去思考交错(易理解性目标)。


场景 4:消息传递的接口设计——两步式 get-balance + withdraw vs 一步式 withdraw-if-sufficient-funds

❌ 错误代码

// 错误:账户模块暴露两个独立的消息,客户端自己组合成「检查再取款」
get-balance
if balance >= 1 then withdraw 1
// Java 侧的等价错误:把「检查」与「扣款」分成两次调用
public class Account {
    private int balance = 0;
    public int getBalance() { return balance; }
    public void withdraw(int amount) { balance -= amount; }   // 不检查余额
}

// 两个柜员机 A、B 同时想取走账户里唯一的一块钱
int b = account.getBalance();          // A、B 都可能读到 1
if (b >= 1) {
    account.withdraw(1);               // 于是两笔取款都执行,账户被透支!
}

【错误代码的问题】

  1. 检查与使用之间的时间窗(TOCTOU)getBalance()withdraw() 是两条独立消息,二者之间可以插入 B 的取款。若账户起初只有 1 元,A 与 B 都读到 1,都认为「余额足够」,于是透支——这在银行业务中是严重的正确性事故。
  2. 交错的对象变成了消息:共享内存里交错的是低层指令,这里交错的是发往账户的消息顺序。危险依然存在,只是换了形态——「消息传递能消除竞态」是错误认知。
  3. 把不变量的维护责任推给了客户端:「余额不得为负」是账户的不变量,却要由每个客户端自己检查,一旦有客户端忘了检查就破坏不变量。
  4. 抽象边界过窄:只暴露 withdraw 迫使客户端做「读—判断—写」这一复合动作,而复合动作恰恰是不安全的来源。

✅ 正确代码

// 正确:把「判断资金是否充足」与「扣款」合成一条消息
withdraw-if-sufficient-funds 1
/**
 * 账户模块:把复合动作封装成单一操作,
 * 由账户自己(在其内部)原子地完成「检查 + 扣款」。
 */
public class Account {
    private int balance;

    public Account(int initialBalance) {
        if (initialBalance < 0) throw new IllegalArgumentException("negative balance");
        this.balance = initialBalance;
    }

    /** @param amount must be > 0
     *  @return true if the withdrawal succeeded and the balance was reduced;
     *          false if the balance was insufficient (balance unchanged) */
    public synchronized boolean withdrawIfSufficientFunds(int amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        if (balance < amount) return false;   // 不变量:余额永不为负
        balance -= amount;
        return true;
    }

    public synchronized int balance() { return balance; }
}

// 客户端只需要发一条消息,并根据布尔回复决定行为
boolean ok = account.withdrawIfSufficientFunds(1);
if (!ok) System.out.println("insufficient funds");

【为什么这样更好】 复合动作被移进账户模块内部,成为一条消息、一个原子操作,客户端之间再也无法插进「检查」与「扣款」中间。不变量「余额永远不为负」由账户自己守护,而不是寄希望于每个客户端都记得检查。返回值让客户端仍能得知结果(消息传递中「回复」也是一条消息)。

【代码对比解说】 这是本讲最有工程价值的教训:并发设计很大程度上是接口设计。当接口把一个复合动作拆成几步暴露出去时,客户端就必须自己去保证那几步之间不被干扰——而客户端做不到这件事(它无法控制其他客户端的消息何时到达)。把动作合并成一条消息,就让「不可分割性」成为服务方的职责。这也是消息传递模型优于共享内存的地方之一:你可以在协议层面只提供安全的操作,而不像共享内存那样必须暴露每一个字段的读写。同样的思路在阅读材料的练习里被明确点出:withdraw-if-sufficient-funds 比单纯的 withdraw 是更好的操作。

【设计原则透视】规格说明直接相关:「余额不为负」是账户的不变量,应当由实现来保证(前置条件只在客户端可控时才有意义——而这里客户端不可控)。与抽象边界相关:账户的抽象应当提供「有意义的原子业务操作」,而不是把表示层的读写原语暴露出去(对比 Reading 10/11 中「不做表示暴露」的思想)。与线程安全相关:synchronized 使这条消息不可分割,属于监视器模式的预览(Reading 23)。


与其他设计原则的关联

  • Reading 20(回调与 GUI):本讲是它的直接延续。GUI 工具包在创建第一个组件时就会创建事件处理线程,HttpServer 也会创建网络线程,因此回调式系统天生并发:回调可能在任意时刻、在另一个线程上被调用,监听器之间共享的可变状态就成了本讲要讨论的竞态对象。
  • Reading 8(不可变性):不可变对象没有「写」,因此不可能出现本讲的丢失更新问题——「让共享数据不可变」是避免竞态最有力的手段之一,会在后续并发设计中反复使用。
  • Reading 11(抽象函数、表示不变量;以及线程安全论证):竞态的本质是不变量被并发操作破坏;本讲中 Counter/Account 的线程安全论证(监视器模式)是 Reading 11 报告格式在并发语境下的延伸。
  • Reading 6/7(规格说明与设计规格):本讲中「正确性依赖于相对时序」的定义本身就是以规格的后置条件与不变量为参照的;场景 4 更说明接口(规格)设计直接决定并发安全性。
  • Reading 22(异步编程)与 Reading 23(锁与线程安全):本讲只负责「吓你一下」——指出问题;Reading 23 提供锁、临界区、监视器模式、死锁与锁顺序等系统性答案;Reading 22 讨论异步回调与 Promise/CompletableFuture 式的组合(Java 对应写法见该讲)。
  • Reading 24(队列):消息传递模型在 Java 中落地需要队列数据结构(BlockingQueue 等),那是 Reading 24 的主题;本讲的「消息排队等待处理」正是它的语义来源。
  • Reading 25(Socket 与网络):跨机器的并发是消息传递模型最自然的实例,也是 Reading 20 中 HttpServer 是并发系统的原因。

关键要点

  • 并发是必需品但也是危险源:处理器主频不再提升、核心数不断增加,要让计算更快就必须拆出并发;但并发直接威胁 Safe from bugs / Easy to understand / Ready for change 三个目标。
  • 通信有两种模型,单元有两种形态:共享内存 vs 消息传递描述「怎么通信」,进程(虚拟计算机)vs 线程(虚拟处理器)描述「谁在并发」;两组概念正交,可两两组合。
  • 交错打破了顺序执行的假象:一行代码不等于一个原子操作,balance+1+=1++balance 三个版本编译器生成同样的代码——无法凭代码外观判断是否免于竞态
  • 竞态条件 = 正确性依赖事件的相对时序:有些交错得到正确结果,有些违反后置条件或不变量;重排序还会让「先写数据再写标志」这类直觉失效。
  • 设计胜于测试:并发 bug 是 heisenbug,不可复现、打印与调试器会让它消失;正确道路是消除共享可变状态、用不可变对象、用同步原语、把复合动作设计成单一消息。

常见陷阱与注意事项

  • 调用 run() 而不是 start(),或创建了 Thread 却忘记 start() → 代码根本不并发(前者)或部分行为永不发生(后者);程序不报错,只是结果或输出静默地不对。
  • 以为「一行 Java 代码」是原子的balance = balance + 1 在并发下丢失更新;+=++ 同样不安全,三种写法等价。
  • 用共享标志位 + 忙等在线程间传递「已就绪」 → 重排序与可见性问题可能导致「标志已置位而数据未写入」,从而抛出莫名其妙的异常;应改用 join()、阻塞队列、消息回复等有同步语义的原语。
  • 认为消息传递就没有竞态 → 交错只是从「指令交错」变成「消息交错」;get-balance 后再 withdraw 依然会透支,接口必须提供 withdraw-if-sufficient-funds 这类原子操作。
  • 用打印语句或调试器「验证」并发代码 → 打印比正常操作慢 100–1000 倍,会显著改变时序从而掩盖 bug(heisenbug 看起来消失了),但并未修复;生产环境时序一变就会重现。
  • 把「测试通过」当作并发正确性的证据:在线程里抛出的异常不会传播到 JUnit 的测试线程,new Thread(() -> { throw new Error("oops"); }).start(); 的测试照样通过(若 JUnit 结束时调用 System.exit(),慢线程中的栈迹甚至可能来不及打印);而并发 bug 本身也极难用测试发现、更难定位,需要显式收集线程内的失败(如 Future、队列回传异常)并做有针对性的并发设计。

思考题(带答案)

问题 1:假设 int balance = 0,两个线程 A、B 各自执行 balance = balance + 1;。请写出所有可能的最终值,并解释为什么 balance += 1++balance 并不会让情况变好。

答案deposit 在低层分解为三步:读 balance、加 1、写回结果。设 A 的三步为 a1(读到 0)、a2(算得 1)、a3(写回 1),B 同理。若交错使得两个「读」都发生在任何「写」之前(例如 a1、b1、a2、b2、a3、b3,或 a1、b1、a3、b3 交错在中间),则两次写回的都是 1,最终 balance == 1——一次更新被丢失;若 A 的写回发生在 B 的读之前(a1、a2、a3、b1、b2、b3),则最终 balance == 2。因此最终值可能是 1 或 2,取决于交错的相对时序,这正是竞态条件:正确性依赖于并发计算中事件的相对时序。balance += 1++balance 并不会更好,因为「复合赋值」与「自增」只是 Java 语法上的简写,它们同样必须完成「读—改—写」这个复合动作;Java 语言并不承诺把一个复合赋值编译成一条原子指令。事实上课程明确指出:典型的现代 Java 编译器会为这三个版本生成完全相同的代码。这就得出本讲的核心教训——你不能通过观察表达式的写法来判断它是否安全,它是否原子取决于编译器与处理器生成的底层操作。要修复它,必须让「读—改—写」整体成为不可分割的临界区(如 synchronized),或使用原子变量类(AtomicInteger.incrementAndGet())等专门提供原子性的设施。

问题 2:课程说「进程像一台虚拟计算机,线程像一个虚拟处理器」。请据此说明:为什么线程天生适合共享内存模型,而进程天生适合消息传递模型?并解释「并发」「并行」「交错」三者的关系。

答案:进程是运行中程序的实例,与同机其他进程相互隔离,拥有自己私有的内存段,因此一个进程根本无法访问另一个进程的内存或对象——它在抽象上就是一台独立的计算机,跨进程通信自然只能靠网络式/管道式的消息传递(新进程天生带标准输入输出流)。线程则是进程内部的控制点:创建线程相当于在这台虚拟计算机里新造一个处理器,它运行同一个程序、共享同一个进程的全部内存——因此在抽象上它天生就绪于共享内存(想要线程私有的内存反而需要额外努力)。二者恰好互补:进程得到隔离(更安全、更重),线程得到共享(更快、更危险)。关于三个概念的关系:并发是结构性质,指多个计算在时间上重叠地推进——单核上的两个线程也构成并发;并行是物理事实,指多个处理器/核心真正同时执行,它需要多个执行资源,是并发的一种实现方式(并发不要求并行);交错是观察模型,指把多个执行流合并成一个操作序列来看,当线程数多于处理器数时由时间分片模拟出并发,使指令序列看起来被任意穿插。推理正确性时应当采用交错模型(考虑所有可能的操作顺序),因为它比「真同时发生」更保守、更安全:任何在交错模型下正确的实现,在真并行下也正确。

问题 3:账户模块原本提供 getBalance()withdraw(amount) 两个操作,两个客户端可能同时透支账户。请说明错误发生的机制,给出修复后的接口设计,并解释为什么「客户端的代码写得再小心」也解决不了这个问题。

答案:机制是「检查与使用之间的时间窗」:客户端先发送 get-balance 消息读到余额为 1,判断「足够取 1 元」,然后发送 withdraw 1。在这两条独立消息之间,另一个客户端的消息可以插进来,它也读到 1、也判断足够、也取走 1 元,于是账户余额变成 −1,破坏了「余额不得为负」这个不变量。这与共享内存里的指令交错是同一个问题的两种形态:交错的对象由「指令」变成「消息」,竞态并未消失。修复方式是把复合动作合并成单一操作:账户只提供 withdrawIfSufficientFunds(amount),由账户自己在内部原子地完成「检查资金 + 扣款」,并返回布尔值告诉调用者成功与否(例如 public synchronized boolean withdrawIfSufficientFunds(int amount):先检查 amount > 0,若 balance < amount 返回 false 且不改动余额,否则扣款并返回 true)。这样「检查」与「扣款」之间不存在任何可被其他消息插入的窗口。客户端写得再小心也没用的原因在于:客户端无法控制其他客户端的消息何时到达,它也无法让自己的两条消息「一起」送达——原子性是服务方的职责,只有把动作合并在一条消息内部,服务方才能保证它不可分割。反过来说,接口只暴露 withdraw 就等于把「维护不变量」的责任推给所有客户端,任何一个客户端疏忽(或未来新增的客户端疏忽)都会破坏账户的不变量。这正是本讲最有价值的工程结论:并发安全性很大程度上是接口设计与规格设计的问题,而不只是加锁的问题。