MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Kotlin协程中的挂起函数与通道

2022-07-154.4k 阅读

Kotlin 协程基础回顾

在深入探讨 Kotlin 协程中的挂起函数与通道之前,我们先来简要回顾一下 Kotlin 协程的基础概念。

Kotlin 协程是一种轻量级的异步编程模型,它允许我们以一种更简洁、更易读的方式编写异步代码,而无需像传统的异步编程那样处理复杂的回调地狱。协程通过暂停和恢复执行的机制,让代码在等待某些异步操作(如网络请求、文件读取等)完成时,释放当前线程的资源,从而提高程序的性能和响应性。

协程的启动方式

在 Kotlin 中,我们可以通过多种方式启动协程。最常见的是使用 GlobalScope.launch 函数,它会在全局作用域中启动一个新的协程:

import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch {
        // 协程代码
        println("Hello, Coroutine!")
    }
    Thread.sleep(1000)
}

在上述代码中,GlobalScope.launch 启动了一个新的协程,在协程内部打印了一条消息。由于协程是异步执行的,为了让主线程等待协程执行完毕,我们使用 Thread.sleep(1000) 让主线程暂停 1 秒。

另一种常用的启动协程的方式是 runBlocking,它会阻塞当前线程直到内部协程执行完毕:

fun main() = runBlocking {
    launch {
        println("Hello, runBlocking!")
    }
}

这里 runBlocking 会阻塞 main 函数所在的线程,直到内部的协程执行完毕。

挂起函数

什么是挂起函数

挂起函数是 Kotlin 协程中的核心概念之一。挂起函数是一种特殊的函数,它可以暂停协程的执行,直到某个异步操作完成,然后再恢复协程的执行。挂起函数的定义需要使用 suspend 关键字。

例如,假设我们有一个模拟网络请求的函数 fetchData,它需要一些时间来完成:

import kotlinx.coroutines.*

suspend fun fetchData(): String {
    delay(2000) // 模拟网络延迟
    return "Data fetched successfully"
}

fun main() = runBlocking {
    val result = fetchData()
    println(result)
}

在上述代码中,fetchData 是一个挂起函数,它使用 delay 函数模拟了网络延迟。delay 本身也是一个挂起函数,它会暂停当前协程的执行指定的时间(这里是 2 秒)。当 fetchData 被调用时,协程会暂停在 delay 处,释放当前线程的资源,直到 2 秒后恢复执行并返回结果。

挂起函数的特点

  1. 只能在协程或其他挂起函数中调用:挂起函数不能在普通的函数中直接调用,因为普通函数没有协程上下文来支持挂起和恢复操作。例如:
fun normalFunction() {
    // 下面这行代码会报错,因为不能在普通函数中调用挂起函数
    // fetchData() 
}
  1. 不会阻塞线程:与传统的阻塞操作(如 Thread.sleep)不同,挂起函数不会阻塞它所在的线程。当挂起函数暂停协程执行时,线程可以去执行其他任务,从而提高了程序的并发性能。

自定义挂起函数

我们可以根据实际需求自定义挂起函数。例如,假设我们有一个需要在后台线程执行一些计算任务并返回结果的场景:

import kotlinx.coroutines.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

val executor: ExecutorService = Executors.newSingleThreadExecutor()

suspend fun performCalculation(): Int = suspendCancellableCoroutine { cont ->
    executor.submit {
        try {
            val result = (1..1000).sum()
            cont.resume(result)
        } catch (e: Exception) {
            cont.cancel(e)
        }
    }
}

fun main() = runBlocking {
    val result = performCalculation()
    println("Calculation result: $result")
    executor.shutdown()
}

在上述代码中,performCalculation 是一个自定义的挂起函数。它使用 suspendCancellableCoroutine 来创建一个可取消的协程。在 executor.submit 中,我们在后台线程执行计算任务(这里是计算 1 到 1000 的和),当计算完成后,通过 cont.resume 将结果返回给调用者。如果在执行过程中发生异常,则通过 cont.cancel 取消协程。

通道(Channel)

通道的基本概念

通道是 Kotlin 协程中用于在协程之间传递数据的一种机制,类似于生产者 - 消费者模型。通道提供了一种线程安全的方式来在不同协程之间发送和接收数据。

在 Kotlin 中,通道由 Channel 接口表示,它有两个主要操作:send 用于向通道发送数据,receive 用于从通道接收数据。

创建通道

我们可以使用 Channel 构造函数来创建通道,同时可以指定通道的容量。例如,创建一个容量为 10 的通道:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel = Channel<Int>(10)
    // 这里可以使用 channel 进行数据的发送和接收
}

通道的容量决定了在不阻塞发送者的情况下,通道可以缓存多少个元素。如果通道已满,调用 send 操作会挂起发送者协程,直到有接收者从通道中取出数据,为新的数据腾出空间。

发送和接收数据

