AtomicBoolean + CAS实现本地乐观锁
前言
在一个带有 流式输出 、任务取消、排队限流、异步回调和分布式部署的后台系统里,经常会遇到同一个问题:一个业务动作在逻辑上只能发生一次,但在运行时可能被多个线程、多个回调、多个异常路径同时触发。
典型场景包括:
- 用户连续点击"停止生成"。
- 前端断开连接,同时后端模型流也结束。
- 模型请求失败,同时业务侧也触发取消。
- 排队请求刚拿到资源,同时又被超时任务取消。
- 定时心跳任务正在续期,同时应用关闭或锁已丢失。
这些问题的共同点不是"业务流程复杂",而是"终态竞争复杂"。多个线程都认为自己应该收尾,但业务只允许一个线程真正完成收尾。
这类问题很适合使用 AtomicBoolean 或基于 CAS 的状态机来解决。它们不是为了替代分布式锁,而是在单个 JVM 内部,为某一个本地对象提供轻量、可靠、无锁的一次性控制。
技术原理
1. CAS 解决的不是分布式互斥,而是本地并发抢占
AtomicBoolean.compareAndSet(false, true) 可以理解成一个"一次性门闩":
bash
if (!closed.compareAndSet(false, true)) {
return;
}
// 只有第一个线程能执行到这里
doClose();
它表达的是:
- 如果当前状态还是
false,就把它改成true,并继续执行。 - 如果当前状态已经是
true,说明有人做过了,当前线程直接返回。 - 这个判断和修改是一个原子动作,中间不会被其他线程插入。
所以它特别适合保护"只能做一次"的本地动作,例如关闭连接、取消请求、释放资源、写入终态、结束 Trace。
需要特别强调:这个 CAS 是本进程内的并发控制,不具备跨机器互斥能力。它只能保护当前 JVM 里的对象状态。
如果系统是分布式部署,通常需要两层协作:
bash
分布式层:Redis / MQ / 数据库负责把信号送到正确节点
本地层:AtomicBoolean / AtomicReference 负责让本节点只执行一次
比如一个流式任务只会在某一台服务实例里持有 SSE 连接和模型 HTTP 连接。Redis 可以负责广播"某个 taskId 要取消",但真正取消底层连接的动作,还是由持有该任务的那台机器用本地 CAS 保证只执行一次。
2. 模式一:本地一次性门闩
最常见的写法是:
bash
private final AtomicBoolean cancelled = new AtomicBoolean(false);
public void cancel() {
if (!cancelled.compareAndSet(false, true)) {
return;
}
cancelRemoteCall();
persistPartialResult();
notifyClient();
releaseResource();
}
这个模式适合业务动作只有两个状态:
bash
未执行 -> 已执行
项目里的典型业务背景是流式回答取消。用户点击停止后,请求会带着任务号进入后端。由于用户可能重复点击、网络可能重试、Redis 广播可能重复到达,取消逻辑可能被触发多次。
但对一个具体任务来说,真正的取消只应该执行一次:
- 底层模型连接只取消一次。
- 已生成内容只保存一次。
- 前端只收到一组
cancel/done终态事件。 - SSE 连接只关闭一次。
所以任务对象内部使用一个本地 AtomicBoolean,谁先把状态从 false 改成 true,谁就获得"执行取消流程"的资格。其他线程看到已经是 true,直接跳过。
3. 模式二:保护非线程安全资源的生命周期
流式输出通常依赖长连接,例如 SSE。SSE 连接本身是一条请求对应一个连接对象,但这个连接对象可能被多个线程同时操作:
- 模型读取线程向前端发送增量内容。
- 正常完成线程发送完成事件并关闭连接。
- 取消线程发送取消事件并关闭连接。
- Servlet 容器在线程中触发超时、断开、异常回调。
- 发送失败后进入异常关闭路径。
这时需要为连接包装一个本地关闭状态:
bash
private final AtomicBoolean closed = new AtomicBoolean(false);
public void send(Object event) {
if (closed.get()) {
return;
}
try {
doSend(event);
} catch (Exception ex) {
fail(ex);
}
}
public void complete() {
if (closed.compareAndSet(false, true)) {
doComplete();
}
}
public void fail(Throwable ex) {
if (closed.compareAndSet(false, true)) {
doCompleteWithError(ex);
}
}
这里的 AtomicBoolean 不是全局状态,而是每条连接自己的本地状态。
这很关键:一条 SSE 连接不会跨机器共享,也不应该跨用户共享。它只存在于处理这次 HTTP 请求的那台服务实例中。因此不需要 Redis 维护"连接是否关闭",只需要在本机内保证多个线程看到一致的关闭状态。
这种设计解决的是:
- 不重复关闭连接。
- 连接关闭后尽量不再发送事件。
complete()和completeWithError()不互相打架。- 客户端断开、后端完成、后端取消三种终态路径只能有一个真正生效。
需要注意,closed.get() 不能彻底消除所有竞态。可能出现"检查时还没关闭,真正发送时已经断开"的窗口。因此发送异常仍然需要统一进入 fail(),再由 CAS 做最终兜底。
4. 模式三:底层请求取消句柄的幂等保护
流式 模型 调用通常底层是一个 HTTP 长请求。取消时需要做两件事:
- 通知读取循环停止。
- 取消底层 HTTP call。
这也适合用本地一次性门闩:
bash
private final AtomicBoolean once = new AtomicBoolean(false);
private final AtomicBoolean cancelled = new AtomicBoolean(false);
public void cancel() {
if (!once.compareAndSet(false, true)) {
return;
}
cancelled.set(true);
call.cancel();
}
这里有两个状态:
once:保证真正的取消动作只执行一次。cancelled:让异步读取循环能感知"我该停了"。
业务意义是双保险:
- 上层任务管理器保证"业务取消流程"只跑一次。
- 底层取消句柄保证"HTTP 模型请求"只取消一次。
即使上层因为超时、用户取消、异常兜底等路径重复调用底层 cancel,底层也能保持幂等。
5. 模式四:流式回调的终态只收一次
流式调用一般有多个终态来源:
- 模型正常完成,触发
onComplete。 - 模型异常,触发
onError。 - 用户取消,触发外部取消收尾。
这些终态在业务上互斥。一个流式节点不应该既成功又失败,也不应该先失败后又被标记取消。
可以使用一个 finished 门闩:
bash
private final AtomicBoolean finished = new AtomicBoolean(false);
private void finishOnce(Status status, Throwable error) {
if (!finished.compareAndSet(false, true)) {
return;
}
recordFinalStatus(status, error);
}
这种模式在链路追踪、指标统计、计费、资源释放中很常见。
它保护的是"终态写入":
- Trace 节点只能从
RUNNING进入一个最终状态。 - 计时只能结束一次。
- 错误信息不能被后来的取消覆盖。
- 成功完成也不能被后来的异常覆盖。
同类思路也可以用于"首包只记录一次":
bash
if (firstContentSeen.compareAndSet(false, true)) {
recordFirstTokenLatency();
}
这里保护的是"首次事件",不是终态事件。
6. 模式五:多终态状态机
当业务状态不止两个时,AtomicBoolean 就不够表达了。比如排队限流中的请求可能有多个终态:
bash
PENDING -> GRANTED
PENDING -> TIMED_OUT
PENDING -> CANCELLED
这时更适合用 AtomicReference<State>:
bash
enum State {
PENDING,
GRANTED,
TIMED_OUT,
CANCELLED
}
private final AtomicReference<State> state = new AtomicReference<>(State.PENDING);
void timeout() {
if (!state.compareAndSet(State.PENDING, State.TIMED_OUT)) {
return;
}
cleanup();
onTimeout();
}
boolean grant() {
if (!state.compareAndSet(State.PENDING, State.GRANTED)) {
return false;
}
runBusiness();
return true;
}
这个模式的核心是:所有终态都从同一个 PENDING 状态抢占。
谁先 CAS 成功,谁就是最终状态。其他路径必须放弃业务回调,只做必要的幂等清理。
这类状态机常用于:
- 排队请求被放行、超时、取消之间的竞争。
- 资源 permit 的交接与释放。
- 定时任务执行权的转移。
- 异步任务的成功、失败、取消互斥。
相比 AtomicBoolean,AtomicReference<State> 的好处是状态表达更清楚,后续排查日志和推理并发行为更容易。
7. 模式六:通知合并,避免重复扫描
在分布式限流或队列系统中,一个释放资源动作可能触发大量通知。如果每个通知都立刻启动一次扫描,就容易形成通知风暴。
可以用一个本地 CAS 门闩表示"当前是否已有扫描任务在跑":
bash
private final AtomicBoolean firing = new AtomicBoolean(false);
private final AtomicInteger pendingNotifications = new AtomicInteger(0);
void fire() {
pendingNotifications.incrementAndGet();
if (!firing.compareAndSet(false, true)) {
return;
}
executor.execute(() -> {
try {
drainNotificationsAndPoll();
} finally {
firing.set(false);
}
});
}
业务意义是:
- 第一条通知负责启动扫描。
- 扫描运行期间,后续通知只增加计数,不重复提交扫描任务。
- 扫描结束后,如果发现期间还有通知,再补一轮。
这不是为了保证"只执行一次",而是为了把"高频重复通知"合并成"尽量少的有效扫描"。
8. 模式七:本地心跳句柄的关闭与丢失
可续期任务或调度锁通常有一个后台心跳。心跳对象也会面对多个线程:
- 心跳线程发现续期失败,标记锁已丢失。
- 业务线程执行完成,主动关闭心跳。
- 应用关闭时,统一停止调度线程。
这类对象通常有两个本地状态:
bash
lost : 锁是否已经确认丢失
closed : 心跳是否已经主动关闭
它们都适合用 AtomicBoolean:
bash
void markLost() {
if (lost.compareAndSet(false, true)) {
cancelFuture();
}
}
void close() {
if (closed.compareAndSet(false, true)) {
cancelFuture();
}
}
这里要区分两种 CAS:
- 数据库层 CAS:通过
UPDATE ... WHERE lock_owner = ?争抢或续期分布式锁。 - JVM 层 CAS:通过
AtomicBoolean管理本地心跳对象的生命周期。
前者负责跨实例互斥,后者负责本机对象不要重复关闭、不要重复取消定时任务。
9. 不是所有 AtomicBoolean 都是 CAS 门闩
项目中还会看到一种 AtomicBoolean 用法:在 Map.compute 或 lambda 内部作为可变返回容器。
例如:
bash
AtomicBoolean allowed = new AtomicBoolean(false);
map.compute(key, (k, value) -> {
if (canAllow(value)) {
allowed.set(true);
}
return nextValue;
});
return allowed.get();
这种写法的重点不是 CAS,而是 Java lambda 对外部局部变量的限制:lambda 只能捕获 effectively final 的变量,所以用 AtomicBoolean 作为一个可变盒子把结果带出来。
如果没有使用 compareAndSet,就不要把它理解成"抢锁"或"一次性门闩"。这类用法要单独看业务语义。
使用原因
1. 避免重复终态
异步系统最怕重复终态:
- 同一条消息重复落库。
- 同一个 Trace 节点重复结束。
- 同一条 SSE 连接重复关闭。
- 同一个取消事件重复发送。
- 同一个 permit 重复释放。
CAS 可以让多个竞争路径共享同一个终态入口,只允许第一个路径生效。
2. 保证跨线程可见性
普通 boolean 在线程间没有可靠的可见性保证。一个线程设置了 closed = true,另一个线程不一定立刻看到。
AtomicBoolean 的读写具备 volatile 语义,适合表达这种跨线程状态。
3. 降低锁的成本和复杂度
很多场景只是一个很小的状态翻转,不需要 synchronized 包住一大段逻辑。
CAS 的优点是:
- 语义直接。
- 无阻塞。
- 适合高频回调。
- 不容易因为锁范围过大造成死锁或性能抖动。
4. 明确表达业务幂等
CAS 代码本身就是业务语义:
bash
只有第一次能进入,后面都返回。
这比在多个 if 分支里散落状态判断更清楚,也更容易审查并发行为。
5. 和分布式组件形成清晰边界
分布式系统里,不是所有状态都应该放到 Redis 或数据库里。
判断标准是:被保护的资源在哪里?
- 如果资源是数据库行、全局任务、跨实例队列,应该用数据库 CAS、Redis 原子操作、分布式锁。
- 如果资源是本 JVM 内的一条 SSE 连接、一个 HTTP call、一个本地回调对象、一个定时 future,就应该用本地原子变量。
把本地对象状态放到分布式存储里,既不能让其他机器操作这个对象,也会增加不必要的复杂度。
适用边界
AtomicBoolean + CAS 适合:
- 本地对象生命周期控制。
- 一次性动作保护。
- 多个终态互斥。
- 异步回调幂等收口。
- 本机通知合并。
- 本地资源释放防重。
它不适合:
- 需要跨机器互斥的业务资源。
- 需要持久化恢复的任务状态。
- 多字段强一致更新。
- 复杂状态流转但仍强行用布尔值表达。
当状态超过两个,优先考虑 AtomicReference<State>。当状态需要跨服务实例共享,应该使用数据库、Redis、MQ 或其他具备分布式语义的组件。