第109篇 Mutex 与信号量:协程世界的锁

简介: 本节详解协程环境下共享资源保护:`synchronized` 因跨挂起、线程与协程语义错配而失效;推荐使用 `Mutex`(单协程互斥)和 `Semaphore`(N并发限流),二者均支持挂起不阻塞线程。结合 Android 实际场景,厘清锁适用边界——单值用 `StateFlow.update`,计数用原子类,复合操作才用 `Mutex`。附典型坑点与面试高频四连问。

上一节讲 Channel 是怎么传数据的,这一节讲共享资源怎么保护。synchronized 在 Java 里管用,是因为"线程"既是执行单位也是互斥单位;协程世界里执行单位是协程、跑在线程池上,同一个 synchronized 块可能由不同线程执行——这直接导致 synchronized 在协程里不再可靠。这一节讲清 Mutex、信号量,以及这套机制在 Android 里的实际落点。

先把结论放在前面:synchronized 在挂起函数里是无效的(编译器会直接报错,因为它持锁时会跨越挂起点)。协程环境下的互斥要用 Mutex;限制并发数要用 Semaphore。两者都由 kotlinx.coroutines.sync 提供。判据是:需要"同一时刻只允许一个进入"用 Mutex;需要"同时最多允许 N 个进入"用 Semaphore。

机制背后的执行路径

Mutex 的核心 API 是 withLock:

val mutex = Mutex()

suspend fun safeUpdate() = mutex.withLock {
   
    // 临界区:同一时刻只有一个协程能进
    sharedState = compute(sharedState)
}

withLock 内部是 lock() → 执行 block → finally { unlock() }。挂起方会在 lock() 处挂起,而不是阻塞线程——所以它不占线程。

为什么 synchronized 不行?两个层面:

1. 语法层面:synchronized(lock) { someSuspendCall() } 里如果有挂起调用,编译器报错("the 'delay' suspension point is inside a critical section")。因为挂起后线程会被释放、锁却还攥在手里,可能再也回不来释放。 2. 语义层面:synchronized 是线程可重入的。协程 A 在线程 1 上持锁,某个操作把协程 A 挂起、线程 1 去跑协程 B(B 又 synchronized 同一把锁)——如果这个锁是 ReentrantLock 语义,B 以为是"自己"拿到的锁,实际是另一个协程持有的,中断了互斥。这是跨协程重入,比语法错误危险得多。

Semaphore(permits) 的用法类似,但控制的是"同时最多几个":

val limit = Semaphore(permits = 3)

suspend fun acquire() {
   
    limit.acquire()
    try {
    doWork() } finally {
    limit.release() }
}

Kotlin 协程版的 Semaphore 同样支持挂起,支持 withPermit { } 简写。acquire() 可以带超时(withTimeout 包裹),这对"限流 + 拿不到就快速失败"的场景很关键。

真实工程场景的推演

场景一:数据库写入串行化。项目里有"读-改-写"的三步逻辑(先查库存再扣减),并发下会超卖。Mutex 保护这三步,扣减逻辑保持在同一个临界区里。

场景二:缓存重建去重。多个协程同时发现缓存未命中,都会去重建(打数据库)。用 Mutex + 双重检查,只让一个真正重建:

private val mutex = Mutex()
private var cache: List<User>? = null

suspend fun getUsers(force: Boolean = false): List<User> {
   
    if (!force) cache?.let {
    return it }
    return mutex.withLock {
   
        cache ?: loadFromDb().also {
    cache = it }   // 双重检查:等锁期间可能已被填充
    }
}

场景三:限流。接口 QPS 需要限制在 20,用 Semaphore(20);拿不到许可时按业务决定"排队等"还是"快速失败"(tryAcquire() 立刻返回布尔)。

场景四:Android 的真实替代方案。这里要说清选型边界——不是所有共享状态都需要 Mutex:

| 场景 | 更好的方案 | |---|---| | 单值状态 | MutableStateFlow.update {}(内部 CAS 原子操作) | | 累加计数 | AtomicInteger / LongAdder | | 内存缓存 | LruCache(自身线程安全) | | 复合临界区 | Mutex | | 跨进程 | FileLock / 数据框架自带的锁 |

这条表格在面试里很关键——能说清"什么时候不该上 Mutex",比会用 Mutex 更能体现经验。

最常见的坑是

在 Mutex.withLock 内调 suspend 重 IO,持锁时间过长其他协程饿死。 表现是并发请求都排队在锁上,吞吐塌陷。修法:临界区只做"必须互斥"的那几行(读-改-写),IO 放锁外;或者把 IO 换成 Deferred 先并发发起、拿到结果后再进锁做合并。

其次是忘了 release()。手动 acquire() 后若抛异常没有 finally { release() },许可数永久减少,最终所有协程卡死。修法:优先用 withLock / withPermit 的 lambda 形式,它自带 finally。

还有一个更隐蔽的坑:用 synchronized 保护协程共享状态,或用 ReentrantLock 之后忘了它不是可重入到协程层面的。修法:项目内禁用裸 synchronized(除非确实只做非挂起操作),并在 lint 里加检查。

现场手写这一段就够了

// 1) Mutex:读-改-写的标准临界区
class InventoryRepo(private val dao: InventoryDao) {
   
    private val mutex = Mutex()

