
前言
大乘境中阶已过,你已看透 CoroutineScheduler 的调度算法与 DispatchedContinuation 的拦截玄机。协程的源码在你眼中不再是黑盒,而是一幅精密的工程蓝图。
然而,修仙之路从无坦途。当你试图在协程中使用传统的同步工具时,一场名为死锁的天劫正在暗中酝酿:
- 1、我在协程里用了
synchronized锁,为什么应用偶尔会卡死,却没有 ANR 弹窗?- 2、
Mutex.lock()和synchronized有什么区别?为什么协程官方推荐用Mutex?- 3、我的老代码用了
ThreadLocal存储用户登录信息,迁移到协程后,数据怎么乱了?- 4、
asContextElement是什么?它如何让ThreadLocal在协程切换线程时自动迁移?
这些问题指向协程并发编程中最容易踩坑的领域------同步原语与线程局部存储 。传统的 synchronized、ThreadLocal 是为线程模型 设计的,而协程的挂起与恢复打破了"一个任务绑定一个线程"的假设。在协程中滥用传统同步工具,轻则性能退化,重则神秘死锁。
本讲是大乘境的最终章。你将直面死锁天劫,掌握协程中的同步与数据隔离之道:
- 理解
synchronized在协程中导致死锁的根本原因。 - 掌握
Mutex的用法及其与synchronized的本质区别。 - 学会使用
Semaphore实现协程级限流。 - 理解
ThreadLocal在协程中的陷阱,并用asContextElement安全迁移。
准备好渡劫飞升,将协程的同步原语彻底驯服了吗?我们开始。
操千曲 而后晓声,观千剑 而后识器。虐它千百遍 方能通晓其真意。
synchronized 在协程中的死锁陷阱
传统线程模型中 synchronized 的行为
在 Java 线程模型中,synchronized 是阻塞式 的:当一个线程进入 synchronized 块时,它会获取对象的监视器锁。如果锁已被其他线程持有,当前线程会被挂起(阻塞) ,操作系统将其移出 CPU,直到锁被释放。线程被阻塞期间,不占用 CPU。
协程中使用 synchronized 的隐患
协程的核心优势在于挂起不阻塞线程 。但 synchronized 的阻塞是线程级别 的------它会阻塞承载协程的线程。考虑以下场景:
kotlin
val lock = Any()
val dispatcher = Dispatchers.IO.limitedParallelism(1) // 单线程调度器
runBlocking {
launch(dispatcher) {
synchronized(lock) {
delay(1000) // 挂起,但锁未释放!
println("协程1 完成")
}
}
launch(dispatcher) {
delay(100)
synchronized(lock) { // 永远等不到锁
println("协程2 永远无法执行")
}
}
}
死锁流程分析:
- 协程1 获取锁,然后调用
delay挂起。delay不会释放synchronized锁。 - 协程1 挂起后,调度器的单线程被释放,可以执行协程2。
- 协程2 尝试获取同一个锁,但由于锁仍被挂起的协程1 持有,协程2 的线程被阻塞。
- 单线程被阻塞后,无法再执行任何任务,包括协程1 的恢复!死锁形成。
核心结论 :synchronized 的锁不会随协程挂起而释放 ,因为 JVM 的监视器锁是与线程绑定的,而非协程。在协程挂起时,线程可能被其他协程复用,但锁依然被原协程持有,极易导致死锁。
何时 synchronized 相对安全?
如果满足以下所有 条件,使用 synchronized 风险较低:
- 临界区代码没有挂起点 (不调用任何
suspend函数)。 - 临界区执行时间极短。
- 使用的是多线程调度器 (如无限制的
Dispatchers.IO),即使一个线程被阻塞,其他线程仍可工作。
但在协程优先的代码库中,强烈建议使用 Mutex 替代 synchronized。
Mutex:协程世界的挂起式互斥锁
Mutex 的定义与核心特性
Mutex是 Kotlin 协程提供的挂起式互斥锁 。它的lock()方法是一个挂起函数 ------当锁被其他协程持有时,当前协程会挂起而非阻塞线程。锁被释放后,等待的协程会被自动恢复。
kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
val mutex = Mutex()
suspend fun safeCriticalSection() {
mutex.withLock {
// 临界区代码,可以安全调用挂起函数
delay(1000)
println("执行完毕")
}
}
核心区别 :Mutex.lock() 挂起的是协程,线程仍可执行其他协程。这正是协程非阻塞哲学的体现。
Mutex 的实战用法
kotlin
class UserProfileCache {
private val cache = mutableMapOf<String, UserProfile>()
private val mutex = Mutex()
suspend fun get(key: String): UserProfile? {
mutex.withLock {
return cache[key]
}
}
suspend fun put(key: String, profile: UserProfile) {
mutex.withLock {
cache[key] = profile
}
}
}
withLock 是一个扩展函数,等价于 try { lock(); ... } finally { unlock() },自动处理异常和释放。
Mutex 的公平性与性能
默认情况下,Mutex 是不公平 的------等待队列中的协程被唤醒的顺序不保证与请求顺序一致。如果需要公平锁,可以传入 fair = true:
kotlin
val fairMutex = Mutex(locked = false, fair = true)
公平锁会按 FIFO 顺序唤醒等待者,但性能略低。大多数场景默认不公平锁即可。
Semaphore:协程级信号量限流
什么是 Semaphore?
Semaphore是协程提供的挂起式信号量 ,用于限制同时访问某一资源的协程数量 。它维护一组虚拟"许可证",协程通过acquire()获取许可(挂起直到有可用许可),通过release()归还许可。
kotlin
import kotlinx.coroutines.sync.Semaphore
val semaphore = Semaphore(permits = 3) // 最多允许 3 个协程并发
suspend fun limitedAccess() {
semaphore.withPermit {
// 最多同时有 3 个协程执行此代码块
doNetworkRequest()
}
}
与 limitedParallelism 的对比
| 对比项 | Semaphore |
limitedParallelism |
|---|---|---|
| 作用范围 | 特定的代码块 | 整个调度器 |
| 灵活性 | 可在任意协程中 acquire |
需在启动时指定调度器 |
| 适用场景 | 局部限流(如单 API 调用) | 全局限流(如下载管理器) |
Semaphore 更细粒度,适合保护特定资源 (如数据库连接池);limitedParallelism 更粗粒度,适合控制整个任务类型的并发。
ThreadLocal 在协程中的陷阱与救赎
ThreadLocal 的工作原理与协程的冲突
ThreadLocal 将数据存储在 Thread 对象的 ThreadLocalMap 中。同一个线程上的所有代码共享同一份 ThreadLocal 数据。
协程的挂起与恢复可能发生在不同线程 。当一个协程在 Dispatchers.IO 上挂起,然后在 Dispatchers.Main 上恢复时,它的线程已经改变 。如果代码依赖 ThreadLocal 传递上下文(如用户登录信息、Trace ID),切换线程后数据会丢失或错乱。
kotlin
val userContext = ThreadLocal<String>()
suspend fun process() {
userContext.set("张三") // 在线程 A 设置
withContext(Dispatchers.IO) {
println(userContext.get()) // 在线程 B 获取,可能为 null
}
}
asContextElement:让 ThreadLocal 随协程迁移
Kotlin 协程提供了 asContextElement() 扩展函数,将 ThreadLocal 包装为 CoroutineContext.Element。当协程切换线程时,这个 Element 会自动将 ThreadLocal 的值从旧线程复制到新线程,并在任务完成后恢复旧线程的原值。
kotlin
val userContext = ThreadLocal<String>()
suspend fun process() {
userContext.set("张三")
withContext(Dispatchers.IO + userContext.asContextElement()) {
// 这里的 ThreadLocal 被自动设置为 "张三"
println(userContext.get()) // 输出 "张三"
}
// 离开 withContext 后,原线程的 ThreadLocal 值被恢复
}
内部机制 :asContextElement() 创建的 Element 在 updateThreadContext 时保存当前线程的 ThreadLocal 原值,并将目标值设置到新线程;在 restoreThreadContext 时恢复原值。
kotlin
// 最佳实践:将 asContextElement 放在顶层作用域
val coroutineContext = userContext.asContextElement() + Dispatchers.Main
val scope = CoroutineScope(coroutineContext)
scope.launch {
// 此协程及其子协程的 ThreadLocal 都会自动迁移
userContext.set("李四")
withContext(Dispatchers.IO) {
println(userContext.get()) // 依然是 "李四"
}
}
更安全的替代方案:使用 CoroutineContext 直接存储
如果你的数据不需要与遗留的 ThreadLocal 代码交互,更推荐直接定义自定义的 CoroutineContext.Element:
kotlin
data class UserContext(val user: String) : AbstractCoroutineContextElement(UserContext) {
companion object Key : CoroutineContext.Key<UserContext>
}
suspend fun process() {
withContext(UserContext("张三")) {
val user = coroutineContext[UserContext]?.user
println(user)
}
}
这种方式纯协程原生 ,无 ThreadLocal 的性能开销和潜在泄漏风险。
实战:构建线程安全的协程缓存
结合 Mutex 和 asContextElement,我们可以构建一个既支持协程并发安全,又能传递用户身份的缓存服务。
kotlin
class SecureCache {
private val cache = mutableMapOf<String, Any>()
private val mutex = Mutex()
suspend fun <T> get(key: String, block: suspend () -> T): T {
mutex.withLock {
@Suppress("UNCHECKED_CAST")
cache[key] as? T
}?.let { return it }
val value = block()
mutex.withLock {
cache[key] = value
}
return value
}
}
// 用户身份 ThreadLocal
val currentUser = ThreadLocal<String>()
class UserAwareRepository(private val cache: SecureCache) {
suspend fun fetchUserData(): UserData {
val userId = currentUser.get() ?: error("未登录")
return cache.get("user_$userId") {
// 模拟网络请求
delay(1000)
UserData(userId, "用户数据")
}
}
}
// 在协程中使用
fun main() = runBlocking {
val cache = SecureCache()
val repo = UserAwareRepository(cache)
// 为协程设置用户上下文
currentUser.set("user123")
withContext(currentUser.asContextElement()) {
val data1 = repo.fetchUserData()
val data2 = repo.fetchUserData() // 第二次命中缓存
println(data1 == data2) // true
}
}
死锁排查工具与技巧
使用 JStack 分析线程堆栈
当怀疑发生死锁时,可以通过 jstack <pid> 获取线程堆栈。重点关注:
- 处于
WAITING或BLOCKED状态的线程。 - 堆栈中包含
synchronized或Mutex.lock的调用。
协程 Debug 模式与自定义命名
开启协程 Debug 模式(-Dkotlinx.coroutines.debug),并为关键协程命名:
kotlin
launch(CoroutineName("UserDataLoader")) {
// ...
}
当发生死锁时,堆栈中会显示协程名称,便于定位。
使用 runBlocking 的压力测试
编写压力测试,模拟高并发场景,提前暴露同步问题。
kotlin
@Test
fun testConcurrentCacheAccess() = runTest {
val cache = SecureCache()
val jobs = List(100) {
launch {
cache.get("key") { delay(10); "value" }
}
}
jobs.joinAll()
// 验证数据一致性
}
常见误区与避坑指南
误区 1:在 Mutex.withLock 内部使用 withContext 切换线程
kotlin
mutex.withLock {
withContext(Dispatchers.IO) {
// 危险:锁在原始线程,但代码在 IO 线程执行
}
}
Mutex 的锁是与协程 绑定的,切换线程本身不会导致问题,但如果 withContext 内部再次尝试获取同一个 Mutex,会导致死锁。保持临界区简洁,避免嵌套锁。
误区 2:忘记 asContextElement,导致 ThreadLocal 数据丢失
kotlin
launch {
userThreadLocal.set("张三")
withContext(Dispatchers.IO) {
// 错误:没有传递 asContextElement,数据丢失
}
}
正确 :withContext(Dispatchers.IO + userThreadLocal.asContextElement())。
误区 3:将 Mutex 用于保护挂起函数,但期望它能限制并发数
Mutex 是互斥锁 ,只能保证同时只有一个协程执行临界区,而不是"允许 N 个协程并发"。如需限流,使用 Semaphore 或 limitedParallelism。
最佳实践
- 在协程中优先使用
Mutex替代synchronized:避免线程阻塞导致的死锁。 - 用
Semaphore实现局部限流,用limitedParallelism实现全局限流。 - 将
ThreadLocal迁移到协程时,使用asContextElement并确保在整个调用链中传递。 - 优先考虑用
CoroutineContext.Element存储协程本地数据 ,而非ThreadLocal。 - 为关键协程命名,开启
Debug模式,便于死锁排查。 - 编写高并发压力测试,提前暴露同步问题。
总结与下回预告
恭喜,你已渡过死锁天劫,掌握了协程同步原语与 ThreadLocal 迁移之道!大乘境大圆满!
本讲核心收获:
synchronized在协程中可能导致线程级阻塞,引发死锁。Mutex是协程原生互斥锁,lock挂起协程而非阻塞线程。Semaphore实现协程级并发限流。ThreadLocal在协程切换线程时丢失数据,asContextElement自动迁移。- 死锁排查依赖
Debug模式、协程命名与堆栈分析。
在下一境------渡劫境·初阶 ------中,我们将面对协程的最高奥秘:挂起函数的字节码实现细节 、CPS 变换的完整流程 、以及协程调度器的终极定制。届时你将真正飞升,看透协程的一切虚妄。
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 大乘境 · 后阶 | 1、Mutex 挂起锁诀 2、Semaphore 信号量术 3、asContextElement 迁移法 |
当前进度 :100% 修为 :71000/1000 下一突破 :[渡劫境 · 初阶] (需领悟:CPS 字节码细节、调度器定制、协程底层全貌) |
synchronized阻塞线程,Mutex挂起协程。ThreadLocal是线程的,asContextElement是协程的。 |
【本讲思考题】
-
表象题:以下代码存在什么问题?
kotlinval lock = Any() suspend fun bad() { synchronized(lock) { delay(100) } } -
场景题 :你有一个第三方库,它内部使用
ThreadLocal存储 Trace ID。你需要在协程中调用该库,且希望 Trace ID 在挂起恢复后不丢失。如何设计封装? -
原理题 :
Mutex.lock()挂起时,等待的协程是如何被组织起来的?Mutex内部是否使用了Channel或LockFreeLinkedList?请结合源码简述。
道友,大乘境已全部通关。你的协程修为已臻至 Android 及后端开发的巅峰。下一境,我们将踏入渡劫期,直面协程最底层的字节码与调度器源码,为飞升做最后准备。渡劫境·初阶见。
欢迎一键四连 (
关注+点赞+收藏+评论)