3. 并发控制核心理论:竞态条件、临界区、原子性、可见性、有序性、happens-before 原则
在构建 SSO 单点登录系统的异步任务编排时,多个线程或服务实例会同时访问共享资源(如用户会话缓存、令牌黑名单、分布式锁状态)。如果不施加正确的并发控制,就会出现数据不一致、逻辑错误甚至系统崩溃。本章深入剖析并发控制的六大核心理论,为后续的锁设计与异步编排奠定坚实的理论基础。
3.1 竞态条件与临界区
竞态条件(Race Condition)是指系统或程序的输出依赖于多个线程/进程之间不可预测的执行时序,导致结果在特定时序下出错。在 SSO 场景中,典型的竞态条件发生在多个请求同时尝试刷新同一个用户的令牌时。
临界区(Critical Section)是指访问共享资源的代码片段,同一时刻只允许一个线程进入执行。临界区的正确标识是解决竞态条件的第一步。
| 概念 | 定义 | SSO 示例 |
|---|---|---|
| 竞态条件 | 执行结果依赖线程交错时序,导致非预期结果 | 两个请求同时读取 refreshToken 为 null,各自生成新令牌,导致旧令牌被覆盖 |
| 临界区 | 访问共享资源的代码段,需互斥执行 | “检查令牌是否存在 → 生成新令牌 → 写入缓存” 这段代码 |
| 共享资源 | 被多个线程同时读写的变量/对象/缓存 | Redis 中的用户会话哈希、本地内存中的令牌黑名单集合 |
3.2 原子性
原子性(Atomicity)保证一个操作或一组操作要么全部执行成功,要么全部不执行,中间状态对外部不可见。在并发编程中,原子性是防止竞态条件的第一道防线。
对于 SSO 的令牌刷新场景,非原子操作会导致严重问题:
// 非原子操作:线程交错导致数据损坏
if (redis.get("token:user123") == null) { // 步骤1:检查
String newToken = generateToken(); // 步骤2:生成
redis.set("token:user123", newToken); // 步骤3:写入
}
// 线程A执行完步骤1后暂停,线程B执行完步骤1、2、3,
// 线程A恢复后继续执行步骤2、3,覆盖了线程B写入的令牌
Java 和 Go 提供了不同的原子性保障机制:
| 语言 | 原子性保障机制 | 适用场景 |
|---|---|---|
| Java | synchronized 块、java.util.concurrent.atomic 包、ReentrantLock |
对共享变量的单次读/写、计数器、CAS 操作 |
| Go | sync.Mutex、sync/atomic 包、channel 同步 |
共享内存保护、原子计数器、无锁数据结构 |
| 分布式 | Redis Lua 脚本、分布式锁(Redisson/Zookeeper) | 跨进程的原子“检查-执行”操作 |
3.3 可见性
可见性(Visibility)是指一个线程对共享变量的修改,能够被其他线程及时看到。在多核 CPU 架构下,每个线程可能将变量缓存到自己的核心缓存中,导致其他线程读取到过期的值。
在 SSO 系统中,可见性问题典型表现为:线程 A 更新了用户权限缓存,但线程 B 仍然读取到旧权限,导致授权决策错误。
synchronized 或 Lock 就自动解决了可见性。实际上,锁机制确实能保证可见性(通过内存屏障),但 volatile 关键字或 atomic 包才是专门解决可见性的轻量级方案。在“读多写少”的场景下,过度使用锁会严重降低吞吐量。
可见性保障机制对比:
| 机制 | 语言 | 原理 | 性能开销 |
|---|---|---|---|
volatile |
Java | 禁止指令重排序 + 强制从主存读写 | 低 |
synchronized |
Java | 进入同步块时刷新缓存,退出时写回主存 | 中 |
sync.Mutex |
Go | Lock/Unlock 时插入内存屏障 | 中 |
sync/atomic |
Go | CPU 原子指令 + 内存屏障 | 低 |
| 分布式缓存 | 通用 | 所有读写都经过 Redis/Etcd,天然具备可见性 | 网络延迟 |
3.4 有序性与 happens-before 原则
有序性(Ordering)是指程序执行的顺序符合代码逻辑的预期。编译器和 CPU 为了优化性能,可能会对指令进行重排序(Reordering),只要重排序后的结果在单线程环境下与原始顺序一致。但在多线程环境下,重排序可能导致意想不到的错误。
happens-before 原则是 Java 内存模型(JMM)中定义的一套规则,用于判断两个操作之间的内存可见性。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。
- 程序次序规则:同一个线程中,写在前面的操作 happens-before 后面的操作。
- 锁规则:对一个锁的解锁(unlock)happens-before 后续对这个锁的加锁(lock)。
- volatile 规则:对一个 volatile 变量的写 happens-before 后续对这个变量的读。
- 传递性:如果 A happens-before B,且 B happens-before C,则 A happens-before C。
下面的 SVG 图展示了 happens-before 原则在 SSO 令牌刷新场景中的应用:
3.5 四大并发问题的关联与区分
原子性、可见性、有序性并非独立存在,它们共同构成了并发安全的三个维度。下面的表格帮助你在 SSO 开发中快速定位问题类型:
| 问题类型 | 核心表现 | SSO 典型场景 | 解决方案 |
|---|---|---|---|
| 原子性破坏 | 复合操作被线程交错打断 | 令牌刷新“检查-生成-写入”被拆解 | 锁、CAS、Lua 脚本 |
| 可见性破坏 | 一个线程的修改对另一个线程不可见 | 权限缓存更新后,其他线程仍读旧值 | volatile、锁、原子类 |
| 有序性破坏 | 指令重排序导致逻辑错误 | 双重检查锁定(DCL)中未使用 volatile | volatile、final、内存屏障 |
| 复合问题 | 上述三种问题同时出现 | 无锁并发更新共享计数器 | 综合使用锁 + volatile |
3.6 从理论到实践:SSO 异步编排中的并发控制策略
在 SSO 异步任务编排中,我们通常采用分层策略来应对并发问题:
- 本地内存层:使用
volatile或atomic保证对状态标志的可见性和原子性,适用于轻量级缓存标记。 - 进程内锁层:使用
synchronized或ReentrantLock保护本地临界区,适用于单节点内的令牌生成逻辑。 - 分布式锁层:使用 Redis Redisson 或 Zookeeper 实现跨节点的互斥,适用于全局令牌刷新、黑名单同步。
- 无锁设计:通过不可变对象(Immutable Object)和线程封闭(Thread Confinement)从根源上避免并发问题。
下面的 SVG 图展示了 SSO 异步任务编排中并发控制的分层架构: