深入浅出 Kotlin 协程:从核心概念到实战技巧

243 阅读10分钟

前言

什么是协程呢?

它和线程类似,你可以简单理解为轻量级的线程。因为不同线程之间的切换需要靠操作系统的调度来实现,而不同协程之间的切换仅需在语言层面就能实现。更准确来说,协程的挂起和恢复不涉及操作系统内核级的线程上下文切换,其开销极小,这就是它高效的原因。

举个例子,比如我们有两个任务:

suspend fun taskA() {
    val threadName = Thread.currentThread().name
    println("任务 A 开始执行 - 线程: '$threadName'")
    delay(300)
    println("任务 A 完成 1/3 ...")
    delay(300)
    println("任务 A 完成 2/3 ...")
    delay(300)
    println("任务 A 完成 3/3 ...")
    println("任务 A 已完成")
}

suspend fun taskB() {
    val threadName = Thread.currentThread().name
    println("任务 B 开始执行 - 线程: '$threadName'")
    delay(300)
    println("任务 B 完成 1/2 ...")
    delay(300)
    println("任务 B 完成 2/2 ...")
    println("任务 B 已完成")
}

我们使用两个协程分别执行这两个任务,其运行结果可能为:

任务 A 开始执行 - 线程: 'main'
任务 B 开始执行 - 线程: 'main'
任务 A 完成 1/3 ...
任务 B 完成 1/2 ...
任务 A 完成 2/3 ...
任务 B 完成 2/2 ...
任务 B 已完成
任务 A 完成 3/3 ...
任务 A 已完成

可以看到,虽然这两个协程运行在同一个线程中,但方法的执行顺序是交错的。这就是协程以单线程模式模拟并发编程的效果。协程执行的挂起与恢复完全由 Kotlin 运行时来控制,使得并发的效率大大提升。

协程的基本用法

添加依赖与初体验

使用协程,需要在 app/build.gradle.kts 文件中添加其依赖:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}

然后就是开启一个协程,最简单的方式是调用 GlobalScopelaunch() 函数:

@OptIn(DelicateCoroutinesApi::class)
fun main() {
    GlobalScope.launch {
        println("start execution")
        println("execution is completed")
    }
}

现在运行 main() 函数,你会发现日志并没有被打印出来。

这是因为 GlobalScope 创建的是顶层协程,它的生命周期与应用的进程绑定,会随着应用的停止而结束。并且不会阻塞启动它的线程,也就是不会阻塞 main 函数的执行。所以当 main 函数结束导致应用停止时,该协程也会被随之被取消,而此时 launch() 函数中的代码还没来得及执行。

你可以让程序延迟结束来解决这个问题,代码如下:

@OptIn(DelicateCoroutinesApi::class)
fun main() {
    GlobalScope.launch {
        println("start execution")
        println("execution is completed")
    }
    Thread.sleep(1000) // 阻塞主线程 1 秒
}

但这也存在着问题:如果协程运行所需的时长大于主线程阻塞的时长,协程还是会被强制中断。例如:

@OptIn(DelicateCoroutinesApi::class)
fun main() {
    GlobalScope.launch {
        println("start execution")
        delay(1500) // 让协程挂起 1.5 秒
        println("execution is completed")
    }
    Thread.sleep(1000)
}

使用 runBlocking 保证执行

为此,我们可以使用 runBlocking() 函数来完美解决上述问题。如下:

fun main() {
    runBlocking {
        println("start execution")
        delay(1500) 
        println("execution is completed")
    }
}

runBlocking() 函数同样会创建协程作用域,它可保证其作用域中的所有代码和子协程在执行完毕之前,会一直阻塞着调用它的线程。所以,它很适合用在 main 函数或是单元测试中。但在 Android 的主线程中,我们应该避免使用它,因为它可能会导致应用无响应问题。

创建多个协程与结构化并发

如果要创建多个协程,只需在协程作用域中多次调用 launch() 函数即可。比如:

fun main() {
    runBlocking { // 父作用域
        launch { // 子协程 1
            println("launchA start execution")
            delay(1000)
            println("launchA execution is completed")
        }

        launch { // 子协程 2
            println("launchB start execution")
            delay(1000)
            println("launchB execution is completed")
        }
    }
}

