跳到内容

第 9 卷 / 第 3 章 / 第 1 课

3.1 共享内存并发:锁、原子操作与不变量 ​

库存服务可能每次都返回成功,却已经超卖。即使存储的库存没有变成负数,把最后一件商品预订成功两次,也违反了业务规则。日志里没有异常,并不会让仓库多长出一件商品。

并发正确性要求系统在操作任意交错时仍保持不变量;只使用线程安全容器并不能保证这一点。本课从这个判断标准出发,讨论锁、原子操作和状态所有权。

学完要能回答 ​

  • 为什么单次读写安全,不代表一组操作安全;
  • 互斥、可见性和原子性分别解决什么问题;
  • 什么时候应使用锁、原子变量或线程封闭;
  • 为什么 GIL、volatile 和线程安全集合都不是万能保护罩。

从交错执行看竞态 ​

下面的判断与扣减在单线程中没有问题:

java
final class Inventory {
    private int stock = 1;

    boolean reserveOne() {
        if (stock <= 0) {
            return false;
        }
        stock--;
        return true;
    }
}

两个线程可能这样交错:

text
线程 A:在 stock = 1 时通过检查,并为扣减读到 1
线程 B:在 stock = 1 时通过检查,并为扣减读到 1
线程 A:写入 0,返回成功
线程 B:写入 0,返回成功

最后的数值没有变成负数,但系统已经把一件商品卖了两次。竞态让结果取决于未被约束的执行顺序,不一定表现为随机数值。

先写出要保护的不变量:

text
stock >= 0
成功预订一件商品的次数 <= 初始库存数量 + 累计补货数量

锁的边界应覆盖从检查到修改的整个状态转换:

java
final class Inventory {
    private int stock = 1;

    synchronized boolean reserveOne() {
        if (stock <= 0) return false;
        stock--;
        return true;
    }
}

这里要把“检查库存并扣减”作为一个整体保护起来;单独让 stock-- 原子化仍会留下竞态。

锁同时建立两种约束 ​

互斥锁通常同时提供:

  1. 互斥:同一时刻只有一个执行体进入临界区;
  2. 内存顺序:按语言内存模型规定,释放锁之前的写入对随后取得同一把锁的线程可见。

因此,锁保护的是一组共享状态及其访问协议,不只是某一行代码。工程上应把锁和它保护的数据放在同一个抽象里,避免调用方绕开协议:

java
final class Account {
    private final Object lock = new Object();
    private long balanceInCents;

    Account(long initialBalance) {
        if (initialBalance < 0) throw new IllegalArgumentException();
        balanceInCents = initialBalance;
    }

    boolean debit(long amount) {
        if (amount <= 0) throw new IllegalArgumentException();
        synchronized (lock) {
            if (balanceInCents < amount) return false;
            balanceInCents -= amount;
            return true;
        }
    }
}

常见失败方式包括:

  • 用不同的锁保护同一份状态;
  • 在持锁期间执行网络请求或磁盘 I/O;
  • 多把锁没有固定取得顺序,形成环路等待;
  • 返回内部可变对象,使调用方在锁外修改它;
  • 临界区太大,正确但吞吐量和尾延迟恶化。

原子变量适合单点状态转换 ​

计数器、序号和简单状态机可以使用原子变量:

java
import java.util.concurrent.atomic.AtomicInteger;

final class Quota {
    private final AtomicInteger remaining = new AtomicInteger(100);

    boolean tryAcquire() {
        while (true) {
            int current = remaining.get();
            if (current == 0) return false;
            if (remaining.compareAndSet(current, current - 1)) {
                return true;
            }
        }
    }
}

compareAndSet 表达的是:只有当前值仍等于我刚看到的值,才提交新值,否则重读并重试。它没有让竞争消失,而是把冲突变成显式重试。

原子变量的边界也很清楚:如果不变量跨越多个变量,分别使用原子类型通常不够。例如“从账户 A 扣款并给账户 B 入账”不能靠两个独立的 AtomicLong 自动获得事务性。此时要么用同一把锁覆盖组合操作,要么把状态收拢到一个可原子替换的不可变值,要么把一致性交给数据库事务。

