为什么需要 IO 多路复用?
我们先从一个简单的场景开始,来了解什么是阻塞 IO(Blocking IO):
当你调用 read() 从 Socket 获取数据时,进程会调用 read()。内核发现数据还未到来,它就会挂起阻塞这个进程。当数据到来时,内核才会唤醒进程,进程才能接着执行拿到数据。
问题是同一时间有 100 个客户端连接怎么办?
难道要开 100 个线程,让每个线程都阻塞等待吗?所以我们就想着要是一个线程可以同时等待多个 IO 就好了,于是就有了 IO 多路复用。
IO 多路复用到底是什么?
简单来说,就是一个线程,同时监视多个文件描述符,只要任意一个就绪,就通知我们来处理。
这场景像不像某些预制菜外卖店,一个"大厨"同时盯着多个微波炉,只要某台微波炉发出了"叮"的一声,就会过去打包出餐。
其中多路指的是多个 IO 流或多个 Socket,复用则是复用同一个线程/进程去处理,复用的是等待的能力。
操作系统的 select 机制
而 select 就是一个内核级支持的 IO 多路复用机制,它的工作逻辑是:
告诉内核当前监视的 Socket 集合,调用 select 进入阻塞,此时进程休眠,不占用 CPU。具体的工作由内核完成,内核会帮我轮询所有的 Socket,一旦发现有一个就绪(可读、可写),select 就会返回,我们再去遍历找到就绪的那个进行处理。
它有很多与生俱来的缺陷,因此后续还出现了 poll,出现了 epoll。
协程中的多路复用
这里先说明一点:操作系统的 select 是内核级监控文件描述符,而我们接下来要讲的 Kotlin 协程的 select 则是用户态的机制。它监控的是协程间的通信事件,复用的是协程的挂起和恢复能力,而不是线程的阻塞。
回到本节的主题,我们来看看多路复用在协程中到底怎么用。
Deferred 的 await 复用
首先是 await 可以进行复用,想象一下这个场景:当应用数据可以从本地或网络获取,我们只希望展示更先返回的数据。
这时,可以使用 select 这样做:
kotlin
fun main(): Unit = runBlocking {
val userId = "123456"
repeat(2) {
val localDeferred = getUserFromLocal(userId)
val remoteDeferred = getUserFromRemote(userId)
val userResult = select {
localDeferred.onAwait { it }
remoteDeferred.onAwait { it }
}
userResult?.let {
println("user result: $userResult")
} ?: run {
// 获取网络数据展示最终结果
val remoteUseResult = remoteDeferred.await()
println("user result: $remoteUseResult")
}
// 拿到结果后,主动取消所有任务,防止协程泄漏
localDeferred.cancel()
remoteDeferred.cancel()
}
}
// 模拟本地缓存
object UserCache {
private val cache = ConcurrentHashMap<String, String>()
fun get(userId: String): String? {
return cache[userId]
}
fun put(userId: String, value: String) {
cache[userId] = value
}
}
fun CoroutineScope.getUserFromLocal(userId: String): Deferred<String?> = async(Dispatchers.IO) {
delay(Random.nextLong(1000, 2000).milliseconds)
UserCache.get(userId)
}
fun CoroutineScope.getUserFromRemote(userId: String): Deferred<String> = async(Dispatchers.IO) {
delay(Random.nextLong(1000, 3000).milliseconds)
val userData = "User-$userId's data(remote)"
UserCache.put(userId, "User-$userId's data(local)")
userData
}
我们调用了 Deferred 的 onAwait 函数,在 select 中注册了回调,select 会调用最先返回的事件。
Channel 的读取复用
复用 Channel 和复用 await 类似:
kotlin
fun main(): Unit = runBlocking {
val channels = List(10) { Channel<Int>() }
// 随机发送数据
launch {
delay(300.milliseconds)
channels[Random.nextInt(0, channels.size)].send(1)
}
// 接收最先收到的数据
val result = select {
channels.forEachIndexed { index, channel ->
// 或者调用 onReceive,它会将异常直接抛出
channel.onReceiveCatching { result ->
result.onSuccess { data ->
return@onReceiveCatching "Channel [${index + 1}] ==> $data"
}
// 当通道被关闭或是出现异常时返回 null 作为兜底,避免程序直接崩溃
return@onReceiveCatching null
}
}
}
println(result)
}
SelectClauseN 接口解析
能被 select 的事件,都实现了 SelectClauseN 接口,例如刚刚的 onReceiveCatching 成员属性类型是 SelectClause1<ChannelResult<E>>。
事件有以下几种类型:
SelectClause0: 事件没有返回值,例如Job.join()没有返回值,因此其onJoin成员属性是SelectClause0类型。SelectClause1: 事件有返回值,例如上面的onAwait和onReceive。SelectClause2: 事件有返回值,同时还需额外的参数。
以 Channel 的 onSend 为例,它有一个 param 参数和一个 block 返回值,当指定的参数 param 成功被发送到通道时,block 回调将会触发,block 中的参数是成功发送到的 Channel 对象。
官方示例:
kotlin
fun main(): Unit = runBlocking {
val sendChannels = List(4) { index ->
Channel<Int>(
onUndeliveredElement = {
println("Undelivered element $it for $index")
}
).also { channel ->
launch {
withTimeout(1.seconds) {
println("Consumer $index receives: ${channel.receive()}")
}
}
}
}
val element = 42
select {
for (channel in sendChannels) {
channel.onSend(element) {
println("Sent to channel $it")
}
}
}
}
上述代码会随机消费一个 Channel,此时对应的 Channel 就会成功发送数据,并触发成功回调。剩余的三个 Channel 会被 select 取消,走到 onUndeliveredElement 回调。
使用 Flow 实现复用
使用 Flow 可以实现上述多路复用的效果,就是只取第一个任务,收到第一个任务后,手动取消其余 Job:
kotlin
fun main(): Unit = runBlocking {
val userId = "123456"
// 并发调用获取 Deferred 列表
val deferredList = listOf(
getUserFromLocal(userId),
getUserFromRemote(userId)
)
try {
deferredList.map { deferred ->
// 创建单独的 Flow 来获取结果
flow {
val result = deferred.await()
emit(result)
}
}
.merge()
.first().also {
println(it)
}
} finally {
// 取消剩余任务
deferredList.forEach {
it.cancel()
}
}
}
同理,Channel 的读取复用:
kotlin
fun main(): Unit = runBlocking {
val size = 10
val channels = List(size) {
Channel<String>()
}
val flows = channels.map {
it.consumeAsFlow()
}.merge()
launch {
val index = Random.nextInt(size)
channels[index].send("67 from Channel [$index]")
}
val result = flows.first()
println(result)
}
协程的并发安全
为什么会有并发安全问题?
Kotlin 协程也存在着并发安全,因其运行在 Java 平台上,最终会被调度到某个线程上执行。
因此,count++ 是不安全的,我们来看一个简单的计数问题:
kotlin
fun main(): Unit = runBlocking {
var count = 0
// 让这个数字尽可能大
List(100000) {
launch(Dispatchers.Default) {
count++
}
}.joinAll()
println("the final count: $count")
}
结果可能是:99849。
原因我们都很清楚了:
count变量不可见,它的读写不会立即同步到主内存。count++不是原子操作,读、改、写的过程中会被其他线程插入。
如果不清楚的话,可以看我的这篇博客:Java 多线程指南:从基础用法到线程安全
协程并发安全的解决方案
为了解决这个问题,我们可以将 count 声明为原子类,也可以直接加锁。但在协程中,我们有更好的解决方案:
Channel: 并发安全的消息通道。Mutex: 轻量级锁,语义上与线程锁类似,但获取不到锁时,它不会阻塞线程,只是会挂起等待锁释放。
kotlin
fun main(): Unit = runBlocking {
var count = 0
val mutex = Mutex()
List(100000) {
launch {
// 封装了 lock 和 unlock 操作
mutex.withLock {
count++
}
}
}.joinAll()
println("the final count: $count")
}
Semaphore: 轻量级信号量,控制同一时间访问资源的协程最大并发数量。当参数为 1 时,效果等价于使用 Mutex。
kotlin
fun main(): Unit = runBlocking {
// 最多允许两个协程同时访问
val semaphore = Semaphore(2)
val jobs = List(5) { taskId ->
launch(Dispatchers.Default) {
// 获取许可证
semaphore.acquire()
try {
println("Starting task $taskId, thread: ${Thread.currentThread().name}")
delay(3000.milliseconds)
println("End task $taskId, thread: ${Thread.currentThread().name}")
} finally {
// 归还许可证
semaphore.release()
}
}
}
jobs.joinAll()
}
上述代码展示了 Semaphore 最原始的获取和释放机制,实际开发中可以使用
Semaphore.withPermit { ... }扩展函数,帮我们自动完成许可证的释放,用法和Mutex的withLock完全一样。
总结:最好的并发就是不共享状态
实际上,大多时候我们都无需硬刚线程安全问题。
通过让协程访问的共享资源不可变 、让访问外部状态的函数变为纯函数,这样就能避免并发安全。
以前面的计数为例:
kotlin
fun main(): Unit = runBlocking {
val count = 0
val sum = List(100000) {
async {
1
}
}.awaitAll().sum()
val result = count + sum
println("the result is $result")
}
运行结果:"the result is 100000"。