上一节讲 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-对比:响应式迁移指南
有任何问题欢迎在评论区留言交流。