协程共享状态:Atomic、Mutex、Semaphore与状态封闭

协程共享状态:AtomicMutexSemaphore与状态封闭

协程让异步代码更容易组织,却不会自动消除并发安全问题。只要多个协程可能同时读写同一份可变数据,原子性、可见性和一致性就仍然需要明确保障。

并发工具的选择不能从API名称出发,而要先判断需要约束的对象:一个变量、一段复合逻辑、同时运行的任务数量,还是状态本身的所有权。AtomicMutexSemaphore和状态封闭分别对应这四种不同问题。

内容摘要

  1. count++包含读取、计算和写回,并不是原子操作。
  2. Atomic适合单个变量的简单原子更新,不负责维护复杂业务不变量。
  3. Mutex保护临界区,等待锁时挂起协程而不是阻塞线程。
  4. Semaphore限制同时进入某段逻辑的协程数量,解决的是并发度控制。
  5. 状态封闭通过明确唯一所有者,从设计上减少共享写入。

一、并发问题来自共享可变状态

一行代码也可能包含多个步骤

javascript 复制代码
var count = 0
​
scope.launch(Dispatchers.Default) {
    repeat(1_000) {
        // count++ 包含读、改、写,多协程并发执行时可能丢失更新
        count++
    }
}

count++可以拆成三个动作:

复制代码
读取旧值
→ 计算新值
→ 写回新值

两个协程可能同时读取相同旧值,各自加一后又写回相同新值,最终丢失一次更新。代码是否写在协程中并不改变这条事实;协程最终仍由线程执行,多个任务也可能在不同线程上并行运行。

并发安全不只涉及原子性。缺少同步关系时,还可能出现一个执行单元无法及时看到另一个执行单元更新,以及多个操作的观察顺序不符合预期等问题。具体工具需要同时承担它所承诺的同步语义,不能仅凭"看起来按顺序书写"判断安全。

先识别需要保持的不变量

真正需要保护的通常不是某个语句,而是业务不变量:

复制代码
库存不能小于零
缓存值与更新时间必须对应
同一资源同时最多执行三个请求
状态只能按合法路径迁移

只有一个计数值需要安全加一,与"读取缓存、判断是否过期、写入新缓存"不是同一类问题。工具选错后,局部操作即使线程安全,整体规则仍可能被破坏。

本节小结

协程并发安全的起点不是加锁,而是找出共享可变状态和必须始终成立的业务不变量。单行代码不等于原子操作,局部安全也不等于整体一致。

二、AtomicMutex保护不同粒度

Atomic:单个变量的原子更新

scss 复制代码
// AtomicInteger 为单个整数提供原子更新能力
val count = AtomicInteger(0)
​
// 加一与读取结果作为一次不可分割的原子操作完成
count.incrementAndGet()

Atomic通常基于CAS完成更新:

复制代码
读取当前值作为预期旧值
→ 计算新值
→ 仅当当前值仍等于预期旧值时写入
→ 失败后重新读取并重试

它适合计数器、开关、单一引用替换等简单状态。优势是更新范围小,不需要让其他协程排队进入一段代码。

但多个原子变量无法天然组成一次原子事务:

scss 复制代码
// 两个变量分别原子,不代表它们能够作为整体同时更新
val balance = AtomicInteger(100)
val version = AtomicInteger(1)

分别更新balanceversion都是原子的,不代表二者在任何观察时刻都保持一致。只要业务规则跨越多个字段或多个步骤,就需要更高层的状态对象或临界区。

Mutex:让复合逻辑互斥执行

kotlin 复制代码
// Mutex 保护"读取、判断、更新"这一整段复合逻辑
private val mutex = Mutex()
​
suspend fun loadConfig(): Config = mutex.withLock {
    // 同一时刻只有一个协程能够进入该临界区
    cache.current()
        ?.takeIf { !it.isExpired }
        // 缓存失效后加载远端数据,并在同一临界区内回写缓存
        ?: remote.load().also(cache::save)
}

Mutex保证同一时间最多一个协程进入临界区。等待锁的协程会挂起,不会占住线程持续等待,这是它与阻塞式锁的重要运行差异。

临界区应尽量保持紧凑,并通过withLock保证异常或取消发生时正确释放。是否允许在锁内执行耗时挂起操作,需要结合业务判断:技术上可以挂起,但长时间持锁会让其他协程持续等待,也可能把远程调用的不确定延迟引入共享状态路径。

不要用锁修补不清晰的状态边界

如果多处代码都能直接修改同一对象,即使每处都加锁,状态迁移规则仍然分散。锁解决同时执行的问题,不会自动建立合法状态机,也不会说明谁有权修改状态。

本节小结

Atomic保护单个变量的一次更新,Mutex保护一段复合逻辑。判断标准是业务不变量跨越的范围,而不是哪种工具代码更短。

