协程之互斥锁和共享变量

305 阅读2分钟

竞态条件(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,会报错,因为它依赖挂起语义。

注意:如果一个变量同时被协程和线程代码访问,还是得用传统的线程锁(如 synchronizedReentrantLock),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 实现类似需求。

学后检测

一、单选题

  1. 下列哪项最能直接消除竞态条件

    A. 多线程访问同一个全局变量
    B. 使用volatile关键字
    C. 在临界区加锁
    D. 只使用局部变量

    答案:C
    解析:只有加锁(synchronized、Mutex等)才能保证同一时刻只有一个线程/协程访问共享资源,从而消除竞态条件。

  2. 在 Kotlin 协程中,如果需要让多个协程按最大并发数执行,应优先选择哪种同步原语?

    A. Mutex
    B. Semaphore
    C. AtomicInteger
    D. ThreadLocal

    答案:B
    解析:Semaphore 能限制并发数量(如限流),Mutex 只能实现互斥访问,不能并发多个。

  3. 关于 ThreadLocal 的说法,正确的是:

    A. 不同线程可以访问同一个ThreadLocal对象存储的同一份值
    B. 每个线程拥有自己独立的ThreadLocal副本
    C. ThreadLocal 适合用来做全局单例
    D. ThreadLocal 只能在主线程中使用

    答案:B
    解析:ThreadLocal 是为每个线程维护独立副本,A、C、D均错误。


二、多选题

  1. 以下哪些属于互斥同步工具?(多选)

    A. synchronized
    B. Mutex
    C. Semaphore
    D. ReentrantLock

    答案:A、B、D
    解析:Mutex、synchronized、ReentrantLock 都可实现临界区互斥,Semaphore 主要用于限流。

  2. 下列关于 Kotlin 协程中 Mutex 的说法,哪些正确?

    A. Mutex 是挂起式锁,不会阻塞线程
    B. Mutex 只能用于协程上下文中
    C. Mutex 在普通线程里也能用
    D. Mutex 适合在 suspend 函数中保护临界区

    答案:A、B、D
    解析:Mutex 只能用于协程(B),是挂起式(A),适合保护临界区(D),C 错。


三、判断题

  1. ( ) 用 ThreadLocal 可以解决多个线程间共享变量导致的数据混乱问题。

    答案:对
    解析:ThreadLocal 为每个线程提供独立副本,避免并发冲突。

  2. ( ) Kotlin 协程的 withLock 挂起时会阻塞所在线程。

    答案:错
    解析:Mutex.withLock 只会挂起协程,不会阻塞线程。


四、简答题

  1. 简述SemaphoreMutex的本质区别,并各举一例使用场景。

    答案要点

    • Mutex:互斥锁,一次只能被一个线程/协程持有,通常用于保护临界区,避免数据竞态。

      • 示例:多协程累加一个全局变量时,用 Mutex 保证每次只有一个协程能修改该变量。
    • Semaphore:信号量,允许一定数量线程/协程同时进入临界区,适合做限流、并发控制。

      • 示例:数据库连接池最多允许5个并发连接时,可以用 Semaphore(5) 控制同时连接数量。

五、编程题

  1. 编程实现:用 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 后主协程退出。