AtomicBoolean + CAS实现本地乐观锁

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 的交接与释放。
  • 定时任务执行权的转移。
  • 异步任务的成功、失败、取消互斥。

相比 AtomicBooleanAtomicReference<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 或其他具备分布式语义的组件。

相关推荐
fox_lht2 小时前
14.6.将错误重定向到标准错误
开发语言·后端·学习·rust
道友可好2 小时前
AI 测试全绿,代码却是错的
前端·人工智能·后端
techdashen2 小时前
Rust 基础设施团队 2025 Q4 回顾与 2026 Q1 计划
开发语言·后端·rust
神奇小汤圆3 小时前
互联网大厂精选面试八股文(附2026最新Java+AI高频题)| 建议收藏
后端
春天花会开1313 小时前
影像上传前置机网络架构设计模板(含VPN)
后端·架构
程序员cxuan3 小时前
Fable 5 的系统提示词被人扒出来了,精彩,太精彩了。
人工智能·后端·程序员
神奇小汤圆3 小时前
颠覆认知:Java 打破双亲委派 ≠ 彻底废弃双亲委派模型
后端
ZengLiangYi3 小时前
5 种 AI 对话数据格式全解析
后端·aigc·ai编程
ZengLiangYi3 小时前
本地向量数据库选型:vectra vs chroma vs hnswlib
javascript·数据库·后端