Kotlin 协程入门

本文主要介绍协程长什么样子, 协程是什么东西, 协程挂起的实现原理以及整理了协程学习的资料.

协程 HelloWorld

协程在官方指南中被称为一种轻量级的线程, 所以在介绍协程是什么东西之前, 这里通过几个与线程对比的小例子初步认识协程.

启动线程与启动协程

/* Kotlin code - Example 1.1 */
// 创建一条新线程并输出 Hello World.
thread {
    println("使用线程输出 Hello World! Run in ${Thread.currentThread()}")
}

// 创建一个协程并使用协程输出 Hello World.
GlobalScope.launch {
    println("使用协程输出 Hello World! Run in ${Thread.currentThread()}")
}

/* output */
使用线程输出 Hello World! Run in Thread[Thread-0,5,main]
使用协程输出 Hello World! Run in Thread[DefaultDispatcher-worker-1,5,main]

上面的例子是一个简单的输出 Hello World 的程序. 在这个例子中, 我们可以看到创建并启动一条协程和创建并启动一条线程的代码几乎一致, 唯一不同的就是创建线程调用的是 #thread 方法, 而创建协程调用的是 GlobalScope#launch 方法.

暂停线程与暂停协程

/* Kotlin code - Example 1.2 */
fun demoSleep() {
    // 创建并运行一条线程, 在线程中使用 Thread#sleep 暂停线程运行 100ms.
    thread {
        val useTime = measureTimeMillis {
            println("线程启动")
            println("线程 sleep 开始")
            Thread.sleep(100L)
            println("线程结束")
        }
        println("线程用时为 $useTime ms")
    }
}

fun demoDelay() {
    // 创建并运行一条协程, 在协程中使用 #delay 暂停协程运行 100 ms.
    GlobalScope.launch {
        val useTime = measureTimeMillis {
            println("协程启动")
            println("协程 delay 开始")
            delay(100L)
            println("协程结束")
        }
        println("协程用时为 $useTime ms")
    }
}

/* output */
线程启动
线程 sleep 开始
线程结束
线程用时为 102 ms

协程启动
协程 delay 开始
协程结束
协程用时为 106 ms

上面例子展示了暂停线程和暂停协程的方法. 我们可以使用 Thread#sleep 方法暂停一条线程, 而暂停一条协程, 只需要把 Thread#sleep 直接替换成 #delay 就可以了.

等待线程执行结束与等待协程执行结束

/* Kotlin code - Example 1.3 */
/**
 * 线程等待另外一个线程任务完成的方法
 */
private fun waitOtherJobThread() {
    // 启动线程 A
    thread {
        println("线程 A: 启动")

        // 随便定义一个变量用于阻塞线程 A
        val waitThreadB = Object()

        // 启动线程 B
        val threadB = thread {
            println("线程 B: 启动")
            println("线程 B: 开始执行任务")
            for (i in 0..99) {
                Math.E * Math.PI
            }
            println("线程 B: 结束")
        }

        // 线程 A 等待线程 B 完成任务
        println("线程 A: 等待线程 B 完成")
        threadB.join()
        println("线程 A: 等待结束")

        println("线程 A: 结束")
    }
}

/**
 * 协程等待另外一个协程任务完成的方法
 */
private fun waitOtherJobCoroutine() {
    // 启动协程 A
    GlobalScope.launch {
        println("协程 A: 启动")

        // 启动协程 B
        val coroutineB = GlobalScope.launch {
            println("协程 B: 启动")
            println("协程 B: 开始执行任务")
            for (i in 0..99) {
                Math.E * Math.PI
            }
            println("协程 B: 结束")
        }

        // 协程 A 等待协程 B 完成
        println("协程 A: 等待协程 B 完成")
        coroutineB.join()
        println("协程 A: 等待结束")

        println("协程 A: 结束")
    }
}

/* output */
线程 A: 启动
线程 A: 等待线程 B 完成
线程 B: 启动
线程 B: 开始执行任务
线程 B: 结束
线程 A: 等待结束
线程 A: 结束

协程 A: 启动
协程 A: 等待协程 B 完成
协程 B: 启动
协程 B: 开始执行任务
协程 B: 结束
协程 A: 等待结束
协程 A: 结束

在上面的例子中, 创建了一条线程 A(协程 A), 然后在线程 A(协程 A)中再创建一条线程 B(协程 B), 接着使用 #join 方法使线程 A(协程 A)等待线程 B(协程 B)执行结束. 我们可以清楚的看到等待线程和等待协程的代码几乎是一致的, 甚至连等待的方法都是 #join.