无锁算法还要处理 ABA、重试饥饿和内存回收等问题。除非性能测量表明锁确实是瓶颈,否则不要仅因为“无锁听起来更快”就自行实现复杂算法。

少共享,比精巧地共享更可靠 ​

共享内存不是必须共享所有对象。更稳健的优先级通常是:

  1. 通过适当的发布机制共享深度不可变数据;
  2. 局部变量和请求上下文由单个任务拥有;
  3. 通过队列转移数据或所有权;
  4. 确实需要协作的少量状态,再使用锁或原子操作。

Rust 会用所有权以及 Send、Sync 等约束,把一部分跨线程错误提前到编译期;但一旦使用 Arc<Mutex<T>>,开发者仍须设计锁粒度、不变量和故障策略。类型系统能缩小错误空间,不能代替并发设计。

别把运行时特性误当业务锁 ​

传统 CPython 构建中的 GIL 约束了同一时刻执行 Python 字节码的线程,但解释器会切换线程,也会在阻塞 I/O 等位置释放 GIL。共享业务状态仍需要锁或队列。自 Python 3.13 起,CPython 还提供可选的 free-threaded 构建,代码更不应依赖“反正有 GIL”这一假设。

同样:

  • Java 的 volatile 主要提供可见性和顺序保证,不会把任意“读、改、写”组合变成原子操作;
  • 线程安全集合只保证其承诺的单个操作,contains 后再 add 仍可能是竞态;
  • 数据库单条更新原子,不代表跨多条记录的业务不变量自然成立。

把任务生命周期也纳入正确性 ​

共享状态正确只是第一层。线程或任务还需要回答:

  • 谁启动它,谁等待它结束?
  • 请求超时后,后台工作是否继续修改状态?
  • 取消信号怎样传播?
  • 异常由谁观察,还是悄悄丢失?
  • 服务关闭时,资源怎样释放?

“启动后不再管理”的后台线程会把局部故障变成进程级隐患。第三课会用结构化并发把这些问题收拢到词法作用域。

选择速查 ​

场景优先工具原因
单调计数、简单标志原子变量状态转换小且独立
多字段共同维护不变量锁或事务需要整体提交
读多写少的不可变快照原子替换引用读路径简单
请求内部临时状态线程/任务封闭从根源消除共享
生产者与消费者有界队列或 Channel同时表达交接与背压

运行并发检查 ​

把上面带 synchronized 的 Inventory 保存为 Inventory.java,Quota 示例连同 import 保存为 Quota.java,下面程序保存为 InventoryCheck.java。使用 JDK 17 或更高版本执行 javac Inventory.java Quota.java InventoryCheck.java,再运行 java InventoryCheck。

java
import java.util.concurrent.atomic.AtomicInteger;

class InventoryCheck {
    public static void main(String[] args) throws InterruptedException {
        Inventory inventory = new Inventory();
        Quota quota = new Quota();
        AtomicInteger reserved = new AtomicInteger();
        AtomicInteger acquired = new AtomicInteger();
        Thread[] workers = new Thread[16];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = new Thread(() -> {
                for (int j = 0; j < 20; j++) {
                    if (inventory.reserveOne()) reserved.incrementAndGet();
                    if (quota.tryAcquire()) acquired.incrementAndGet();
                }
            });
            workers[i].start();
        }
        for (Thread worker : workers) worker.join();
        System.out.println(reserved.get());
        System.out.println(acquired.get());
    }
}

320 次尝试结束后,输出必须是 1 和 100。所有工作线程共享同一对对象,主线程等待它们全部结束后才读取计数。换成无同步 Inventory 可能破坏第一个计数,也可能恰好通过某次运行;同步版本的正确性依据是共同 monitor 保护了完整状态转换,不能只靠多跑几次来证明。这些例子只保护当前进程,不提供持久化或跨实例协调。

完成检查 ​

给“预订座位、扣减余额、记录订单”写出至少两条不变量,再回答:

  1. 哪些状态必须在同一个原子边界内更新?
  2. 这个边界位于进程锁、数据库事务,还是消息处理器?
  3. 超时或重试发生时,怎样避免重复扣减?

如果答案只是“加一把锁”,还没有完成设计。

参考资料 ​

Built with VitePress | Software Systems Atlas