Kotlin 协程如何让异步代码像同步一样流畅

133 阅读4分钟

示例

一般在主线程执行网络请求需要使用回调(Callbacks),从 "developer.android.com" 获取数据的代码类似这样:

class ViewModel: ViewModel() {  

    fun fetchDocs() {  
        get("developer.android.com") { result ->  
            show(result)  
        }  
    }  
    
}

这里 get() 方法是从主线程发起的,我们一般需要在里面启动一个子线程来执行网络请求,这样主线程可以同时去做其他的事情。一旦请求结果返回后,主线程的回调将会被调起。

下面我们使用协程来对这个示例进行改造,代码如下:

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}// look at this in the next section

suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}

可以看到,这里没有使用回调,直接把 get() 方法的返回结果给 result,看起来是同步的写法,为什么这样写可以呢?

suspend

原因就在于这里 get() 方法是一个 suspend(挂起) 函数,挂起函数会挂起当前协程一段时间,并在挂起函数执行完毕后继续执行。这里使用了 withContext(Dispatchers.IO) 让网络请求在另一个线程中执行,拿到请求结果之后,挂起函数会 resume 挂起的协程,把请求结果给 result。

为了处理长时间运行的任务,协程添加了 2 个新的操作:

  • suspend — 暂停当前协程的执行(挂起当前协程),并保存所有的局部变量
  • resume — 让挂起的协程从暂停的地方继续执行

通过给函数添加 suspend 关键字来实现此功能,被 suspend 修饰的函数称为挂起函数,挂起函数只能在挂起函数或协程作用域中调用。

每当协程挂起时,当前堆栈帧( stack frame:Kotlin 用来跟踪哪个函数正在运行及其变量的位置)都会被复制并保存起来以备后用。resume 的时候,堆栈帧从保存的地方复制回来并继续执行。当主线程所有的协程都挂起了,主线程还是可以执行其他的更新 UI 和处理用户输入事件的逻辑。这里使用 suspend 和 resume 来代替回调,代码变得更加简洁了,可读性也更高了。

Dispatchers

在调用 get() 方法获取网络请求结果的时候使用了 withContext() 并传入了 Dispatchers 参数来指定协程运行的线程,Dispatchers 参数常用的有三种:

  • Dispatchers.Main:Android 中的主线程,可以直接操作 UI 和执行简单任务。
  • Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件、操作数据库、网络请求。
  • Dispatchers.Default:适合 CPU 密集型的任务,比如解析 JSON 文件、排序一个较大的列表、DiffUtils。

这样前面示例中 withContext(Dispatchers.IO){...} 代码块中的代码就是运行在 IO Dispatcher 中,withContext() 也是一个挂起函数,你在主线程可以很安全地调用它。

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.com")
    // Dispatchers.Main
    show(result)
}// Dispatchers.Main

suspend fun get(url: String) =
    // Dispatchers.Main
    withContext(Dispatchers.IO) {
        // Dispatchers.IO
        /* perform blocking network IO here */
    }
    // Dispatchers.Main

这样,使用协程你可以很精细地控制线程的调度。

协程与线程的比较

虽然协程像 JVM 上的线程一样并发地执行代码,但它们的底层工作方式并不相同。线程依赖操作系统的调度,线程可以在多个 CPU 内核上并行执行任务,这是 JVM 上实现并发的一种标准方法。创建线程时,操作系统为其堆栈分配内存,并使用内核来切换线程,这使得线程功能强大,但也占用大量资源。每个线程通常需要几 MB 的内存,通常 JVM 一次只能运行几千个线程。

但是,协程并不与特定的线程绑定,它可以在一个线程上挂起(suspend),在另一个线程上恢复(resume),因此多个协程可以共享同一个线程池。当一个协程挂起时,线程并不会因此阻塞,它还可以运行其他的任务,这使得协程比线程轻得多,你可以在一个进程中运行数百万个协程,而不会耗尽系统资源。

image.png

看下面的示例,运行 50000 个协程,每个协程里面等待 5 秒后打印一个".":

suspend fun printPeriods() = coroutineScope { // this: CoroutineScope
    // Launches 50,000 coroutines that each wait five seconds, then print a period
    repeat(50_000) {
        this.launch {
            delay(5.seconds)
            print(".")
        }
    }
}

如果使用线程,代码如下:

import kotlin.concurrent.thread

fun main() {
    repeat(50_000) {
        thread {
            Thread.sleep(5000L)
            print(".")
        }
    }
}

使用线程会消耗更多的内存,因为每个线程都需要自己的内存堆栈,50000 个线程就需要 100GB 的内存,相比之下,相同数量的协程大约只需要 500 MB 内存。根据您的操作系统、JDK 版本和设置,JVM 线程版本可能会抛出 OOM 的错误或减慢线程创建的速度,以避免一次运行太多线程。


参考文档:

 Coroutines on Android (part I): Getting the background 

 Understand Kotlin Coroutines on Android (Google I/O'19) 

 Using Kotlin Coroutines in your Android App 

Coroutines basics