中断线程与中断协程

/* Kotlin code -  Example 1.4 线程的中断与协程的中断. */
private fun cancelThread() {
    val job1 = thread {
        println("线程: 启动")

        // 循环执行 100 个耗时任务.
        for (i in 0..99) {
            try {
                Thread.sleep(50L)
                println("线程: 正在执行任务 $i...")
            } catch (e: InterruptedException) {
                println("线程: 被中断了")
                break
            }
        }

        println("线程: 结束")
    }

    // 延时 200ms 后中断线程.
    Thread.sleep(200L)
    println("中断线程!!!")
    job1.interrupt()
}

private fun cancelCoroutine() = runBlocking {
    val job1 = GlobalScope.launch {
        println("协程: 启动")

        // 循环执行 100 个耗时任务.
        for (i in 0..99) {
            try {
                delay(50L)
                println("协程: 正在执行任务 $i...")
            } catch (cancelException: CancellationException) {
                println("协程: 被中断了")
                break
            }
        }

        println("协程: 结束")
    }

    // 延时 200ms 后中断协程.
    delay(200L)
    println("中断协程!!!")
    job1.cancel()
}

/* output */
线程: 启动
线程: 正在执行任务 0...
线程: 正在执行任务 1...
线程: 正在执行任务 2...
中断线程!!!
线程: 被中断了
线程: 结束

协程: 启动
协程: 正在执行任务 0...
协程: 正在执行任务 1...
协程: 正在执行任务 2...
中断协程!!!
协程: 被中断了
协程: 结束

在上面例子中, 可以看到中断线程调用的方法是 #interrupt, 当线程被中断后会抛出 InterruptedException. 中断协程的方法为 #cancel. 协程被中断后会抛出 CancellationException.

通过上面的几个小例子, 我们可以看到几乎每一个线程的方法在协程中都有一个方法与之对应. 除了调用的方法名称不一样, 协程在使用上可以说几乎和线程没有特别大的区别.


协程是什么?

可挂起的计算实例。 它在概念上类似于线程,在这个意义上,它需要一个代码块运行,并具有类似的生命周期 —— 它可以被创建与启动,但它不绑定到任何特定的线程。它可以在一个线程中挂起其执行,并在另一个线程中恢复。而且,像 future 或 promise 那样,它在完结时可能伴随着某种结果(值或异常)。

上面这段话引用自 Kotlin 官方协程设计文档中对协程的描述. 那么这段话应该怎么理解呢? 首先, 协程需要一个计算实例. 类比与线程, 创建和启动线程同样需要一个计算实例. 对于线程来说, 线程的计算实例是 Runnable, 我们需要把 Runnable 扔给线程才能在线程中完成计算任务. 对于协程来说, 这个计算实例是 suspend 关键字修饰的方法或 lambda 表达式, 我们需要把这个 suspend 关键字修饰的方法或 lambda 表达式扔给协程才能在协程中完成计算任务. 接着, 除了需要一个计算实例之外, 协程中的这个计算实例还必须是可挂起的, 这也是协程和线程的区别. 那么可挂起是什么意思呢? 比如在上面暂停线程与暂停协程的例子中, 线程和协程同样是等待 100ms, 在线程的实现方式中, 是通过调用 Thread#sleep 方法阻塞线程来实现的, 而在协程的实现中, 调用 #delay 实现的等待是不会阻塞任何线程的(协程也是运行在某一条线程上的). 同样是等待, 线程等待的实现方式会阻塞线程, 而协程等待的实现方式不会阻塞线程, 所以就把线程的等待称之为阻塞, 把协程的等待称之为挂起. 同样的, 在上面等待线程执行结束与等待协程执行结束例子中, 线程调用 threadB#join 势必会造成线程 A 的阻塞, 而在协程中, 调用 coroutineB#join 也能实现同样的功能却不会造成任何线程的阻塞.

协程挂起的实现原理

经过上文的简单介绍, 我们知道了协程是什么, 协程和线程的区别是什么. 这里做个总结, 协程是一个可挂起的计算实例, 和线程的区别就是协程的计算实例在执行某些需要等待的任务时是可挂起的, 不阻塞线程的. 那么下面就开始介绍 Kotlin 协程是怎么实现等待某些任务而不阻塞线程的.

/* Kotlin code - Example 2.1*/

/**
 * 自定义一个 delay 挂起函数. 功能和协程库中的 [delay] 函数是一样的.
 * 这里使用的是标准库中定义挂起函数的方法.
 */