下面是一个简单的生产者 - 消费者示例,展示了如何使用通道在协程之间发送和接收数据:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel = Channel<Int>()

    // 生产者协程
    launch {
        for (i in 1..5) {
            channel.send(i)
            println("Sent: $i")
        }
        channel.close()
    }

    // 消费者协程
    launch {
        for (num in channel) {
            println("Received: $num")
        }
        println("Channel is closed")
    }
}

在上述代码中,生产者协程通过 channel.send 向通道发送数据,从 1 到 5。消费者协程使用 for - in 循环从通道接收数据,当通道关闭时(通过 channel.close()),for - in 循环结束。

通道的类型

  1. 无缓冲通道(Unbuffered Channel):创建通道时不指定容量或者指定容量为 0 时,就是无缓冲通道。对于无缓冲通道,send 操作会一直挂起,直到有接收者准备好接收数据,反之亦然。例如:
val unbufferedChannel = Channel<Int>()
  1. 有缓冲通道(Buffered Channel):如前面示例中创建的容量为 10 的通道就是有缓冲通道。有缓冲通道允许在没有接收者的情况下,发送者先向通道中发送一定数量的数据(不超过通道容量)。

  2. 广播通道(Broadcast Channel):广播通道允许一个发送者向多个接收者发送数据。它由 BroadcastChannel 类表示。例如:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BroadcastChannel

fun main() = runBlocking {
    val broadcastChannel = BroadcastChannel<Int>(10)

    // 多个接收者协程
    repeat(3) {
        launch {
            for (num in broadcastChannel.openSubscription()) {
                println("Receiver $it received: $num")
            }
        }
    }

    // 发送者协程
    launch {
        for (i in 1..5) {
            broadcastChannel.send(i)
            println("Sent: $i")
        }
        broadcastChannel.close()
    }
}

在上述代码中,BroadcastChannel 可以向多个通过 openSubscription 订阅的接收者发送数据。

挂起函数与通道的结合使用

使用挂起函数向通道发送数据

在实际应用中,我们常常会在挂起函数中向通道发送数据。例如,假设我们有一个挂起函数 fetchAndSendData,它从网络获取数据并发送到通道:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

suspend fun fetchAndSendData(channel: Channel<String>) {
    val data = fetchData() // 假设 fetchData 是前面定义的挂起函数,用于模拟网络请求
    channel.send(data)
}

fun main() = runBlocking {
    val channel = Channel<String>()

    launch {
        fetchAndSendData(channel)
    }

    launch {
        val receivedData = channel.receive()
        println("Received from channel: $receivedData")
    }
}

在上述代码中,fetchAndSendData 是一个挂起函数,它先调用 fetchData 获取数据,然后将数据发送到通道。另一个协程从通道接收数据并打印。

使用通道控制挂起函数的执行

通道也可以用于控制挂起函数的执行逻辑。例如,我们可以通过通道发送一个信号,告诉某个挂起函数停止执行:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

suspend fun longRunningTask(controlChannel: Channel<Boolean>) {
    var counter = 0
    while (true) {
        if (controlChannel.receive()) {
            break
        }
        delay(1000)
        println("Task is running: $counter")
        counter++
    }
    println("Task stopped")
}

fun main() = runBlocking {
    val controlChannel = Channel<Boolean>()

    launch {
        longRunningTask(controlChannel)
    }

    delay(3000) // 运行 3 秒后停止任务
    controlChannel.send(true)
}

在上述代码中,longRunningTask 是一个长时间运行的挂起函数,它通过 controlChannel 接收控制信号。当接收到 true 时,任务停止执行。主线程在 3 秒后向通道发送 true 信号,从而停止任务。

通道的关闭与异常处理

通道的关闭

正确关闭通道对于确保协程之间的通信正常结束非常重要。如前面的示例中,我们通过调用 channel.close() 来关闭通道。当通道关闭后,发送者不能再向通道发送数据,接收者在接收到所有已发送的数据后,也会结束接收操作。

例如,在下面的代码中,我们在生产者协程完成数据发送后关闭通道:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel = Channel<Int>()

    launch {
        for (i in 1..3) {
            channel.send(i)
        }
        channel.close()
    }

    launch {
        for (num in channel) {
            println("Received: $num")
        }
        println("Channel closed, no more data")
    }
}

当生产者协程关闭通道后,消费者协程的 for - in 循环会正常结束,并打印提示信息。

通道的异常处理

在通道的使用过程中,可能会发生各种异常,例如在发送或接收数据时出现错误。Kotlin 协程提供了一些机制来处理这些异常。

  1. 在发送数据时处理异常:如果在 send 操作时发生异常,异常会被抛给发送者协程。例如:
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel = Channel<Int>()

    launch {
        try {
            channel.send(1)
            // 模拟发送数据时的异常
            throw RuntimeException("Send error")
            channel.send(2)
        } catch (e: Exception) {
            println("Caught send error: $e")
        }
    }

    launch {
        val num = channel.receive()
        println("Received: $num")
    }
}

