Edit Page

协程的基本概念

最终更新: 2024/03/21

本章我们介绍协程的基本概念.

你的第一个协程

协程 是一段可挂起的计算代码的一个实例. 概念上类似于线程, 它包含一段需要运行的代码, 与其他代码并行工作. 但是, 协程并没有绑定到任何特定的线程上. 它的运行可以在一个线程上挂起, 然后在另一个线程中恢复运行.

协程可以看作是轻量的线程, 但有很多重要的区别, 使得协程的使用与线程非常不同.

下面请运行以下代码, 看看你的第一个协程:

import kotlinx.coroutines.*

//sampleStart
fun main() = runBlocking { // this: CoroutineScope
    launch { // 启动一个新的协程, 然后继续执行当前程序
        delay(1000L) // 非阻塞, 等待 1 秒 (默认的时间单位是毫秒)
        println("World!") // 等待完成后输出信息
    }
    println("Hello") // 当前一个协程在后台等待时, 主协程继续执行
}
//sampleEnd

完整的代码请参见 这里.

你将看到以下运行结果:

Hello
World!

我们来分析一下这段代码做了什么.

launch 是一个 协程构建器. 它启动一个新的协程, 协程会与其他代码并行执行, 其他代码则会继续自己的工作. 所以 Hello 会先输出.

delay 是一个特殊的 挂起函数. 它 挂起 协程一段指定的时间. 挂起一个协程不会 阻塞 底层的线程, 而是允许其他协程运行, 并使用底层的线程执行它们的代码.

runBlocking 也是一个协程构建器, 它负责联通通常的 fun main() 内的非协程的世界 与 runBlocking { ... } 括号之内使用协程的代码. IDE 会在 runBlocking 的开括号之后会提示 this: CoroutineScope.

如果你在这段代码中删除或者忘记了 runBlocking, 那么会在 launch 调用处发生错误, 因为 launch 声明在 CoroutineScope 上:

Unresolved reference: launch

runBlocking 的名称表示, 运行它的线程 (在这个示例中 — 是主线程) 在调用期间之内会被 阻塞, 直到 runBlocking { ... } 之内的所有协程执行完毕. 你会经常在应用程序的最顶层看到这样使用 runBlocking, 而在真正的代码之内则很少如此, 因为线程是代价高昂的资源, 阻塞线程是比较低效的, 我们通常并不希望阻塞线程.

结构化的并发

协程遵循 结构化的并发 原则, 意思就是说新的协程只能在一个指定的 CoroutineScope 之内启动, CoroutineScope 界定了协程的生命周期. 上面的示例代码中 runBlocking 建立了相应的作用范围(Scope), 所以前面的示例程序会等待, 直到延迟 1 秒后 World! 打印完毕, 然后才会退出.

在真实的应用程序中, 你会启动很多协程. 结构化的并发保证协程不会丢失或泄露. 直到所有子协程结束之前, 外层的作用范围不会结束. 结构化的并发还保证代码中的任何错误都会正确的向外报告, 不会丢失.

代码重构, 抽取函数

下面我们把 launch { ... } 之内的代码抽取成一个独立的函数. 如果在 IDE 中对这段代码进行一个 "Extract function" 重构操作, 你会得到一个带 suspend 修饰符的新函数. 这就是你的第一个 挂起函数. 在协程内部可以象使用普通函数那样使用挂起函数, 但挂起函数与普通函数的不同在于, 它们又可以使用其他挂起函数(比如下面的例子中使用的 delay 函数)来 挂起 当前协程的运行.

import kotlinx.coroutines.*

//sampleStart
fun main() = runBlocking { // this: CoroutineScope
    launch { doWorld() }
    println("Hello")
}

// 这是你的第一个挂起函数
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}
//sampleEnd

完整的代码请参见 这里.

作用范围(Scope)构建器

除了各种构建器提供的协程作用范围之外, 还可以使用 coroutineScope 构建器来自行声明作用范围. 这个构建器可以创建一个新的协程作用范围, 并等待在这个范围内启动的所有子协程运行结束.

runBlockingcoroutineScope 构建器看起来很类似, 因为它们都会等待自己的代码段以及所有的子任务执行完毕. 主要区别是, runBlocking 方法为了等待任务结束, 会 阻塞 当前线程, 而 coroutineScope 只会挂起协程, 而低层的线程可以被用作其他用途. 由于这种区别, runBlocking 是一个通常的函数, 而 coroutineScope 是一个挂起函数.

你可以在任何挂起函数中使用 coroutineScope. 比如, 你可以将打印 HelloWorld 并发代码移动到一个 suspend fun doWorld() 函数之内:

import kotlinx.coroutines.*

//sampleStart
fun main() = runBlocking {
    doWorld()
}

suspend fun doWorld() = coroutineScope {  // this: CoroutineScope
    launch {
        delay(1000L)
        println("World!")
    }
    println("Hello")
}
//sampleEnd

完整的代码请参见 这里.

这段代码的输出同样是:

Hello
World!

作用范围构建器与并发

在任何挂起函数之内, 可以使用 coroutineScope 构建器来执行多个并发的操作. 我们在 doWorld 挂起函数内启动 2 个并发的协程:

import kotlinx.coroutines.*

//sampleStart
// 顺序执行 doWorld, 然后输出 "Done"
fun main() = runBlocking {
    doWorld()
    println("Done")
}

// 并发执行 2 段代码
suspend fun doWorld() = coroutineScope { // this: CoroutineScope
    launch {
        delay(2000L)
        println("World 2")
    }
    launch {
        delay(1000L)
        println("World 1")
    }
    println("Hello")
}
//sampleEnd

完整的代码请参见 这里.

launch { ... } 之内的 2 段代码会 并发 执行, 启动之后 1 秒会先输出 World 1, 启动之后 2 秒会输出 World 2. 直到 2 段代码都结束之后, doWorld 之内的 coroutineScope 才会结束, 然后 doWorld 函数会返回, 直到这时才会输出 Done:

Hello
World 1
World 2
Done

明确控制的 job

launch 协程构建器会返回一个 Job 对象, 它是被启动的协程的管理器, 可以用来明确的等待协程结束. 比如, 你可以等待子协程结束, 然后再输出 "Done":

import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
    val job = launch { // 启动一个新的协程, 并保存它的 Job 实例
        delay(1000L)
        println("World!")
    }
    println("Hello")
    job.join() // 等待子协程结束
    println("Done")
//sampleEnd    
}

完整的代码请参见 这里.

这段代码的输出是:

Hello
World!
Done

协程是非常轻量的

与 JVM 线程相比, 协程消耗更少的资源. 有些代码使用线程时会耗尽 JVM 的可用内存, 如果用协程来表达, 则不会达到资源上限. 比如, 以下代码启动 50,000 个不同的协程, 每个协程等待 5 秒, 然后打印一个点号('.'), 但只消耗非常少的内存:

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(50_000) { // 启动非常多的协程
        launch {
            delay(5000L)
            print(".")
        }
    }
}

完整的代码请参见 这里.

如果你使用线程来实现同样的功能 (删除 runBlocking, 将 launch 替换为 thread, 将 delay 替换为 Thread.sleep). 那么会消耗非常多的内存. 根据你的操作系统, JDK 版本, 以及程序的设定, 这段程序要么会抛出内存不足(out-of-memory)的错误, 要么会缓慢的启动线程, 以免出现太多并发运行的线程.