private suspend fun customDelay(delayInMillis: Long) = suspendCoroutine { complete: Continuation<Unit> ->
    // 创建一个可延时执行任务的 Thread Executor.
    val executorService = Executors.newSingleThreadScheduledExecutor()

    // 延时 delayInMillis ms 后调用 complete#resume 方法通知该任务已经执行完成了.
    executorService.schedule({
        complete.resume(Unit)
        executorService.shutdown()
    }, delayInMillis, TimeUnit.MILLISECONDS)
}

/**
 * suspend 函数可以类比于 Thread 中的 Runnable.
 * 同时, suspend 函数还被看作挂起点, 也就是说运行到这个函数的时候
 * 可能会被切换到其他线程当中运行.
 */
private suspend fun doSomething() {
    println("A")
    customDelay(10L)  // 挂起点

    println("B")
    customDelay(10L) // 挂起点

    println("C")
}

/**
 * Example 2.1 使用标准库启动一个协程.
 */
fun main() {
    // 位于标准库中的协程启动函数
    ::doSomething.startCoroutine(Continuation(EmptyCoroutineContext) {
        println(">>> doSomething Completed <<<")
    })

    // 防止进程退出.
    Thread.sleep(1000L)
}

/* output */
A
B
C
>>> doSomething Completed <<<

在正式介绍协程挂起原理之前, 需要先简单介绍一下协程的几个基本知识点.

  1. 所有 suspend 修饰的函数或 lambda 表达式可以直接通过 public fun <T> (suspend () -> T).startCoroutine(completion: Continuation<T>) 这个拓展方法创建并启动协程, 该方法在 suspend 修饰的计算实例(也就是前文提到的 suspend 修饰的函数或 lambda 表达式)完成计算后会回调参数的 #resumeWith 方法. 这个函数是最底层创建并启动协程的函数, 所有封装的协程构建器最终都要通过这个方法来创建并启动一条协程. 在上面与线程对比的几个小例子中使用到的 GlobalScope#launch 协程构建器最终也会调用该方法来创建并启动协程. 这也是在协程是什么这一节提到协程需要的计算实例是 suspend 关键字修饰的方法或 lambda 表达式的原因.
  2. suspend 修饰的函数被称为挂起函数. 调用挂起函数可能会挂起计算实例, 所以调用挂起函数的地方也被称为挂起点. 在上面代码示例中, #customDelay#doSomething 都是挂起函数. 在 #doSomething 中调用 #customDelay 的地方被称为挂起点.
  3. public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T 这个函数的作用是把被编译器隐藏的 Continuation 参数暴露出来. 我们可以通过这个函数自定义自己的挂起函数, 实现等待却不阻塞线程的任务.

在弄懂这几个知识点之后, 上面的代码就很容易知道是干什么的了. 上面的代码实际上就是使用 #doSomething 创建并启动一条协程, 在 #doSomething 中依次输出 "A", "B", "C". 执行完 #doSomething 之后输出 "doSomething Completed". 在执行 #doSomething 的过程中会调用自定义的 #customDelay 方法挂起等待 10ms.

说了这么多, 那么协程到底是如何实现等待而不阻塞线程的呢? 这里面的原理其实十分简单. 协程实现等待而不阻塞线程的方法就是通过回调, 只不过这个回调是 Kotlin 编译器实现的. 既然是编译器实现的, 那么我们就需要反编译一下这段代码看看 Kotlin 编译器到底做了什么黑科技的东西. 在 idea 中, Kotlin 编译后的代码可以通过 Tools -> Kotlin -> show kotlin bytecode 这几个步骤查看. 为了更加清晰的展示编译器干了什么东西, 这里我就直接贴我整理过后的反编译 Java 代码了. 下面这段整理过的 Java 代码和反编译的代码是等效的.

/* Java code - Example 2.2 */
public class StartCoroutineSimulation {

    /**
     * 一个可挂起的计算实例. 根据协程的定义, 这个接口的对象就是协程.
     */
    interface Continuation {

        /**
         * 唤醒被挂起的计算任务, 继续运行.
         */
        void resume();
    }