在上述代码中,发送者协程在发送第二个数据前抛出了异常,通过 try - catch 块捕获并处理了该异常。

  1. 在接收数据时处理异常:如果在 receive 操作时发生异常,异常会被抛给接收者协程。例如:
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel = Channel<Int>()

    launch {
        channel.send(1)
        // 关闭通道并抛出异常
        channel.close(RuntimeException("Close with error"))
    }

    launch {
        try {
            val num = channel.receive()
            println("Received: $num")
            val anotherNum = channel.receive()
        } catch (e: Exception) {
            println("Caught receive error: $e")
        }
    }
}

在上述代码中,生产者协程关闭通道时抛出了异常,接收者协程在第二次调用 receive 时捕获到该异常。

高级通道操作

通道的选择操作(Select)

在某些情况下,我们可能需要同时监听多个通道,并在其中一个通道有数据可用时做出响应。Kotlin 协程提供了 select 表达式来实现这种功能。

例如,假设我们有两个通道 channel1channel2,我们希望在任意一个通道有数据时打印出来:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val channel1 = Channel<Int>()
    val channel2 = Channel<String>()

    launch {
        delay(1000)
        channel1.send(42)
    }

    launch {
        delay(2000)
        channel2.send("Hello")
    }

    select<Unit> {
        channel1.onReceive { value ->
            println("Received from channel1: $value")
        }
        channel2.onReceive { value ->
            println("Received from channel2: $value")
        }
    }
}

在上述代码中,select 表达式同时监听 channel1channel2。当其中一个通道有数据时,对应的 onReceive 块会被执行。

通道的转换操作

有时我们需要对通道中的数据进行转换,例如将一种类型的数据转换为另一种类型。Kotlin 协程提供了一些函数来实现通道数据的转换。

例如,我们有一个 Int 类型的通道,我们想将其中的数据转换为 String 类型并发送到另一个通道:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
    val intChannel = Channel<Int>()
    val stringChannel = Channel<String>()

    launch {
        for (i in 1..3) {
            intChannel.send(i)
        }
        intChannel.close()
    }

    launch {
        intChannel.consumeEach { num ->
            stringChannel.send("$num")
        }
        stringChannel.close()
    }

    launch {
        stringChannel.consumeEach { str ->
            println("Received string: $str")
        }
    }
}

在上述代码中,intChannel.consumeEach 遍历 intChannel 中的数据,并将其转换为 String 类型后发送到 stringChannel

实际应用场景

数据处理流水线

在数据处理场景中,我们可以使用通道构建数据处理流水线。例如,假设有一个数据采集模块、一个数据清洗模块和一个数据分析模块,我们可以通过通道将它们连接起来:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

// 模拟数据采集
suspend fun collectData(channel: Channel<String>) {
    val dataList = listOf("data1", "data2", "data3")
    for (data in dataList) {
        delay(1000)
        channel.send(data)
    }
    channel.close()
}

// 模拟数据清洗
suspend fun cleanData(inputChannel: Channel<String>, outputChannel: Channel<String>) {
    for (data in inputChannel) {
        val cleanedData = data.replace(" ", "")
        outputChannel.send(cleanedData)
    }
    outputChannel.close()
}

// 模拟数据分析
suspend fun analyzeData(channel: Channel<String>) {
    for (data in channel) {
        println("Analyzing: $data")
        delay(1000)
    }
}

fun main() = runBlocking {
    val collectChannel = Channel<String>()
    val cleanChannel = Channel<String>()

    launch {
        collectData(collectChannel)
    }

    launch {
        cleanData(collectChannel, cleanChannel)
    }

    launch {
        analyzeData(cleanChannel)
    }
}

在上述代码中,数据从采集模块通过 collectChannel 发送到清洗模块,清洗后的数据通过 cleanChannel 发送到分析模块,形成了一个数据处理流水线。

多线程协作

在多线程协作的场景中,通道可以用于线程间的数据传递和同步。例如,假设我们有一个主线程和几个工作线程,主线程通过通道向工作线程分配任务,工作线程完成任务后通过另一个通道返回结果:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

val executor: ExecutorService = Executors.newFixedThreadPool(3)

fun main() = runBlocking {
    val taskChannel = Channel<String>()
    val resultChannel = Channel<String>()

    // 主线程分配任务
    launch {
        for (i in 1..5) {
            taskChannel.send("Task $i")
        }
        taskChannel.close()
    }

    // 工作线程执行任务
    repeat(3) {
        launch {
            for (task in taskChannel) {
                val result = executor.submit {
                    // 模拟任务执行
                    delay(1000)
                    "Result of $task"
                }.get()
                resultChannel.send(result)
            }
        }
    }

    // 主线程接收结果
    launch {
        for (result in resultChannel) {
            println(result)
        }
    }
}

在上述代码中,主线程通过 taskChannel 向工作线程发送任务,工作线程执行任务后通过 resultChannel 将结果返回给主线程。

通过深入理解 Kotlin 协程中的挂起函数与通道,我们可以更加高效地编写异步、并发的代码,解决各种实际应用中的问题。无论是处理网络请求、构建数据处理流水线还是实现多线程协作,挂起函数和通道都为我们提供了强大而灵活的工具。