Kotlin-协程(三)理解挂起函数

1,391 阅读4分钟

挂起函数,是 Kotlin 协程当中最基础、最重要的知识点。如果对协程的挂起函数没有足够的认识,我们将无法理解协程的非阻塞;如果不了解挂起函数,我们将无法掌握 Channel、Flow 等 API;如果不理解挂起函数,我们写出来的代码也会漏洞百出,就更别提优化软件架构了。

一、挂起函数:Kotlin 协程的优势

比如现在有一个需求,需要先通过接口获取用户信息后,再通过用户信息调接口获取好友列表,再通过好友列表调接口获取聊天记录,在java中只能通过嵌套接口回调实现,而现在有了协程的挂起和恢复就变得很简单,代码如下:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        System.setProperty("kotlinx.coroutines.debug", "on")  //开启debug打印协程名称

        lifecycleScope.launch {  //协程
            logMsg("lifecycleScope.launch start")
            val user = getUserInfo()  //获取用户信息
            val list = getFriendList(user)   //获取好友列表
            getChatRecord(list)  //获取聊天记录
            logMsg("lifecycleScope.launch end")
        }
    }

    //获取用户信息
    suspend fun getUserInfo(): String {
        val result = withContext(Dispatchers.IO) {  //开启协程,withContext是挂起函数
            logMsg("getUserInfo withContext")  //打印协程的线程
            delay(2000L)                       //模拟网络请求耗时
            return@withContext "getUserInfo"   //协程返回值
        }
        logMsg("getUserInfo result:$result")   //打印返回值和线程
        return result
    }

    //获取好友列表
    suspend fun getFriendList(user: String): String {
        val result = withContext(Dispatchers.IO) {  //开启协程,withContext是挂起函数
            logMsg("getFriendList withContext")     //打印协程的线程
            delay(2000L)                            //模拟网络请求耗时
            return@withContext "getFriendList"      //协程返回值
        }
        logMsg("getFriendList result:$result")     //打印返回值和线程
        return result
    }

    //获取聊天记录
    suspend fun getChatRecord(list: String): String {
        val result = withContext(Dispatchers.IO) {  //开启协程,withContext是挂起函数
            logMsg("getChatRecord withContext")     //打印协程的线程
            delay(2000L)                            //模拟网络请求耗时
            return@withContext "getFriendInfo"      //协程返回值
        }
        logMsg("getChatRecord result:$result")      //打印返回值和线程
        return result
    }

    //打印日志方法
    fun logMsg(msg: String) {
        Log.d("TAG", "${Thread.currentThread().name}:$msg")
    }
}

输出的结果

2023-02-16 14:08:07.291 D/TAG: main @coroutine#2:lifecycleScope.launch start  //主线程
2023-02-16 14:08:07.297 D/TAG: DefaultDispatcher-worker-1 @coroutine#2:getUserInfo withContext //子线程:协程挂起
2023-02-16 14:08:09.300 D/TAG: main @coroutine#2:getUserInfo result:getUserInfo  //主线程:协程恢复
2023-02-16 14:08:09.301 D/TAG: DefaultDispatcher-worker-1 @coroutine#2:getFriendList withContext  //子线程:协程挂起
2023-02-16 14:08:11.302 D/TAG: main @coroutine#2:getFriendList result:getFriendList  //主线程:协程恢复
2023-02-16 14:08:11.303 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:getChatRecord withContext  //子线程:协程挂起
2023-02-16 14:08:13.304 D/TAG: main @coroutine#2:getChatRecord result:getFriendInfo  //主线程:协程恢复
2023-02-16 14:08:13.304 D/TAG: main @coroutine#2:lifecycleScope.launch end  //主线程

用一张图来理解就是

image.png

所谓的挂起函数,其实就是比普通的函数多了一个 suspend 关键字而已。如果去掉这个 suspend 关键字,所有的函数都会变成普通函数。实际上,挂起函数最神奇的地方,就在于它的挂起和恢复功能。从字面上看,suspend 这个词就是“挂起”的意思,而它既然能被挂起,自然就还可以被恢复。它们两个一般是成对出现的。

