竞态条件(Race Condition)
竞态条件(或竞争条件)指的是两个或以上线程/进程并发执行时,其最终的结果依赖于它们执行的精确时序。这种场景下,如果时序稍有不同,程序的结果就会变化,经常会导致不可预期甚至错误的行为。竞态条件一般来源于“共享资源的非同步访问”,这种访问区域叫临界区(Critical Section) 。因此,消除竞态条件的办法,就是对临界区进行有效的同步控制。
线程的同步:synchronized
在 Java/ Kotlin 传统线程代码中,经常使用 synchronized 关键字来保证临界区的互斥访问:
val lock = Any()
var number = 0
val thread1 = thread {
repeat(1_000_000) {
synchronized(lock) {
number++
}
}
}
val thread2 = thread {
repeat(1_000_000) {
synchronized(lock) {
number--
}
}
}
thread1.join()
thread2.join()
println("number: $number")
- 这里两个线程都访问同一个全局变量
number,如果不加synchronized就会发生竞态条件。 synchronized(lock)保证了每次只有一个线程可以进入临界区修改number。
协程中的同步:Mutex
协程虽然能并发,但本质还是跑在 JVM 的线程上。协程和线程混合场景里,如果协程之间访问共享资源,也要同步。
推荐使用 Mutex(协程互斥锁)代替 synchronized/lock。
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
val mutex = Mutex()
var number = 0
fun main() = runBlocking {
val job1 = launch {
repeat(1_000_000) {
mutex.withLock {
number++
}
}
}
val job2 = launch {
repeat(1_000_000) {
mutex.withLock {
number--
}
}
}
job1.join()
job2.join()
println("number: $number")
}
说明:
Mutex适用于协程间的同步,withLock是挂起函数,不会阻塞线程,而是让出线程资源。- 如果你在非协程环境(例如普通线程代码)里用
Mutex,会报错,因为它依赖挂起语义。
注意:如果一个变量同时被协程和线程代码访问,还是得用传统的线程锁(如 synchronized、ReentrantLock),Mutex 只适合挂起函数上下文。
信号量(Semaphore)
信号量(Semaphore)是一种可以被多个线程持有的计数器,常用于限制并发量。例如线程池、资源池场景。
Java 的 Semaphore 用法
Semaphore semaphore = new Semaphore(3); // 最多允许3个线程同时进入
void someTask() {
semaphore.acquire(); // 拿锁
try {
// ... 临界区
} finally {
semaphore.release(); // 释放锁
}
}
Kotlin 协程的 Semaphore
Kotlin 协程中也有类似的 API,调用方法和 Java 基本一致(只是用挂起函数,不会阻塞线程)。
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
val semaphore = Semaphore(3)
fun main() = runBlocking {
repeat(10) {
launch {
semaphore.acquire()
try {
// 只允许最多3个协程同时进入这里
delay(1000)
println("协程 $it 完成任务")
} finally {
semaphore.release()
}
}
}
}
说明:
Semaphore 用于限流,不是用来解决竞态条件(互斥)问题,互斥还是得用 Mutex 或锁。
wait()、notify()、notifyAll()
这些方法已经不推荐在新项目中使用(Java 早期方案),一般都被 synchronized/lock 取代,协程也没有类似 API。
挂起当前线程的逻辑会直接卡住所有协程(因为协程跑在 JVM 线程上) ,应避免。
Volatile 和 Transient
Kotlin 中还是可以用 @Volatile、@Transient 注解,原理同 Java。
@Volatile保证变量在线程间的可见性,适用于简单同步场景。@Transient用于序列化时跳过该字段。
@Volatile
var v = 0
@Transient
var t = 0
ThreadLocal
ThreadLocal 提供线程隔离的变量副本,即每个线程都有自己的变量副本,互不干扰。
它的作用范围介于方法局部变量和静态变量之间,适用于线程封闭的场景。
Java示例:
ThreadLocal<String> localString = new ThreadLocal<>();
void threadLocalExample() {
localString.set("foo");
String val = localString.get(); // "foo"
new Thread(() -> {
String valInOtherThread = localString.get(); // null
}).start();
}
协程和 ThreadLocal 的兼容性问题:
协程挂起后恢复,可能被调度到另一个线程,原生 ThreadLocal 就会丢失。
Kotlin 提供了 asContextElement() 工具来解决:
val kotlinLocalString = ThreadLocal<String>()
fun main() = runBlocking {
val scope = CoroutineScope(EmptyCoroutineContext)
scope.launch {
val stringContext = kotlinLocalString.asContextElement("协程内的ThreadLocal")
withContext(stringContext) {
delay(100)
println(kotlinLocalString.get()) // 一直是"协程内的ThreadLocal"
}
}
delay(1000)
}
注意:只要用 withContext(stringContext) { ... } 包住代码块,ThreadLocal 的值在同一个协程内会一直有效(即便切换了线程)。
协程的 ThreadLocal 替代:CoroutineContext
在协程世界,类似 ThreadLocal 的作用其实是 CoroutineContext。它能让协程在不同方法、不同 suspend 点之间共享一份上下文数据。
总结
- 并发条件下,临界区一定要加锁。线程环境用
synchronized/lock,协程优先用Mutex。 - 协程的锁是挂起式的,不会阻塞线程,效率更高。
- 信号量用于并发数量的限制,不用于解决互斥。
- 不推荐使用 wait/notify 这类底层 API,协程没有等价物。
- Volatile/Transient 语义与 Java 相同。
- ThreadLocal 只在线程隔离场景有效,协程需要用
asContextElement或 CoroutineContext 实现类似需求。
学后检测
一、单选题
-
下列哪项最能直接消除竞态条件?
A. 多线程访问同一个全局变量
B. 使用volatile关键字
C. 在临界区加锁
D. 只使用局部变量答案:C
解析:只有加锁(synchronized、Mutex等)才能保证同一时刻只有一个线程/协程访问共享资源,从而消除竞态条件。 -
在 Kotlin 协程中,如果需要让多个协程按最大并发数执行,应优先选择哪种同步原语?
A. Mutex
B. Semaphore
C. AtomicInteger
D. ThreadLocal答案:B
解析:Semaphore 能限制并发数量(如限流),Mutex 只能实现互斥访问,不能并发多个。 -
关于 ThreadLocal 的说法,正确的是:
A. 不同线程可以访问同一个ThreadLocal对象存储的同一份值
B. 每个线程拥有自己独立的ThreadLocal副本
C. ThreadLocal 适合用来做全局单例
D. ThreadLocal 只能在主线程中使用答案:B
解析:ThreadLocal 是为每个线程维护独立副本,A、C、D均错误。
二、多选题
-
以下哪些属于互斥同步工具?(多选)
A. synchronized
B. Mutex
C. Semaphore
D. ReentrantLock答案:A、B、D
解析:Mutex、synchronized、ReentrantLock 都可实现临界区互斥,Semaphore 主要用于限流。 -
下列关于 Kotlin 协程中 Mutex 的说法,哪些正确?
A. Mutex 是挂起式锁,不会阻塞线程
B. Mutex 只能用于协程上下文中
C. Mutex 在普通线程里也能用
D. Mutex 适合在 suspend 函数中保护临界区答案:A、B、D
解析:Mutex 只能用于协程(B),是挂起式(A),适合保护临界区(D),C 错。
三、判断题
-
( ) 用 ThreadLocal 可以解决多个线程间共享变量导致的数据混乱问题。
答案:对
解析:ThreadLocal 为每个线程提供独立副本,避免并发冲突。 -
( ) Kotlin 协程的 withLock 挂起时会阻塞所在线程。
答案:错
解析:Mutex.withLock 只会挂起协程,不会阻塞线程。
四、简答题
-
简述Semaphore与Mutex的本质区别,并各举一例使用场景。
答案要点:
-
Mutex:互斥锁,一次只能被一个线程/协程持有,通常用于保护临界区,避免数据竞态。
- 示例:多协程累加一个全局变量时,用 Mutex 保证每次只有一个协程能修改该变量。
-
Semaphore:信号量,允许一定数量线程/协程同时进入临界区,适合做限流、并发控制。
- 示例:数据库连接池最多允许5个并发连接时,可以用 Semaphore(5) 控制同时连接数量。
-
五、编程题
-
编程实现:用 Kotlin 协程模拟100个并发协程,每次最多允许10个协程同时执行任务,任务内容为:延迟500ms并输出自己的编号。要求所有协程执行完毕后主线程再退出。
import kotlinx.coroutines.* import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit fun main() = runBlocking { val semaphore = Semaphore(10) val jobs = List(100) { idx -> launch { semaphore.withPermit { delay(500) println("协程$idx完成") } } } jobs.forEach { it.join() } println("全部任务完成") }解析:Semaphore 控制并发数为10,每个协程获得 permit 后才能进入临界区(延迟和输出),所有任务 join 后主协程退出。