前言
什么是协程呢?
它和线程类似,你可以简单理解为轻量级的线程。因为不同线程之间的切换需要靠操作系统的调度来实现,而不同协程之间的切换仅需在语言层面就能实现。更准确来说,协程的挂起和恢复不涉及操作系统内核级的线程上下文切换,其开销极小,这就是它高效的原因。
举个例子,比如我们有两个任务:
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")
}
然后就是开启一个协程,最简单的方式是调用 GlobalScope 的 launch() 函数:
@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.launch、runBlocking、launch、coroutineScope 这几种协程作用域构建器。其中 GlobalScope.launch、runBlocking 可在任意地方调用,coroutineScope 可在协程作用域或是挂起函数中调用,而 launch 只能在协程作用域中调用。
并且我们并不推荐使用 runBlocking 和 GlobalScope.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")
}
}
withContext 和 async 的关键区别在于 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 发起网络请求,用同步的方式直接获得服务器响应的数据。