3.1 共享内存并发:锁、原子操作与不变量
库存服务可能每次都返回成功,却已经超卖。即使存储的库存没有变成负数,把最后一件商品预订成功两次,也违反了业务规则。日志里没有异常,并不会让仓库多长出一件商品。
并发正确性要求系统在操作任意交错时仍保持不变量;只使用线程安全容器并不能保证这一点。本课从这个判断标准出发,讨论锁、原子操作和状态所有权。
学完要能回答
- 为什么单次读写安全,不代表一组操作安全;
- 互斥、可见性和原子性分别解决什么问题;
- 什么时候应使用锁、原子变量或线程封闭;
- 为什么 GIL、
volatile和线程安全集合都不是万能保护罩。
从交错执行看竞态
下面的判断与扣减在单线程中没有问题:
final class Inventory {
private int stock = 1;
boolean reserveOne() {
if (stock <= 0) {
return false;
}
stock--;
return true;
}
}两个线程可能这样交错:
线程 A:在 stock = 1 时通过检查,并为扣减读到 1
线程 B:在 stock = 1 时通过检查,并为扣减读到 1
线程 A:写入 0,返回成功
线程 B:写入 0,返回成功最后的数值没有变成负数,但系统已经把一件商品卖了两次。竞态让结果取决于未被约束的执行顺序,不一定表现为随机数值。
先写出要保护的不变量:
stock >= 0
成功预订一件商品的次数 <= 初始库存数量 + 累计补货数量锁的边界应覆盖从检查到修改的整个状态转换:
final class Inventory {
private int stock = 1;
synchronized boolean reserveOne() {
if (stock <= 0) return false;
stock--;
return true;
}
}这里要把“检查库存并扣减”作为一个整体保护起来;单独让 stock-- 原子化仍会留下竞态。
锁同时建立两种约束
互斥锁通常同时提供:
- 互斥:同一时刻只有一个执行体进入临界区;
- 内存顺序:按语言内存模型规定,释放锁之前的写入对随后取得同一把锁的线程可见。
因此,锁保护的是一组共享状态及其访问协议,不只是某一行代码。工程上应把锁和它保护的数据放在同一个抽象里,避免调用方绕开协议:
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;
- 多把锁没有固定取得顺序,形成环路等待;
- 返回内部可变对象,使调用方在锁外修改它;
- 临界区太大,正确但吞吐量和尾延迟恶化。
原子变量适合单点状态转换
计数器、序号和简单状态机可以使用原子变量:
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、重试饥饿和内存回收等问题。除非性能测量表明锁确实是瓶颈,否则不要仅因为“无锁听起来更快”就自行实现复杂算法。
少共享,比精巧地共享更可靠
共享内存不是必须共享所有对象。更稳健的优先级通常是:
- 通过适当的发布机制共享深度不可变数据;
- 局部变量和请求上下文由单个任务拥有;
- 通过队列转移数据或所有权;
- 确实需要协作的少量状态,再使用锁或原子操作。
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。
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 保护了完整状态转换,不能只靠多跑几次来证明。这些例子只保护当前进程,不提供持久化或跨实例协调。
完成检查
给“预订座位、扣减余额、记录订单”写出至少两条不变量,再回答:
- 哪些状态必须在同一个原子边界内更新?
- 这个边界位于进程锁、数据库事务,还是消息处理器?
- 超时或重试发生时,怎样避免重复扣减?
如果答案只是“加一把锁”,还没有完成设计。