协程并发执行与共享状态

协程并发执行与共享状态

本文整理 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。
相关推荐
miaowmiaow1 小时前
我让 AI 给老项目做了一次分层架构升级:对标 Now in Android,从「穿层」到「端口与适配器」
android·ai编程·deepseek
mmsx1 小时前
Android 栅格数据缓冲区的类型包装:用匿名子类破解泛型擦除
android·地图·栅格
叶羽西1 小时前
Android Camera HAL调整图像处理线程优先级
android
HouWan2 小时前
Flutter: MediaQuery.of(context) 为什么可能拖慢页面?
android·flutter·ios
事圆则缓2 小时前
Android 图片内存到底怎么算
android
Godikov3 小时前
弱网环境离线优先:Android 终端数据可靠同步架构实战
android
爱编程的小新☆6 小时前
Fake GPS 虚拟定位保姆级使用教学
android·移动开发·gps·虚拟定位
执明wa6 小时前
Android RecyclerView 多类型, 多种 Item
android·xml·开发语言·设计模式·android studio