    /**
     * 自定义一个 delay 挂起函数. 功能和协程库中的 [delay] 函数是一样的.
     * 这里使用的是标准库中定义挂起函数的方法.
     */
    private static void customDelay(Continuation complete, long delayInMillis) {
        Continuation continuation = new Continuation() {
            @Override
            public void resume() {
                // 创建一个可延时执行任务的 Thread Executor.
                ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();

                // 延时 delayInMillis ms 后调用 complete#resume 方法通知该任务已经执行完成了.
                executorService.schedule(() -> {
                    complete.resume();
                    executorService.shutdown();
                }, delayInMillis, TimeUnit.MILLISECONDS);
            }
        };

        continuation.resume();
    }

    private static void doSomething(Continuation complete) {
        Continuation continuation = new Continuation() {

            int label = 0;

            @Override
            public void resume() {
                switch (label) {
                    case 0:
                        // 片段任务 A
                        label = 1;
                        System.out.println("A");
                        customDelay(this, 10L);
                        return;

                    case 1:
                        // 片段任务 B
                        label = 2;
                        System.out.println("B");
                        customDelay(this, 10L);
                        return;

                    case 2:
                        // 片段任务 C
                        label = 3;
                        System.out.println("C");
                        break;
                }

                complete.resume();
            }
        };

        continuation.resume();
    }

    /**
     * Example 2.2 模拟 kotlin 协程标准库启动一个协程.
     */
    public static void main(String[] args) {
        doSomething(new Continuation() {
            @Override
            public void resume() {
                System.out.println(">>> doSomething Completed <<<");
            }
        });
    }
}

/* output */
A
B
C
>>> doSomething Completed <<<

通过反编译的代码, 我们可以看出 Kotlin 编译器做了以下几个点.

  1. 每一个挂起函数都被编译成了一个 Continuation.
  2. 每一个挂起函数都被编译器添加了一个 Continuation 参数. 在完成该函数的任务之后, 会回调该参数的 #resume 方法. 该参数在 Kotlin 的源码中是被隐藏的, 所以自定义挂起函数的时候需要使用 public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T 函数把隐藏的 Continuation 参数暴露出来, 以便通知调用者任务已经完成了.
  3. 如果挂起函数中有挂起点, 被编译成的 Continuation 中的 #resume 方法会被实现成状态机模式. 两两挂起点之间组成一种状态. 在上面例子中, 我们可以清晰的看到 println("A") 到第一个 #costomDelay 方法之间组成了第一种状态, println("B") 到第二个 #customDelay 方法之间组成了第二种状态, 最后的 println("C") 组成最后一种状态.
  4. 在一个挂起函数调用另外一个挂起函数时, 需要把自身作为参数传入另外一个挂起函数中. 当另外一个挂起函数完成时, 会回调参数的 #resume 方法通知调用者继续完成任务. 例如在上面的例子中, 从 #doSomething函数调用 #customDelay 函数对应代码 customDelay(this, 10L); 中可以看出 #doSomething 把自身 this 作为参数传给了 #customDelay 方法, 而最终自定义的 #customDelay 方法在等待任务结束后通过 complete.resume(); 这句代码让 #doSomething 函数继续运行. 顺带一提, Kotlin 规定 suspend 修饰的函数或 lambda 表达式只能在 suspend 修饰的函数或表达式中调用. 就是因为 suspend 修饰的函数或 lambda 表达式会编译成需要 Continuation 参数的 Continuation, 调用另外一个 suspend 修饰的函数或 lambda 表达式需要传入一个 Continuation 作为参数, 而只有在 suspend 修饰的函数或 lambda 表达式中才有 Continuation (自身) 对象传入另外一个 suspend 函数中.

至此, 我们已经清晰的了解到了协程是怎么实现等待而不阻塞线程的了. 总结成一句话就是 suspend 关键字修饰的方法或 lambda 表达式会编译成一个带 Continuation 参数的 Continuation 对象, 当一个 Continuation 调用另外一个 Continuation 时需要把自身作为参数传入到另外一个 Continuation 中, 另外一个 Continuation 完成任务后会传入的 Continuation 参数的 #resume 方法让调用者继续运行. 更简单的说, 协程的挂起就是通过 Continuation 这个回调对象实现的.


协程的使用指导

本片文章的初衷就是让大家初步认识一下协程长什么样子, 协程是什么东西, 协程的挂起原理是什么. 搞明白了这个三个问题, 那么本片文章的目的也就达到了. 如果有兴趣继续学习 Kotlin 协程相关内容, 这里整理了一些 Kotlin 协程相关的官方文档资料. 资料按易难程度顺序排列.

最后的最后, 如果觉得本文对你有帮助, 请帮我点个👍. 谢谢大家 ^ _ ^. 本文欢迎分享和转载, 转载请补上链接并注明出处.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容