协程并发执行与共享状态
本文整理 Kotlin 协程中共享状态、数据竞争和并发同步的核心内容。重点比较原子变量、单线程调度器、Mutex 与 Semaphore,并说明如何在保证正确性的同时减少线程阻塞。
共享状态带来的问题
在开始之前,先看看下面的 UserDownloader 类。它可以根据用户 ID 获取用户,也可以返回之前已经下载过的所有用户。
这段实现有什么问题?
kotlin
class UserDownloader(
private val api: NetworkService
) {
private val users = mutableListOf<User>()
fun downloaded(): List<User> =
users.toList()
suspend fun fetchUser(id: Int) {
val newUser = api.fetchUser(id)
users.add(newUser)
}
}
防御性复制
downloaded() 使用了 toList():
kotlin
fun downloaded(): List<User> =
users.toList()
这会创建一个新的只读列表,避免调用方持有内部的可变列表。
如果直接返回 users,外部代码就可能修改内部集合:
kotlin
fun downloaded(): List<User> =
users
调用方甚至可能执行:
text
downloader.downloaded().clear()
因此,toList() 可以避免外部修改内部状态,也可以减少读取和写入同一个可变列表时的冲突。
不过,防御性复制只保护了读取结果,并没有保护 users.add(...) 本身。
什么是共享状态
上面的实现不适合并发使用。
每次调用 fetchUser() 都会修改同一个 users 集合:
text
users.add(newUser)
如果多个协程同时调用 fetchUser(),它们可能同时操作同一个 MutableList。
这种被多个线程或协程共同访问、并且至少有一个操作会修改它的数据,称为共享状态:
text
多个协程
↓
共同访问
↓
同一个 users 列表
↓
并发修改
↓
产生数据竞争
MutableList,特别是 ArrayList,默认不是线程安全的。多个线程同时执行 add() 可能导致:
-
元素数量少于预期;
-
数据覆盖;
-
集合内部结构损坏;
-
抛出异常。
例如:
kotlin
class FakeNetworkService : NetworkService {
override suspend fun fetchUser(id: Int): User {
delay(2)
return User("User$id")
}
}
suspend fun main() {
val downloader = UserDownloader(FakeNetworkService())
coroutineScope {
repeat(1_000_000) {
launch {
downloader.fetchUser(it)
}
}
}
println(downloader.downloaded().size)
}
理想结果应该是:
text
1000000
但实际可能输出:
text
998242
也可能抛出异常:
text
java.lang.ArrayIndexOutOfBoundsException
原因是多个线程同时修改同一个 ArrayList,导致内部数组扩容和写入过程发生冲突。
更简单的例子:并发累加
下面的代码启动 1,000 个协程,每个协程执行 1,000 次累加:
kotlin
var counter = 0
fun main() = runBlocking {
massiveRun {
counter++
}
println(counter)
}
辅助函数如下:
kotlin
suspend fun massiveRun(
action: suspend () -> Unit
) = withContext(Dispatchers.Default) {
repeat(1_000) {
launch {
repeat(1_000) {
action()
}
}
}
}
理论上:
text
1,000 个协程 × 每个协程 1,000 次 = 1,000,000
但实际结果可能只有:
text
567231
因为:
text
counter++
并不是一个原子操作。它实际上包含三个步骤:
text
1. 读取 counter
2. 将读取到的值加 1
3. 将新值写回 counter
数据丢失的过程
假设初始值为 0,两个线程同时执行 counter++。
text
线程 A:读取 0
线程 B:读取 0
线程 B:0 + 1 = 1,并写回 1
线程 A:0 + 1 = 1,并写回 1
最终结果是:
text
1
但实际上执行了两次加法,正确结果应该是:
text
2
第二次写入覆盖了第一次写入,因此发生了一次更新丢失。
这就是典型的竞态条件:
text
读取、修改、写回
这三个步骤没有作为一个不可分割的整体执行。
使用 Mutex 保护共享状态
对于 UserDownloader,可以使用 Mutex:
kotlin
class UserDownloader(
private val api: NetworkService
) {
private val users = mutableListOf<User>()
private val mutex = Mutex()
suspend fun downloaded(): List<User> =
mutex.withLock {
users.toList()
}
suspend fun fetchUser(id: Int) {
val newUser = api.fetchUser(id)
mutex.withLock {
users.add(newUser)
}
}
}
需要导入:
kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
这里的关键点是:
kotlin
mutex.withLock {
users.add(newUser)
}
同一时刻只能有一个协程进入这个代码块,因此对 users 的修改不会同时发生。
网络请求放在锁外:
kotlin
val newUser = api.fetchUser(id)
这样可以避免一个协程等待网络响应时,长时间占用锁。
使用原子变量保护计数器
对于简单的整数计数,可以使用 AtomicInteger:
kotlin
import java.util.concurrent.atomic.AtomicInteger
val counter = AtomicInteger(0)
fun main() = runBlocking {
massiveRun {
counter.incrementAndGet()
}
println(counter.get()) // 1000000
}
incrementAndGet() 会以原子方式完成读取、加一和写回,避免多个线程互相覆盖结果。
总结
共享状态问题的核心是:
text
多个协程或线程
↓
同时访问同一份可变数据
↓
至少一个操作会修改数据
↓
发生数据竞争
需要注意:
-
toList() 可以防止外部修改内部列表,但不能保护并发写入;
-
MutableList 默认不是线程安全的;
-
counter++ 不是原子操作;
-
Mutex 适合保护协程共享状态;
-
AtomicInteger 适合简单的原子计数;
-
锁应该只包围真正访问共享状态的代码,不要把耗时网络请求放在锁里面。
阻塞式同步
前面共享状态的问题,可以使用 Java 中的经典工具解决,例如 synchronized 代码块或线程安全集合。
kotlin
var counter = 0
fun main() = runBlocking {
val lock = Any()
massiveRun {
synchronized(lock) {
// 这里会阻塞线程
counter++
}
}
println("Counter = $counter") // 1000000
}
这段代码可以保证结果正确,但存在两个主要问题:
-
synchronized 代码块中不能直接调用挂起函数;
-
协程等待锁时,线程会被阻塞。
如果等待的是主线程,界面就会卡顿;如果线程池中的线程数量有限,阻塞还可能导致其他协程无法获得线程。
因此,协程程序通常应该优先使用不会阻塞线程的工具,例如:
-
挂起;
-
原子操作;
-
单线程调度器;
-
Mutex 等协程同步工具。
原子变量
Java 提供了一组原子类型,例如 AtomicInteger。它们的操作通常在底层使用无锁机制实现,因此速度快,并且具备线程安全性。
kotlin
import java.util.concurrent.atomic.AtomicInteger
private val counter = AtomicInteger()
fun main() = runBlocking {
massiveRun {
counter.incrementAndGet()
}
println(counter.get()) // 1000000
}
其中:
text
counter.incrementAndGet()
会以原子方式完成:
text
读取旧值 → 加一 → 写回新值
因此不会发生两个线程同时读取相同旧值的问题。
原子操作的局限
原子变量只保证单个操作是原子的。如果把多个操作拆开,就仍然可能产生竞争:
kotlin
private val counter = AtomicInteger()
fun main() = runBlocking {
massiveRun {
counter.set(counter.get() + 1)
}
println(counter.get()) // 可能小于 1000000
}
这里包含两个独立操作:
text
counter.get()
counter.set(...)
两个线程可能同时执行:
text
线程 A:读取 0
线程 B:读取 0
线程 A:写入 1
线程 B:写入 1
因此,counter.set(counter.get() + 1) 并不是一个完整的原子操作。
原子类型适合:
-
一个简单计数器;
-
一个状态标志;
-
一个引用的整体替换。
如果需要协调多个字段或多个步骤,就需要更强的同步工具。
使用 AtomicReference 保护用户列表
可以使用 AtomicReference 保存一个只读用户列表:
kotlin
import java.util.concurrent.atomic.AtomicReference
class UserDownloader(
private val api: NetworkService
) {
private val users =
AtomicReference(listOf<User>())
fun downloaded(): List<User> =
users.get()
suspend fun fetchUser(id: Int) {
val newUser = api.fetchUser(id)
users.getAndUpdate { oldUsers ->
oldUsers + newUser
}
}
}
这里的关键是:
text
users.getAndUpdate { oldUsers ->
oldUsers + newUser
}
它会基于旧列表生成一个新列表,并以原子方式替换旧引用。
由于 listOf() 返回的是只读列表,因此外部不能直接修改内部集合。
这种方式适合状态比较简单、可以整体替换的场景。对于复杂对象或多步骤事务,仍然需要其他同步机制。
使用单线程调度器
另一种简单方案是使用并行度限制为 1 的调度器:
kotlin
val dispatcher =
Dispatchers.IO.limitedParallelism(1)
它保证同一时间只有一个协程在这个调度器上执行关键代码。
kotlin
var counter = 0
fun main() = runBlocking {
massiveRun {
withContext(dispatcher) {
counter++
}
}
println(counter) // 1000000
}
这里虽然 counter++ 本身不是原子操作,但所有对它的访问都在同一个线程上依次执行,因此不会发生并发冲突。
粗粒度线程限制
第一种方式是粗粒度线程限制:把整个函数放入单线程调度器。
kotlin
class UserDownloader(
private val api: NetworkService
) {
private val users = mutableListOf<User>()
private val dispatcher =
Dispatchers.IO.limitedParallelism(1)
suspend fun downloaded(): List<User> =
withContext(dispatcher) {
users.toList()
}
suspend fun fetchUser(id: Int) =
withContext(dispatcher) {
val newUser = api.fetchUser(id)
users += newUser
}
}
这种方式简单、安全,但整个函数都在单线程调度器上执行。
也就是说,下面的网络请求也会被限制在同一个线程中:
kotlin
val newUser = api.fetchUser(id)
如果网络请求是阻塞式调用,或者函数内部执行 CPU 密集型计算,就可能导致其他用户请求排队等待,降低并发性能。
细粒度线程限制
第二种方式是细粒度线程限制:只保护访问共享状态的代码。
kotlin
class UserDownloader(
private val api: NetworkService
) {
private val users = mutableListOf<User>()
private val dispatcher =
Dispatchers.IO.limitedParallelism(1)
suspend fun downloaded(): List<User> =
withContext(dispatcher) {
users.toList()
}
suspend fun fetchUser(id: Int) {
// 网络请求可以并发执行
val newUser = api.fetchUser(id)
// 只有修改共享状态的部分需要串行化
withContext(dispatcher) {
users += newUser
}
}
}
执行过程是:
text
多个协程并发调用 api.fetchUser(id)
↓
请求完成
↓
进入单线程调度器
↓
依次修改 users
这样可以同时获得:
-
网络请求的并发能力;
-
共享列表修改的安全性。
如果 api.fetchUser() 是真正的非阻塞挂起函数,粗粒度和细粒度的性能差异可能不明显;如果它包含阻塞 I/O 或 CPU 密集型工作,细粒度方式通常更高效。
三种方案对比
| 方案 | 是否阻塞线程 | 适合场景 | 主要问题 |
|---|---|---|---|
| synchronized | 是 | 传统 Java 代码 | 不能在锁中调用挂起函数 |
| 原子变量 | 否 | 简单计数或引用替换 | 复杂多步骤操作难以处理 |
| 单线程调度器 | 否 | 保护协程共享状态 | 粗粒度使用可能降低并发能力 |
实际开发中,可以按以下原则选择:
kotlin
简单计数
→ AtomicInteger
单个引用的整体替换
→ AtomicReference
多个协程共享可变状态
→ Mutex 或单线程调度器
需要保护多个复杂步骤
→ Mutex 或专门的状态管理方案
单线程调度器通常比较容易使用,也比较高效,因为 Dispatchers.Default 和 Dispatchers.IO 会共享线程池中的线程资源。
Mutex
最后一种常见方案是使用 Mutex。
可以把它想象成一间只有一把钥匙的房间。谁拿到钥匙,谁就可以进入房间;其他协程如果也想进入,就必须等待钥匙归还。
Mutex 最重要的两个函数是:
text
mutex.lock()
mutex.unlock()
第一个协程调用 lock() 时,如果锁没有被占用,它可以直接继续执行。
如果另一个协程也调用 lock(),它不会阻塞线程,而是被挂起并进入等待队列。只有当前协程调用 unlock() 后,等待队列中的下一个协程才会恢复执行。
因此,lock() 和 unlock() 之间同一时间最多只有一个协程执行。
使用 lock 和 unlock
kotlin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
private val mutex = Mutex()
suspend fun main() = coroutineScope {
repeat(5) {
launch {
delayAndPrint()
}
}
}
suspend fun delayAndPrint() {
mutex.lock()
delay(1_000)
println("Done")
mutex.unlock()
}
输出类似:
text
1 秒后:Done
1 秒后:Done
1 秒后:Done
1 秒后:Done
1 秒后:Done
总耗时大约为 5 秒,因为每个协程都必须依次通过锁。
直接使用 lock 和 unlock 的风险
上面的写法存在严重风险。
如果 lock() 和 unlock() 之间发生异常,或者函数提前返回,unlock() 可能永远不会执行:
kotlin
suspend fun unsafeOperation() {
mutex.lock()
throw IllegalStateException("发生异常")
mutex.unlock() // 永远不会执行
}
此时锁就不会归还,其他协程会永远等待,最终形成死锁。
因此,不建议手动配对使用:
text
mutex.lock()
// ...
mutex.unlock()
如果确实需要手动加锁,至少应该使用 try-finally:
kotlin
suspend fun safeOperation() {
mutex.lock()
try {
// 临界区代码
} finally {
mutex.unlock()
}
}
推荐使用 withLock
withLock 会自动完成以下操作:
-
获取锁;
-
执行代码块;
-
无论是否发生异常,都在 finally 中释放锁。
kotlin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
private val mutex = Mutex()
private var counter = 0
fun main() = runBlocking {
massiveRun {
mutex.withLock {
counter++
}
}
println(counter) // 1000000
}
它等价于:
text
mutex.lock()
try {
counter++
} finally {
mutex.unlock()
}
所以通常应该优先使用:
kotlin
mutex.withLock {
// 访问或修改共享状态
}
Mutex 与 synchronized 的区别
Mutex 和 Java 的 synchronized 都可以保护共享状态,但它们的行为不同。
synchronized
text
synchronized(lock) {
counter++
}
当线程无法获取锁时,线程会被阻塞。
Mutex
kotlin
mutex.withLock {
counter++
}
当协程无法获取锁时,协程会被挂起,线程可以去执行其他协程。
因此,Mutex 更适合协程环境:
kotlin
synchronized:
等待锁 → 阻塞线程
Mutex:
等待锁 → 挂起协程,释放线程
这也是 Mutex 相对于 synchronized 的主要优势。
Mutex 不能重复加锁
Mutex 默认不是可重入锁。
也就是说,同一个协程不能在已经持有锁的情况下,再次获取同一个锁:
kotlin
suspend fun main() {
val mutex = Mutex()
println("Started")
mutex.withLock {
mutex.withLock {
println("Will never be printed")
}
}
}
输出:
text
Started
程序会一直等待,内部的第二次 withLock 永远无法完成。
原因是:
kotlin
第一次 withLock:已经持有 mutex
↓
第二次 withLock:再次请求同一个 mutex
↓
必须等待第一次释放
↓
但第一次必须等第二次执行完
↓
死锁
因此,在使用 Mutex 时,必须避免调用一个会再次获取同一把锁的函数。
不要让锁覆盖耗时挂起操作
Mutex 还有一个重要特点:
协程挂起时,锁不会自动释放。
例如:
kotlin
class MessagesRepository {
private val messages = mutableListOf<String>()
private val mutex = Mutex()
suspend fun add(message: String) {
mutex.withLock {
delay(1_000) // 模拟网络请求
messages.add(message)
}
}
}
测试代码:
kotlin
import kotlin.system.measureTimeMillis
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
suspend fun main() {
val repo = MessagesRepository()
val timeMillis = measureTimeMillis {
coroutineScope {
repeat(5) {
launch {
repo.add("Message$it")
}
}
}
}
println(timeMillis) // 大约 5120 毫秒
}
虽然 delay() 会挂起协程,但它仍然持有 mutex:
text
协程 1 获取锁
↓
delay(1000)
↓
协程 1 挂起,但锁仍被占用
↓
其他协程无法进入
↓
协程 1 恢复并释放锁
所以 5 个任务只能一个接一个执行,总耗时约为 5 秒。
与单线程调度器的对比
如果使用并行度为 1 的调度器:
kotlin
class MessagesRepository {
private val messages = mutableListOf<String>()
private val dispatcher =
Dispatchers.IO.limitedParallelism(1)
suspend fun add(message: String) {
withContext(dispatcher) {
delay(1_000) // 模拟网络请求
messages.add(message)
}
}
}
测试代码:
kotlin
suspend fun main() {
val repo = MessagesRepository()
val timeMillis = measureTimeMillis {
coroutineScope {
repeat(5) {
launch {
repo.add("Message$it")
}
}
}
}
println(timeMillis) // 大约 1058 毫秒
}
这里发生的是:
text
协程 1 执行 delay
↓
协程 1 挂起
↓
单线程可以执行其他协程
↓
其他协程也开始 delay
↓
大约 1 秒后全部完成
单线程调度器保证了共享状态的访问按顺序执行,但协程挂起时不会占用这个线程。
不要用 Mutex 包裹整个函数
下面的写法存在死锁风险:
kotlin
class MongoUserRepository : UserRepository {
private val mutex = Mutex()
override suspend fun updateUser(
userId: String,
userUpdate: UserUpdate
) = mutex.withLock {
val currentUser = getUser(userId) // 可能再次获取 mutex
deleteUser(userId) // 可能再次获取 mutex
addUser(currentUser.updated(userUpdate)) // 可能再次获取 mutex
}
override suspend fun getUser(
userId: String
): User = mutex.withLock {
// ...
}
override suspend fun deleteUser(
userId: String
) {
mutex.withLock {
// ...
}
}
override suspend fun addUser(
user: User
): User = mutex.withLock {
// ...
}
}
执行过程可能是:
text
updateUser() 获取 mutex
↓
updateUser() 调用 getUser()
↓
getUser() 再次获取同一个 mutex
↓
发生死锁
这就是为什么不能简单地把整个函数都包裹在 withLock 中。
粗粒度与细粒度保护
粗粒度保护
把整个函数包在锁中:
kotlin
mutex.withLock {
// 网络请求
// 计算
// 修改共享状态
}
优点是简单,缺点是锁的持有时间过长,容易降低并发性能。
细粒度保护
只保护真正访问共享状态的部分:
kotlin
suspend fun update() {
val result = loadData()
mutex.withLock {
sharedState = result
}
}
这样,耗时的网络请求和计算可以并发执行,只有修改共享状态时才需要排队。
总结
Mutex 是一种适合协程的同步工具:
-
获取不到锁时挂起协程,而不是阻塞线程;
-
适合保护共享可变状态;
-
推荐使用 withLock 自动释放锁;
-
不要在锁内调用可能再次获取同一把锁的函数;
-
不要把网络请求、数据库访问等耗时操作放在锁内;
-
锁的范围应尽可能小;
-
对于需要串行访问的共享状态,也可以考虑使用 limitedParallelism(1)。
选择原则可以概括为:
kotlin
简单原子变量
→ AtomicInteger / AtomicReference
少量共享状态
→ Mutex.withLock
需要整个状态区域串行执行
→ limitedParallelism(1)
传统同步代码
→ synchronized
Semaphore:信号量
提到 Mutex,还应该介绍 Semaphore。
它们的工作方式类似,但有一个重要区别:
-
Mutex 只有一把锁,同一时间只允许一个协程进入;
-
Semaphore 可以拥有多个许可证(permit),允许多个协程同时进入。
Mutex 与 Semaphore 的 API 对比
| Mutex | Semaphore |
|---|---|
| lock() | acquire() |
| unlock() | release() |
| withLock { } | withPermit { } |
Mutex 关注的是一把锁:
kotlin
mutex.withLock {
// 同时只能有一个协程执行
}
Semaphore 关注的是许可证数量:
kotlin
semaphore.withPermit {
// 同时允许指定数量的协程执行
}
Semaphore 的基本用法
kotlin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
suspend fun main() = coroutineScope {
val semaphore = Semaphore(2)
repeat(5) { index ->
launch {
semaphore.withPermit {
delay(1_000)
print(index)
}
}
}
}
输出类似:
text
01
// 等待约 1 秒
23
// 等待约 1 秒
4
执行过程如下:
kotlin
Semaphore(2)
第一批:
协程 0 获取许可证
协程 1 获取许可证
协程 2 等待
协程 3 等待
协程 4 等待
1 秒后:
协程 0、1 释放许可证
协程 2、3 获取许可证
再过 1 秒:
协程 2、3 释放许可证
协程 4 获取许可证
因此,Semaphore(2) 表示:
同时最多允许两个协程执行受保护代码。
withPermit 的作用
推荐使用:
kotlin
semaphore.withPermit {
doSomething()
}
它大致等价于:
text
semaphore.acquire()
try {
doSomething()
} finally {
semaphore.release()
}
即使代码块内部发生异常,许可证也会被释放,避免其他协程永久等待。
不推荐手动写成:
text
semaphore.acquire()
doSomething()
semaphore.release()
因为如果 doSomething() 抛出异常,release() 就不会执行。
Semaphore 不等于共享状态保护
多个许可证并不能解决共享状态问题。
例如:
kotlin
val semaphore = Semaphore(10)
var counter = 0
suspend fun increase() {
semaphore.withPermit {
counter++
}
}
这里最多允许 10 个协程同时执行 counter++,但仍然可能发生数据竞争,因为多个协程仍然可以同时修改 counter。
如果需要保护一个共享计数器,应该使用:
kotlin
val mutex = Mutex()
mutex.withLock {
counter++
}
或者使用原子变量:
kotlin
val counter = AtomicInteger(0)
counter.incrementAndGet()
可以这样理解:
kotlin
Mutex:
同一时间只允许一个协程访问共享状态
Semaphore(10):
同一时间允许最多十个协程访问资源
所以:
-
Mutex 主要用于互斥访问;
-
Semaphore 主要用于限制并发数量。
使用 Semaphore 限制网络请求
Semaphore 很适合限制同时进行的网络请求数量。
kotlin
class LimitedNetworkUserRepository(
private val api: UserApi
) {
// 最多允许 10 个请求同时执行
private val semaphore = Semaphore(10)
suspend fun requestUser(userId: String): User =
semaphore.withPermit {
api.requestUser(userId)
}
}
当同时有大量请求时:
text
前 10 个请求:立即执行
第 11 个及之后的请求:挂起等待
某个请求完成:释放许可证
等待队列中的下一个请求:恢复执行
这可以防止:
-
同时创建过多网络连接;
-
服务端被瞬间压垮;
-
客户端线程或连接池资源耗尽;
-
下游服务触发过于严格的并发限制。
需要注意,Semaphore(10) 限制的是"同时执行的请求数",不是严格意义上的每秒请求数。
例如:
kotlin
Semaphore(10)
表示最多 10 个请求同时运行,但不表示每秒只能发送 10 个请求。
如果需要限制每秒请求数,还需要结合延迟、时间窗口或专门的速率限制算法。
Semaphore(1) 与 Mutex(1)
从并发数量上看:
kotlin
Semaphore(1)
也只允许一个协程进入,因此看起来类似于:
kotlin
Mutex()
但两者概念不同:
-
Mutex 表示互斥锁;
-
Semaphore(1) 表示只有一个许可证;
-
Mutex 更适合保护共享状态;
-
Semaphore 更适合限制资源并发数。
因此,不要仅仅因为 Semaphore(1) 能实现互斥,就完全把它当作 Mutex 使用。
总结
协程中有多种方式可以避免共享状态冲突。
最实用的方案之一,是使用并行度限制为 1 的调度器:
kotlin
val dispatcher =
Dispatchers.Default.limitedParallelism(1)
可以只保护具体修改共享状态的位置:
kotlin
suspend fun updateState(value: Int) {
withContext(dispatcher) {
sharedState = value
}
}
这称为细粒度线程限制。
也可以把整个函数放到单线程调度器中:
kotlin
suspend fun updateState(value: Int) =
withContext(dispatcher) {
loadData()
calculate()
sharedState = value
}
这称为粗粒度线程限制。
两者的区别是:
| 方案 | 特点 |
|---|---|
| 细粒度线程限制 | 只保护需要同步的代码,性能通常更好 |
| 粗粒度线程限制 | 整个函数串行执行,实现简单,但可能降低并发能力 |
| 原子变量 | 适合简单变量或引用的原子更新 |
| Mutex | 适合互斥访问共享状态 |
| Semaphore | 适合限制同时运行的协程数量 |
可以用一句话概括:
kotlin
修改共享状态:
优先考虑单线程调度器、Mutex 或原子变量。
限制并发资源数量:
优先考虑 Semaphore。