在上述代码中,我们调用了两次 launch() 函数,创建了两个子协程。这里体现了协程的结构化并发思想。父作用域会在其下的所有子协程执行完毕后才会结束。如果外层作用域的协程被取消了,那么该作用域下的所有子协程也会随之被取消。

前面我们说过,协程的并发效率远高于线程,现在我们就来看看。代码如下:

fun main() {
    val start = System.currentTimeMillis()
    runBlocking {
        repeat(100000) { 
            launch {
                // 执行任务
                println("launch ${it+1}")
            }
        }
    }
    val end = System.currentTimeMillis()
    println("Total time: ${end - start} ms")
}

我们创建了 100000 个协程。运行程序,你会发现耗时非常短,只有 381 毫秒。如果开启的是 100000 个线程的话,应用早就因内存溢出而崩溃了。

挂起函数与 coroutineScope

随着协程中的逻辑越来越复杂,我们不可避免地将部分逻辑抽取成函数。但在函数中的代码可没有协程作用域,我们该怎么在其中调用像 delay() 这样的挂起函数呢?

Kotlin 提供了一个 suspend 关键字,它可将一个函数声明为挂起函数,而挂起函数之间是可以相互调用的。所以:

suspend fun helloWorld() {
    print("hello ")
    delay(1000)
    println("world")
}

这样就能在抽取出来的函数中调用挂起函数了。但如果我们想在函数中提供一个协程作用域来并发执行一些任务,该怎么办呢?

这个问题也好解决,可以调用 coroutineScope 挂起函数,它会继承外部的协程作用域并创建一个子协程,并将其代码块中最后一行代码的执行结果返回。如下:

suspend fun doSomething() = coroutineScope { // 创建作用域
    launch { // 并发任务1
        print("hello ")
        delay(1000)
        println("world")
    }
    launch { // 并发任务2
        print("goodbye ")
        delay(500)
        println("world")
    }
}

另外,coroutineScope 函数和 runBlocking 有些类似,它可保证其作用域中的所有代码和子协程在执行完毕之前,外部的协程会被挂起

注意,它们的区别是:coroutineScope 只会挂起当前协程,不会影响其他协程,更不会阻塞线程。而 runBlocking阻塞外部线程,所以在实际项目中,不推荐使用。

更多的作用域构建器

我们知道了 GlobalScope.launchrunBlockinglaunchcoroutineScope 这几种协程作用域构建器。其中 GlobalScope.launchrunBlocking 可在任意地方调用,coroutineScope 可在协程作用域或是挂起函数中调用,而 launch 只能在协程作用域中调用。

并且我们并不推荐使用 runBlockingGlobalScope.launch,前者的原因我们已经说过了,那么后者呢?

为什么不推荐使用 GlobalScope.launch

因为它破坏了 “结构化并发” 的原则,管理成本太高了。

GlobalScope 创建的协程是顶层协程,其生命周期和应用进程一样长,且不受任何父作用域的约束。很容易导致协程在不需要的时候,仍在后台运行,造成资源浪费甚至内存泄露。

当要取消时,我们需要获取每个 GlobalScope.launch 返回的 Job 对象,然后调用其 cancel() 方法一一取消,非常麻烦。比如:

// 手动管理的麻烦
val job1 = GlobalScope.launch {
    // ...
}
val job2 = GlobalScope.launch {
    // ...
}
job1.cancel()
job2.cancel()

所以不推荐使用 GlobalScope.launch。在实际项目中,我们更多会使用 CoroutineScope() 函数来创建和管理与业务逻辑生命周期绑定的作用域。例如:

// 创建 Job 对象
val job = Job()
// 传入 Job 对象,获取 CoroutineScope 对象
val scope = CoroutineScope(job)
scope.launch {
    // ...
}
scope.launch {
    // ...
}
job.cancel()

在上述代码中,只需调用一次 cancel() 方法,就能将该作用域下的所有协程取消。