三、Semaphore控制的是资源并发度

kotlin 复制代码
// 三个许可表示最多允许三个上传任务同时执行
private val uploadSlots = Semaphore(permits = 3)
​
suspend fun upload(file: File): UploadResult =
    uploadSlots.withPermit {
        // 代码块结束后自动归还许可,异常和取消也不会永久占用名额
        api.upload(file)
    }

Semaphore维护一组许可。只有拿到许可的协程才能进入目标逻辑,结束后归还许可。因此它适合:

  • 限制并发网络请求,避免服务端或连接池压力过高;
  • 限制图片解码、文件处理等资源密集任务;
  • 控制批量任务的吞吐和内存峰值。

当许可数为1时,Semaphore在表面上也能实现互斥,但语义仍然不同:

复制代码
Mutex     → 保护共享状态的临界区
Semaphore → 为有限资源控制并发名额

如果目标是保护某个变量,应表达为互斥;如果目标是允许最多N个任务并发,应表达为许可。清晰的语义会直接影响后续维护和参数调优。

还需要注意,限制并发数量不等于限制单位时间内的请求总数。Semaphore(3)保证最多三个任务同时运行,却不会保证每秒只发三个请求;后者属于速率限制,需要单独的时间窗口或令牌策略。

本节小结

Semaphore保护的不是变量,而是有限资源的使用容量。它控制同时运行多少任务,不自动保证共享状态安全,也不等同于单位时间限流。

四、状态封闭把问题收口到所有权

让状态只有一个写入入口

状态封闭的核心不是"换一种锁",而是让状态只归一个所有者管理:

kotlin 复制代码
class DownloadStore {
    // 状态只在 Store 内部修改,并由 Mutex 串行化状态迁移
    private val mutex = Mutex()
    private var state: DownloadState = DownloadState.Idle

    suspend fun reduce(action: DownloadAction): DownloadState =
        mutex.withLock {
            // 根据旧状态和动作生成新状态,外部不能直接改写 state
            state = transition(state, action)

            // 返回更新后的不可见内部状态快照
            state
        }
}

外部只能提交动作或调用方法,不能直接改写state。这样一来,并发控制、合法状态迁移和日志记录都集中在同一边界内。

更严格的设计可以让一个专属协程顺序处理消息:

复制代码
多个调用方发送命令
→ 单一所有者按顺序处理
→ 更新内部状态
→ 对外发布不可变快照

这种模型把"谁先拿到锁"转化为"命令按什么规则处理"。如果状态本身具有复杂生命周期,它通常比在各处零散加锁更容易验证。

一套稳定的选择顺序

sql 复制代码
能否取消共享写入?
├─ 能:优先状态封闭或不可变数据
└─ 不能
   ├─ 单个变量简单更新:Atomic
   ├─ 多步逻辑保持一致:Mutex
   └─ 限制同时运行数量:Semaphore

这些方案并不互斥。一个状态所有者内部仍可能用Mutex保证串行更新,批量请求外部再用Semaphore控制并发。关键是每种工具只承担清晰的一层责任。

本节小结

状态封闭通过唯一所有者减少共享面,并把状态迁移规则集中起来。它解决的是设计边界;AtomicMutexSemaphore解决的是边界内部不同类型的并发约束。

结语:先保护业务规则,再选择同步工具

协程共享状态的稳定判断模型可以归纳为:

sql 复制代码
简单变量原子更新   → Atomic
复合逻辑保持一致   → Mutex
有限资源并发控制   → Semaphore
复杂状态统一管理   → 状态封闭

并发工具不是强弱递进关系,也不能相互机械替代。可靠设计需要先明确共享状态、业务不变量和所有权,再用最贴合问题的机制建立边界。

参考资料

相关推荐
WAsbry1 小时前
Flow执行模型:上下文保持与生产消费节奏
android
WAsbry1 小时前
协程任务的组织方式:launch、async、withContext与结构化并发
android
WAsbry1 小时前
本文区分原子变量、复合操作、并发数量与状态所有权四类问题,比较Atomic、Mutex、Semaphore和状态封闭的适用边界,并结合计数、缓存和限流场景,建立
android
2501_915909062 小时前
iOS test 测试怎么做?功能、性能、兼容、稳定与安全五类测试指南
android·ios·小程序·https·uni-app·iphone·webview
赵广陆2 小时前
企业实战:Markdown图片检索
android·java·开发语言
alexhilton3 小时前
千万别误用Android Skills
android·kotlin·android jetpack
淡淡的香烟4 小时前
Android视频直播播放器简单封装
android·物联网·音视频
爱笑鱼4 小时前
Android 系统启动机制导读:从 init 到 system_server,谁创建谁?
android
爱笑鱼4 小时前
Android 系统启动机制(一):init 到底是什么?为什么它不是普通 Native Service?
android