在上面代码中:

  • 每一次从主线程到 IO 线程,都是一次协程挂起。
  • 每一次从 IO 线程到主线程,都是一次协程恢复。
  • 挂起和恢复,这是挂起(suspend)函数特有的能力,普通函数是不具备的。
  • 挂起,只是将程序执行流程转移到了其他线程,主线程不会被阻塞。如果以上代码运行在 Android 系统,我们的 App 仍然可以响应用户的操作,主线程并不繁忙。

借助挂起函数,我们可以用同步的方式来写异步代码,对比起“回调地狱”式的代码,挂起函数写出来的代码可读性更好、扩展性更好、维护性更好,并且更难出错。

二、深入理解 suspend

加了suspend的函数(挂起函数)和普通函数有什么区别呢? 首先,suspend函数只能在协程中或其他suspend函数中被调用,其次看下面的代码以及反编译的结果:

//代码
suspend fun foo():String{
    return "Hello World"
}

 fun foo1():String{
     return "Hello World"
}

//反编译后
@Nullable
public final Object foo(@NotNull Continuation $completion) {  //多了一个参数Continuation
   return "Hello World";
}

@NotNull
public final String foo1() {
   return "Hello World";
}

相比于普通函数,挂起函数会多了一个传参,我们看看Continuation是什么?

/**
 * Interface representing a continuation after a suspension point that returns a value of type `T`.
 * 表示一个中止点暂停后恢复运行,返回泛型T类型数据的接口
 */
@SinceKotlin("1.3")
public interface Continuation<in T> {
    /**
     * The context of the coroutine that corresponds to this continuation.
     * 用于恢复运行的协程上下文
     */
    public val context: CoroutineContext   //持有协程上下文

    /**
     * Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
     * return value of the last suspension point.
     * 协程通过返回结果恢复运行
     */
    public fun resumeWith(result: Result<T>)
}

Continuation相当于协程回调接口,返回协程运行的结果。单独给函数添加suspend并没有挂起和恢复的含义,只是部分suspend函数实现了协程的挂起和恢复,比如withContext函数,源码如下:

/**
 * Calls the specified suspending block with a given coroutine context, suspends until it completes, and returns
 * the result.
 * 通过给的协程上下文调用规定的挂起来阻塞,并最终返回执行的结果
 * ......
 */
public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
   ......
}

具体源码分析暂略。

三、协程与挂起函数

既然协程和挂起函数都是支持挂起和恢复的,那它们两个是不是同一个东西呢?答案当然是否定的。

现阶段我们会发现协程和suspend函数都可以调用suspend函数,那可以得到如下结论:挂起和恢复,是协程的一种底层能力;而挂起函数,是这种底层能力的一种表现形式,通过暴露出来的 suspend 关键字,我们开发者可以在上层,非常方便地使用这种底层能力。

四、挂起与线程的关系

1、挂起时要等待挂起的协程执行完,会不会影响阻塞当前线程?

不会,挂起的协程会在新的线程执行,不会阻塞当前线程。

2、嵌套的协程作用域,内部协程被挂起是否会阻塞外部协程?

会,嵌套的协程作用域,如果内部协程被挂起,外部协程会被阻塞,请看下面的代码:

GlobalScope.launch {
    log("GlobalScope start")
    val job = launch {
        log("job start")
        delay(4000)
        log("job end")
    }
    job.join()
    log("GlobalScope mid")
    job.invokeOnCompletion {
        log("job Completion")
    }
    log("GlobalScope end")
}

//输出日志
16:09:29.064 DefaultDispatcher-worker-1 @coroutine#1 GlobalScope start
16:09:29.064 DefaultDispatcher-worker-2 @coroutine#2 job start         //被挂起
16:09:33.070 DefaultDispatcher-worker-2 @coroutine#2 job end           //直到4秒后结束
16:09:33.072 DefaultDispatcher-worker-2 @coroutine#1 GlobalScope mid   //继续输出外部协程作用域的日志
16:09:33.073 DefaultDispatcher-worker-2 @coroutine#1 job Completion
16:09:33.073 DefaultDispatcher-worker-2 @coroutine#1 GlobalScope end

参考内容

挂起函数:Kotlin协程的核心

Kotlin协程suspend关键字理解

个人学习笔记