我们通常会将协程作用域和组件的生命周期绑定在一起,比如在 ViewModel 中,viewModelScope 就会在 ViewModel 被销毁时自动取消所有协程,完美解决了生命周期管理问题。

使用 async 获取协程结果

现在,我们只是使用协程执行了一段逻辑,但却不知道执行结果。因为 launch 的返回值是 Job 对象,那该怎么办?

其实可以借助 async() 函数来完成,该函数只能在协程作用域中调用。它会创建一个子协程并返回一个 Deferred 对象,只要我们调用该对象的 await() 方法,就能够获取协程的执行结果。例如:

fun main() {
    runBlocking {
        val result = async {
            delay(1000)
            val number = (1..100).random()
            number // 最后一行的值会作为结果
        }.await()
        println("the random number is $result")
    }
}

实际上,在调用了 async() 函数时,其代码块中的代码会立即开始执行。当调用 await() 方法时,如果代码块中的代码还没执行完,那么 await() 方法会阻塞当前协程,直到能够获取 async() 函数的执行结果。

利用这个特性,我们就能够实现并行执行,但这里存在一个常见陷阱。请看下面的代码:

fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val result1 = async {
            delay(1000)
            1
        // 立即调用 await 函数,会导致后续代码必须等待它完成
        }.await()  
        
        val result2 = async {
            delay(1000)
            2
        }.await()
        
        println("The sum is ${result1 + result2}")
        val end = System.currentTimeMillis()
        println("cost ${end - start} ms.")
    }
}

运行代码,可以看到耗时为 2048 毫秒。说明这两个 async() 函数之间的关系是串行的,不是并行,降低了效率。

正确的并行写法应该是:

fun main() {
    runBlocking {
        val start = System.currentTimeMillis()
        val deferred1 = async { // 启动第一个任务
            delay(1000)
            1
        }
        val deferred2 = async { // 同时启动第二个任务
            delay(1000)
            2
        }
        // 最后一起等待结果
        println("The sum is ${deferred1.await() + deferred2.await()}")
        val end = System.currentTimeMillis()
        println("cost ${end - start} ms.") 
    }
}

我们只是将 await() 方法的调用推迟到了所有 async() 函数调用之后,这样这两个 async() 函数之间的关系就变为并行了,执行耗时只有 1041 毫秒。

使用 withContext 切换线程

最后,我们来看一个特殊的作用域构造器:withContext() 函数。它是一个挂起函数,可在协程作用域或是另一个挂起函数中调用。

你可以把它简单理解为 async { ... }.await() 的简化版。因为调用该函数时,也会立即执行其代码块中的代码,也会将外部协程挂起,当代码块中的代码全部执行完毕后,会返回最后一行代码的执行结果。

例如下面的代码:

fun main() {
    runBlocking {
        val randomNumber = withContext(Dispatchers.Default) {
            delay(1000)
            (1..100).random()
        }
        println("The random numbers is $randomNumber")
    }
}

withContextasync 的关键区别在于 withContext() 函数强制要求我们传入一个协程上下文(CoroutineContext),这个参数通常是调度器(Dispatcher),用于指定协程在哪个线程池中执行。

因为很多情况下,我们需要开启线程来执行并发任务。比如 Android 中的网络请求要求必须在子线程中执行,如果你开启了属于主线程的协程去执行的话,程序会崩溃。

参数的值主要有以下几种:

  • Dispatchers.Default:表示默认的、适合 CPU 密集型任务的线程池。

  • Dispatchers.IO:表示为高并发的 IO 密集型任务(如网络请求、读写文件)优化的线程池。

  • Dispatchers.Main:表示Android主线程,用于UI更新。这个值只有在 Android 项目中才能使用。

实际上,之前的协程作用域构建器中,除了 coroutineScope 函数,其余的都可以传入这个参数。只不过 withContext() 函数是强制要求的,专门用于切换执行线程的。

使用协程简化回调的写法

之前,我们常常通过回调机制来获取网络请求等异步操作的结果。但这种写法比较繁琐,比如:

interface HttpCallbackListener {
    fun onFinish(response: String)
    fun onError(e: Exception)
}