    suspend fun reserve(sku: String, n: Int): Boolean = mutex.withLock {
   
        val stock = dao.stockOf(sku)              // 读
        if (stock < n) return@withLock false
        dao.setStock(sku, stock - n)              // 写
        true
    }
}

// 2) 临界区要小:IO 放锁外,只锁合并
suspend fun loadDetail(id: String): Detail = mutex.withLock {
   
    val cached = cache[id]
    if (cached != null) return@withLock cached
    // ❌ val d = api.load(id) 放锁内:所有其他协程被阻塞数秒
    // ✅ 改成:锁内只做"是否已被别人加载"的判断
    pending[id] ?: api.load(id).also {
    cache[id] = it; pending.remove(id) }
}

// 3) 双检锁:等锁期间可能已被别的协程填好
private val cacheMutex = Mutex()
private var userCache: List<User>? = null
suspend fun users(): List<User> {
   
    userCache?.let {
    return it }
    return cacheMutex.withLock {
   
        userCache ?: dao.all().also {
    userCache = it }
    }
}

// 4) Semaphore 限流:许可数控制并发上限
val apiLimit = Semaphore(permits = 20)
suspend fun callApi(req: Request): Response {
   
    return apiLimit.withPermit {
    http.send(req) }   // 最多 20 个并发
}
suspend fun callApiOrFail(req: Request): Response? {
   
    if (!apiLimit.tryAcquire()) return null          // 快速失败,不排队
    return try {
    http.send(req) } finally {
    apiLimit.release() }
}

// 5) 单值状态其实不需要 Mutex:StateFlow.update 内部是 CAS
val count = AtomicInteger(0)
val state = MutableStateFlow(0)
suspend fun incr() {
    state.update {
    it + 1 } }        // 原子,不需要锁

// 6) 反例:synchronized + 挂起,编译器直接拒绝
// synchronized(lock) {
    delay(100); shared = 1 }      // ❌ 编译错误
val lock = Any()
suspend fun bad() = synchronized(lock) {
    delay(100) }

关键行解读:withLock 自动 finally 释放;临界区只放"读-改-写"三行,IO 在锁外;双检锁避免重复加载;withPermit 与 tryAcquire 覆盖"排队"和"快速失败"两种限流策略;StateFlow.update 与 AtomicInteger 说明并非所有共享状态都需要锁。

面试追问四连

"为什么协程里不能用 synchronized?" 答:①临界区内有挂起调用会编译报错,因为挂起后线程被释放、锁可能永不释放;②synchronized 是线程可重入的,协程挂起换线程后可能发生"跨协程重入",互斥语义被破坏。

"Mutex 和 Semaphore 怎么选?" 答:Mutex 限制"同时只有一个"进入;Semaphore 限制"同时最多 N 个"进入。前者是互斥,后者是限流/限并发。

"Mutex 会阻塞线程吗?" 答:不会。竞争失败的协程在 lock() 处挂起,让出线程,不消耗线程资源。

"什么时候不需要锁?" 答:单值状态用 StateFlow.update(CAS)、计数用原子类、缓存用线程安全容器、状态天然隔离(每个协程自己一份)时都不需要锁。锁是最后手段。

落地建议

1. 项目里统一封装一个 SafeCounter 或 KeyedMutex(按 key 分桶加锁),避免全局单锁造成无关任务互相等待。 2. 规范:临界区不超过 10 行、不含 IO/日志/第三方调用;code review 时对照检查。 3. 限流统一用 Semaphore + 显式策略(排队 / 快速失败 / 超时),把策略写进函数命名或注释。 4. 自动化预防:为每个 Mutex 保护的临界区写并发单测(N 个协程并发 + CountDownLatch 校验最终值),这类 bug 靠读代码很难发现;并用 detekt 拦下 synchronized 块内出现挂起调用的写法。

给正在准备面试的你 把这题画成"锁的三层演进"图。第一层画"Java 线程世界":synchronized 锁 + 线程 A/B 排队;第二层画"协程世界":三个协程跑在两个线程上,其中一个协程被挂起(虚线),旁边红叉标"synchronized 在此失效";第三层画"正确的 Mutex 世界":三个协程排队等 Mutex,锁是逻辑的(虚线圈住)而不是绑在线程上,标注"挂起的是协程,不是线程"。图下补一句"锁的粒度从线程变成了协程,共享单位从线程变成了状态"。

再补一个高分追问答案:"Mutex 是可重入的吗?协程里重复加锁会怎样?" 答:Mutex 的 lock() 不可重入,同一个协程内重复 lock() 会挂起到自己,形成死锁——这是比死锁更难排查的情况。Kotlin 提供 owner 参数做"持有者校验"(默认 owner 就是协程本身),但更实用的做法是设计时避免嵌套锁:把需要两把锁的逻辑合并成一把,或者用 withLock 的最小作用域包住临界区,锁外做准备工作。答出"不可重入 + 避免嵌套"这一层,说明真踩过。

复习时别孤立刷题:Channel 与 select——上一节讲协程之间传数据,本节讲共享资源的互斥与限流,两者共同覆盖了"协程并发编程"的两大主题。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:Channel-与-select:协程间通信

下一篇预告:Flow-与-RxJava-对比:响应式迁移指南

有任何问题欢迎在评论区留言交流。

相关文章
|
18天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8618 25
|
16天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3040 14
|
16天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2110 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
16天前
|
云安全 人工智能 安全
|
11天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
11天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章