object HttpUtil {
    fun sendHttpRequest(address: String, listener: HttpCallbackListener) {
        // 开启线程执行网络请求
        thread {
            var connection: HttpURLConnection? = null
            try {
                val response = StringBuilder()
                val url = URL(address)
                connection = url.openConnection() as HttpURLConnection
                connection.requestMethod = "GET"
                connection.connectTimeout = 8000
                connection.readTimeout = 8000

                val input = connection.inputStream
                input.use { inputStream ->
                    val reader = BufferedReader(InputStreamReader(inputStream))
                    reader.use { bufferedReader ->
                        bufferedReader.forEachLine {
                            response.append(it)
                        }
                    }
                }

                // 回调 onFinish() 方法
                listener.onFinish(response.toString())
            } catch (e: Exception) {
                e.printStackTrace()
                // 回调 onError() 方法
                listener.onError(e)
            } finally {
                connection?.disconnect()
            }
        }
    }
}

// 发起网络请求
HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
    override fun onFinish(response: String) {
        // 请求成功
    }

    override fun onError(e: Exception) {
        // 请求失败
    }
})

每当发起网络请求时,都需要实现一个匿名类。但现在有了 Kotlin 的协程,我们能够借助 suspendCoroutine() 函数来简化这种写法。

它需要在协程作用域或是其他挂起函数中调用,会在线程中执行其 Lambda 表达式的代码,并且会挂起当前协程。调用其 resume() 或是 resumeWithException() 方法可以让当前协程恢复执行并返回一个值或抛出异常。

那我们现在就来对回调写法进行优化,首先定义一个 request() 函数:

// suspend 声明为挂起函数
suspend fun request(address: String): String {
    // suspendCoroutine 会挂起当前协程
    return suspendCoroutine { continuation ->
        HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
            override fun onFinish(response: String) {
                // 请求成功,恢复被挂起的协程,并传入服务器响应数据
                continuation.resume(response)
            }

            override fun onError(e: Exception) {
                // 请求失败,恢复被挂起的协程,并传入异常
                continuation.resumeWithException(e)
            }
        })
    }
}

现在,你可能会觉得这不还是回调的写法吗?别着急,现在我们发起网络请求只需这样:

private suspend fun getResponse() {
    try {
        val address = "http://10.0.2.2/get_data.json"
        val response = request(address)
        // 像调用同步代码一样,直接拿到了 response
    } catch (e: Exception) {
        // 异常也能用标准的 try-catch 来捕获
    }
}

这样我们就以同步代码的风格,获取到了异步网络请求的响应数据。不过 getResponse() 函数被声明为了挂起函数,只能在协程作用域或其他挂起函数中调用。

并且 suspendCoroutine 函数几乎可以简化任何回调的写法。比如,之前使用 Retrofit 发起网络请求的代码为:

val appService = ServiceCreator.create<AppService>()
appService.getAppData().enqueue(object : Callback<List<App>> {
    override fun onResponse(call: Call<List<App>>, response: Response<List<App>>) {
        // 得到服务器返回的数据
    }
    override fun onFailure(call: Call<List<App>>, t: Throwable) {
        // 在这里对异常情况进行处理
    }
})

我们为 Call 对象定义一个 await() 扩展函数:

suspend fun <T> Call<T>.await(): T {
    return suspendCoroutine { continuation ->
        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                val body = response.body()
                if (response.isSuccessful && body != null) {
                    continuation.resume(body)
                } else {
                    continuation.resumeWithException(
                        RuntimeException("Response body is null or request failed")
                    )
                }
            }
            override fun onFailure(call: Call<T>, t: Throwable) {
                continuation.resumeWithException(t)
            }
        })
    }
}

现在,调用 Retrofit 的 Service 接口方法只需这样:

suspend fun getAppData() {
    try {
        val appList = ServiceCreator.create<AppService>().getAppData().await()
        // 对服务器响应的数据进行处理
    } catch (e: Exception) {
        // 对异常情况进行处理
    }
}

我们再也不用实现匿名类,只需简单调用一下我们定义的 await() 函数,就可以让 Retrofit 发起网络请求,用同步的方式直接获得服务器响应的数据。