本文正在参加「金石方案」

这是一份写给 Android工程师 的协程指南,期望在安静的2023,给咱们带来一些本质或许别样的了解。

引言

Android 的开发国际中,关于 异步使命 的处理一向不是件简略事。

面临杂乱的事务逻辑,比方多次的异步操作,咱们常常会阅历回调嵌套的状况,关于开发者而言,无疑苦不堪言。

Kotlin协程 呈现之后,上述问题能够说真实意义上得到了好的解法。其良好的可读性及api规划,使得无论是新手仍是老手,都能快速享受到协程带来的舒适体会。

但越是运用随手的组件,背面也往往隐藏着更杂乱的规划。

故此,在本篇,咱们将由浅入深,系统且全面的聊聊 Kotlin协程 的思维及相关问题,然后帮助咱们更好的了解。

本篇没有难度定位、更多的是作为一个 Kotlin 运用者的根本技能铺垫。

ps: 在B站也有视频版别,结合观看,体会更佳,Android Kotlin 协程同享。

写在开端

大约在三年前,那时的我实习期间刚学会 Kotlin ,神采飞扬,协程Api 调用的也是炉火纯青,对外自称api调用渣渣工程师。

那时分的客户端还没这么饱和,也不像现在这样稳定。

那个时期,曾探寻过几回 Kotlin协程 的规划思维,比方看霍老师、扔物线视频、相关博客等。

但看完后处于一种,懂了,又好像不是很懂的状况,就一向模模糊糊着。

记得后往来不断面试,有人问我,协程究竟是什么?

我答复: 一个在 Kotlin 上以 同步办法写异步代码 的线程结构,底层是运用了 线程池+状况机 的概念,诸如此类,巴拉巴拉。

面试官: 那它究竟和线程池有啥差异,我为啥不直接用线程池呢?

我心想:上面不是现已答复了吗,同步办法,爽啊!… 但奈何遭到了一顿白眼。

事后回想,他或许想问的是更深层,多角度的解说,但明显我只停留在运用层次,以及借着他人的几句碎片经验,官样文章、看似Easy。

直到现在为止,我仍然没有仔细去看过协程的底层完成,真是何其的尴尬,再次想起,仍觉不安。

跟着近几年对协程的运用以及一些cv经验,相关的api了解也逐步像那么回事,也有些对Kt代码背面完成进行同步转化的经验。

故此,这篇文章也是对自己三年来的一份答卷。

当然网上关于协程的解析也有许多,无论是从原理或是顶层笼统归纳,其间更是不乏优异的文章与作者。

本文会尽量在这两者中间找到一个适宜的折中点,并增加一些特别考虑,即不缺深度,又能使初学者关于协程能够有较明晰明晰的认知。

好了,让咱们开端吧!

根底铺垫

在开端之前,咱们先对根底做一些铺垫,然后便于更好的了解 Kotlin协程

线程

咱们知道,线程是 cpu调度 的最小单元,每个cpu所能发动的线程数量往往也是有限的。

在常见的事务开发中,尽管大多数时分咱们都是根据单线程,或许最多开启子线程去恳求网络,与多线程的 [多] 好像关系不大。但其实这也属于多线程的一种,不过是少使命的状况。但就算这样,线程在履行时的切换,也是存在这一些小成本,比方从主线程切到子线程去履行异步计算,完成后再从子线程切到主线程去履行UI操作,而这个切换的进程在学术上又被称之为 [上下文切换]

协程

在维基百科中,是这样解说的:

协程是计算机程序的一类组件,推行了协作式多使命的子例程,答应履行被挂起与被康复。相对子例程而言,协程更为一般和灵敏,但在实践中运用没有子例程那样广泛。协程更适合于用来完成相互熟悉的程序组件,如协作式多使命、反常处理、事件循环、迭代器、无限列表和管道。

上面这些词好像拆开都懂,但连在一起就不懂了。

说的浅显一点便是,协程指的是一种特殊的函数,它能够在履行到某个方位时 暂停 ,并 保存 当时的履行状况,然后 让出 CPU操控权,使得其他代码能够持续履行。当CPU再次调用这个函数时,它会从前次暂停的方位持续履行,而不是从头开端履行。然后使得程序在履行 长期使命 时愈加高效和灵敏。

协作式与抢占式

这两个概念一般用于描绘操作系统中多使命的处理办法。

  • 协作式指的是 多个使命同享CPU时刻 ,而且在没有主动开释CPU的状况下,使命不会被强制中止。相应的,在协作式多使命处理中,使命需求自己决议何时抛弃CPU,不然将影响其他使命的履行。
  • 抢占式指的是操作系统能够在没有使命主动抛弃CPU的状况下,强制中止 当时使命,以便其他使命能够取得履行。这也就意味着,抢占式多使命一般是需求硬件支撑,以便操作系统能够在必要时强制中止使命。

假如将上述概念带入到协程与线程中,当一个线程履行时,它会一向运转,直到被操作系统强制中止或许自己抛弃CPU;而协程的协作式则需求协程之间相互配合协作,以便让其他协程也能够取得履行机遇,一般状况下,这种协作关系是由运用层(开发者)自行操控。也就意味着比较线程,协程的切换与创立开支比较小,由于其并不需求多次的上下文切换,或许说,线程是真实的操作系统内核线程的影射,而协程仅仅在运用层调度,故协程的切换与创立开支比较小。

协程与线程的差异

  • 线程是操作系统调度的根本单位,一个进程能够具有多个线程,每个线程独立运转,但它们同享进程的资源。线程切换的开支较大,且线程间的通讯需求通过同享内存或音讯传递等办法完成,简略呈现资源竞赛、死锁等问题。
  • 协程是用户空间下的轻量级线程,也称为“微线程”。它不依赖操作系统的调度,而是由用户自己操控协程的履行。协程之间的切换只需求保存和康复少数的状况,开支较小。协程通讯和数据同享的办法比线程愈加灵敏,一般运用音讯传递或同享状况的办法完成。
  • 简略来说,协程是一种愈加高效、灵敏的并发处理办法,但需求用户 自己操控履行流程和协程间的通讯 ,而线程则由操作系统担任调度,具有更高的并发度和更强的隔离性,但开支较大。在不同的场景下,能够根据需求挑选运用不同的并发处理办法。

那Kotlin协程呢?

在上面,咱们说了 线程协程 ,但这个协程指的是 广义协程 这个概念,而不是 Kotlin协程 ,那假如回到 Kotlin协程 呢?

相信不少同学在学习 Kotlin协程 的时分,常常会看到许多人(包含官网)会将线程与协程拉在一起比较,或许常常也能看见一些试验,比方一起发动10w个线程与10w个协程,然后从成果上看两者距离巨大,线程看起来功用巨差,协程又无比的优异。

此刻就会有同学喊,你上个线程池与协程试试啊!用线程试谈什么公正(很有道理)。

ps: 假如你真的运用了线程池而且运用了schedule代替Thread.sleep(),会发现,线程比协程明显要更快。当然,这也并不难了解。

那协程究竟是什么呢?它和线程池的差异呢?或许说协程的职责呢?

这儿咱们用 Android官方 的一句话来归纳:

协程是一种并发规划模式,您能够在 Android 渠道上运用它来 简化 异步履行的代码。协程是咱们在 Android 上进行异步编程的引荐处理方案。

简略明晰,协程便是用于 Android 上进行 异步编程 的引荐处理方案,或许说其便是一个 异步结构 ,仅此而已,别无其他‍♂️。

那有些同学或许要问了,异步结构多了,为什么要运用协程呢?

由于协程的规划愈加先进,比方咱们能够同步代码写出相似异步回调的逻辑。这一点,也是Kotlin协程在Android渠道最大的特色,即 简化异步代码

相应的,Kotlin协程 具有以下特色:

  • 轻量:您能够在单个线程上运转多个协程,由于协程支撑挂起,不会使正在运转协程的线程堵塞。挂起比堵塞节省内存,且支撑多个并行操作。
  • 内存泄漏更少:运用结构化并发机制在一个效果域内履行多项操作。
  • 内置撤销支撑:撤销操作会自动在运转中的整个协程层次结构内传达。
  • Jetpack 集成:许多 Jetpack 库都包含供给全面协程支撑的扩展。某些库还供给自己的协程效果域,可供您用于结构化并发。

上述特色来自Android官网-Android上的Kotlin协程。

协程发展

:如非特别标注,本文接下来的协程皆指Kotlin协程。

本小节,咱们将看一下Kotlin协程的发展史,然后为咱们解说kotlin协程的布景。

写给Android工程师的协程指南

Kotlin1.6 之前,协程的版别一般与 kotlin 版别作为对应,可是 1.6 之后,协程的大版别就没有怎样更新了(目前最新是1.7.0-beta),反而是 Kotlin 版别目前最新现已 1.8.10

根本示例

在开端之前,咱们仍是用一个最根本的示例看一下协程与往常回调写法的差异,在哪里。

比方,咱们现在有这样一个场景,需求恳求网络,获取数据,然后显现到UI中。

回调写法

fun main() {
    // 示例,一般为线程池
    thread(name="t1") {
        val message = getMessage()
        // 或许其他切线程办法,底层都是这样,handler复用
        val handler = Handler(Looper.getMainLooper())
        handler.post {
            showMessage(message)
        }
    }
}
fun getMessage(): String {
    Thread.sleep(1000)
    return "123"
}

如上所示,创立了一个线程t1,并在其间调用了 getMessage() 办法,该办法咱们运用 Thread.sleep() 模仿网络恳求,然后回来一个String数据, 最终运用 handler 将当时要履行的使命发送到主线程去履行然后完成线程切换。

协程写法

fun main() {
    val coroutineScope = CoroutineScope(Dispatchers.Main)
    coroutineScope.launch {
        val message = getMessages()
        showMessage(message)
    }
}
suspend fun getMessages(): String {
    return withContext(Dispatchers.IO) {
        delay(1000)
        "123"
    }
}

如上所示,创立了一个协程效果域,并发动了一个新的子协程c1,该协程内部调用了 getMessages() 办法,用于取得一个 String类型 的音讯。然后调用 showMessage() 办法,显现方才获取的音讯。在相应的 getMessages() 办法上,咱们增加了 suspend 符号,并在内部运用withContext(Dispatcher.IO) 将当时上下文环境切换到IO协程中,用于推迟等候(假定网络恳求),终究回来该成果。

在不谈功用的布景下,上述这两种办法,无疑是协程的代码愈加直观简练,毕竟同步的写法去写异步,这没什么可比性,当然咱们也答应部分的功用损失。

挂起与康复

站在初学者的视角,当聊到挂起与康复,开发者究竟想了解什么?

什么是挂起康复?挂起是挂起什么?挂起线程吗?仍是挂起一个函数?康复又是具体指什么?又是怎样做到康复的呢?

根底概念

在标准的解说中,如下所示:

在协程中,当咱们的代码履行到某个方位时,能够运用特定的关键字来暂停函数的履行,一起保存函数的履行状况,这个进程叫做 [挂起],挂起操作会将操控器交还给调用方,调用方能够持续履行其他使命。

当再次调用被挂起的函数时,它会从上一次暂停的方位开端持续履行,这个进程称为 [康复]。在康复操作之后,被挂起的函数会持续履行之前保存的状况,然后能够在不从头计算的状况下持续履行之前的逻辑。

假如切换到 Kotlin 的国际中中,这个特定的关键字便是 suspend 。但并不是说加了这个关键字就一定会挂起,suspend 仅仅作为一个符号,用于告知编译器,该函数或许会挂起并暂停履行(即该函数或许会履行耗时操作,而且功德期间会暂停履行并等候耗时操作完成,一起需求将操控权回来给调用方),但至于要不要挂起及保存函数当时的履行状况,终究仍是要取决于函数内部是否满意条件。

如下所示,咱们用一个示例Gif(出处已找不到)来表示:

写给Android工程师的协程指南

那用程序员的言语该怎样了解呢?咱们用一段代码举例:

coroutineScope.launch(Dispatchers.Main) {
    val message = getNetMessages()
    showMessage(message)
}
suspend fun getNetMessages(): String {
    return withContext(Dispatchers.IO) {
        delay(1000)
        "123"
    }
}
  • 当咱们的程序运转到 coroutineScope.launch(Dispatchers.Main) 时,此刻会创立一个新协程,并将这个协程放入默许的协程调度器(即Main调度器),一起当时新创立的协程也会成为 coroutineScope 的子协程。
  • 当履行到 getNetMssage() 办法时,此刻遇到了 withContext(Dispatchers.IO) ,此刻会切换当时协程的上下文到IO调度器(能够了解将当时协程放入IO线程池中履行),此刻协程将被挂起,然后咱们当时 withContext() 被挂起的状况会告知给外部的调用者,并将当时的状况保存到协程的上下文中,直到IO操作完成。
    • 当遇到 delay(1000) 时,此刻再次挂起(这儿不是切换线程,而是运用了协程的调度算法),并保存当时的函数状况;
    • delay(1000) 完毕后,再次康复到从前地点的IO调度器,并开端回来 “123”;
    • 当上述逻辑履行完成后,此刻 withContext() 会将协程的调度器再次切换到之前开端时的调度器(这儿是Main),并康复之前的函数状况;
  • 此刻咱们取得了 getNetMssage() 的回来值,持续履行 showMessage()

挂起函数

在上面咱们聊到了 Kotlin 的挂起函数,与相关的 挂起康复 。那 suspend 标志究竟做了什么呢?

本小节,咱们将就这个问题,从字节码层,打开剖析。

咱们先看一下 suspend 办法是怎样被编译器识其他?如下代码所示:

写给Android工程师的协程指南

不难发现,咱们带有suspend的函数终究会被转变为一个带 Continutaion 参数,而且回来值为Object(可null)的函数。

上述示例中,原函数没带回来值,你也能够运用带回来值的原函数,成果也是与上述共同。

1. Continucation 是什么?为什么要带着它呢?

在前文中,咱们现已提及,suspend 仅仅一个标志,它的目的是告知编译器或许会挂起,相似与咱们开发中常运用的注解相同,但又比注解愈加强壮,suspend 标志是编译器等级,而注解是运用等级。从原理上来看,那终究的代码运转时应该怎样记住这些状况呢,或许怎样知道这个办法和其他办法不相同?故此,kotlin编译器 会对带有 suspend 的办法在终究的字节码生成上进行额定更改,这个进程又被称作 CPS转化 (下面会再解说),如下所示:

suspend fun xx()
->
Object xx(Continucation c)

在字节码中,咱们原有的函数办法参数中会再增加一个 Continucation ,而 Continuation 就相当于一个参数传递的枢纽(或许你也能够了解其便是一个 CallBack ),担任保存函数的履行状况、履行 挂起与康复 操作,具体如下:

public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

context 参数相似于 Android 开发中的 context 相同,其代表了当时的配置,对运用协程的同学而言,context就相当于当时协程所运转的环境与参数 ,而 resumeWith() 则是担任对咱们函数办法进行挂起与康复(这块咱们先这样了解即可)。


1 什么是CPS转化?

CPS(Continuation Passing Style)转化是一种将函数转化为回调函数的编程技能。在 CPS 转化中,一个函数不会像一般那样直接回来成果,而是承受一个额定的回调函数作为参数,用于接收函数的成果。这个回调函数自身也或许承受一个回调函数,构成一个接连的回调链。这种办法能够防止堵塞线程,提高代码的并发功用。

比方,协程通过 CPS 转化来完成异步编程。具体来说,协程在被挂起时,会将当时的履行状况保存到一个回调函数(即挂起函数的 Continuation)中,然后将操控权交回给调用方。当协程准备好康复时,它会从回调函数中取回履行状况,持续履行。这种办法能够使得异步代码的逻辑愈加明晰和易于维护。


2. 为什么还要增一个 Object 类型回来值呢?

这块的直接解说比较麻烦,可是咱们能够先考虑一下,代码运转时,该怎样知道该办法真的被挂起呢?难道是增加了suspend就要被挂起吗?

故此,仍是需求一个回来值,用于确认,该挂起函数是否真的被挂起。

在IDE中,关于运用了suspend的办法而言,假如内部没有其他挂起函数,那么编译器就会提示咱们移除suspend符号,如下所示:

写给Android工程师的协程指南


3. 为什么回来值类型是Object?

关于挂起函数而言,在协程,是否真的被挂起,通过函数回来值来确认,但相应的,假如咱们有挂起函数需求具有回来类型呢?那假如该函数没有挂起呢?如下示例所示:

写给Android工程师的协程指南

关于挂起函数而言,回来值有或许是 COROUTINE_SUSPENDEDUnit.INSTANCE 或许终究回来咱们办法需求的回来类型成果,所以选用 Object 作为回来值以习气一切成果。

深化探索

在上面,咱们看到了 suspend 在底层的转化细节,那回到挂起函数本质上,它究竟是怎样做到 **挂起 ** 与 康复 的呢?

故此,本小节,咱们将就着这个问题,从字节码层次,打开剖析,力求流程完整明晰,不过相对而言或许有点繁琐。

如下代码所示:

fun main() = runBlocking {
    val isSuccess = copyFileTo(File("old.mp4"), File("new.mp4"))
    println("---copy:$isSuccess")
}
suspend fun copyFileTo(oldFile: File, newFile: File): Boolean {
    val isCopySuccess = withContext(Dispatchers.IO) {
        try {
            oldFile.copyTo(newFile)
            // 示例代码,一般这儿需求验证字节流或许MD5
            true
        } catch (e: Exception) {
            false
        }
    }
    return isCopySuccess
}

这是一段用于将文件复制到指定文件的示例代码,具体伪字节码如下:

写给Android工程师的协程指南

上述的过程实在是难读,思路收拾起来比较绕圈,不过仍是主张开发者多了解几遍。

上述的过程如下:

当左侧 main() 办法开端履行时,由于示例中运用的 runBlocking(),其需求传递一个函数式接口目标,一般咱们会以 lambda表达式 的形式去实例化这个函数目标,然后在其间写入咱们的事务代码。

所以根据终究的字节码比照,咱们的lambda会被转化为如下的形式:

suspend CoroutineScope.() -> Unit
			⚡️ ->
(Function2) (new Function2((Continuation) null){}
// 具体伪代码如下所示,为什么会是这样的,下面会解说
class xxx(Continucation) : Function2<CoroutineScope,Continucation,Any> {
  fun	invoke(Any,Continucation) : Any {}
}

接着当咱们的函数被调用时,会触发 invoke() 办法,即咱们的函数体开端履行,开端进入咱们的事务代码中。由于 invoke() 需求回来一个Object(由于咱们的函数体自身也是suspend),这时分,会先创立一个 Continuation 目标,用于履行协程体逻辑,然后去调用 invokeSuspend() 办法然后取得本次的履行成果。

这儿为什么要再去创立一个 Continuation?不是在runBlocking()里现已运用lambda表达式实例化了函数目标了吗?

不知道是否会有同学有这个疑问,所以这儿仍然需求解说一遍。

咱们知道,在 kotlin 中,lambda 是匿名内部类的一种实例化办法(简化),所以这儿仅仅给 runBlocking() 函数传递了所需求的办法参数。可是这个 lambda 内部的 invoke() 仍然是挂起函数(由于增加过suspend),所以这儿的匿名内部类实际上也是完成了 Continuation(默许的只要Funcation1,2,3等等),为了便于底层调用 invoke() 时传递 Continuation ,不然后续挂起康复流程就断了。相应的,为了延续 invoke() 里的挂起函数流程,编译器在当时匿名类内部又创立了一个 anonymous constructor(无类型) 的内部类(实际上是承继自SuspendLambda),然后在其 ivokeSuspend() 里履行当时挂起函数的状况机。

所以来说,咱们能够了解咱们传递的 lambda 相当于一个进口,可是其内部(即invoke)的触发办法,又是一个 挂起函数 ,这也便是为什么 invoke() 里需求创立 Continuation ,以及为什么 invoke() 办法参数里需求有 continuation 的原因,以及为什么字节码中会呈现 new Function2((Continuation) null) ,Continuation 为null 的状况,由于它压根没有 continuation 啊(不在挂起函数内部)。

这儿的解说稍许有些啰嗦,但这关于了解全流程将十分有用,假如不是很了解,主张多读几遍。

invokeSuspend() 办法里,即正式进入了函数的状况机,这儿的状况符号运用了一个 int 类型的 label 表示。

  • 默许履行 case 0,由于咱们接下来要进入 copyFileTo() 办法,而该办法也是一个挂起函数,所以履行该办法后会取得一个回来状况,用于判别该函数是否真的现已挂起。假如回来值是 COROUTINE_SUSPENDED,则证明该函数现已挂起,然后直接 return 当时函数的挂起状况(相当于告知父callback,当时我内部现已在忙了,你能够先履行自己的事了,等我履行完再告知你),不然持续履行当时剩余逻辑。
  • copyFileTo() 履行完毕后,会再次触发当时 invokeSuspend(),由于咱们在 case0 里现已更新了label为1,然后正常履行接下来的流程。

咱们再去看一下 copyFileTo() 办法,咱们在字节码中能够看到,其默许先创立了当时的 ContinuationImpl() ,并在初始化时将父 Continuation 也保存在其间,接着进入状况机开端履行逻辑,由于咱们在该办法里有运用 withContext() 切换到IO调度器,所以这儿也需求获取 withContext() 的挂起状况,假如成功挂起,则直接 return 当时状况,相似上述 invokeSuspend() 里的流程。

需求留意的,咱们 withContext() 范围内,尽管阅历了CPS转化,但由于不存在其他挂起函数,所以并不会再回来是否挂起,而是直到咱们的逻辑履行完毕 ,然后触发 withContext() 内部去调用 resumeWith(),然后康复外部 copyFileTo() 的履行,重复此流程,然后康复 runBlocking() 内部的持续履行,然后拿到咱们的终究成果。

总结

关于Kotlin协程的挂起与康复,从字节码去看,中心的 continuation 好像有点像 callback 的嵌套,但比较 callback ,协程做的愈加完善。比方当触发挂起函数调用时,会进入其内部对应的状况机,然后触发状况流通。而且为了防止了 callback 的 重复创立,而每一个挂起函数内部都会复用当时已创立好的 continuation

比方说,关于挂起函数,编译器会对其进行 CPS转化 ,然后使其从:

supend fun test()
->
fun test:(Continuation):Any?

当咱们在挂起函数中调用该函数时,编译器就会将当时的 continuation 也一起传入并取得当时函数的成果。在具体调用时,假如挂起函数内部真的挂起(函数回来值为 COROUTINE_SUSPENDED ),则将调用权交还给调用方,然后当时的状况+1。而当该挂起函数内部履行完毕时,由于其持有着外部的 continuation ,所以会调用 continuation.resume() 康复挂起的协程,即调用了 invokeSuspend() ,然后康复履行从前的逻辑。

而咱们常说的状况机,从根本上,其实便是结构了一个 switch 结构的label流通,每个 case 内部都或许又会再对应着一个相似的结构(假如存在挂起函数)。假如咱们称其为分层,那每一层也都持有上层的目标,而当咱们最底层的函数履行完毕时,即开端触发康复上层逻辑,此刻状况回传,然后将子函数的成果回来出去。

协程的矛与盾

当咱们在讨论协程时,首先要明确,咱们是在说 Kotlin协程 ,下述论点也都是根据这个布景下开端。

相应的,咱们也需求一个参照物,假如直接比照线程,未免有些太过于不公正,所以咱们选用 线程池协程 进行比照剖析。

协程是线程结构吗?

Jvm 渠道,由于 协程 底层离不开 Java线程模型 ,故终究的使命也是需求 线程池 终究去承载。所以从底层而言,咱们能够浅显且斗胆的以为协程便是一个线程结构,这没问题。

[但],这明显不是很适宜,或许说,这有点过于糙了!

在文章开端,咱们现已提过了,Android官方对其的描绘:

协程是一种并发规划模式,您能够在 Android 渠道上运用它来简化异步履行的代码。

所以,假如咱们从协程本质与规划思维去看待,明显其比较线程池具有更高层次的编程模型,故此刻称其为 异步编程结构 或许更为适宜。具体原因与剖析有如下几点:

  • 从编程模型而言

    协程与线程池两者都是用于处理异步使命或许耗时使命的东西,但两者的编程模型完全不同。线程池或许其他线程结构,往往运用回调函数来处理使命,这种办法常常比较繁琐,事务杂乱时,代码可读性较差;而协程则是异步使命同步写法,根据挂起康复的理念,由程序员自己操控履行次序,可读性高;

  • 从反常的处理角度而言

    在线程池中,处理反常时,咱们能够通过 tryCach 事务代码,或许能够在创立线程池时,自界说 ThreadFactory , 然后运用 Thread.setDefaultUncaughtExceptionHandler() 设置一个默许反常处理办法。相应的,协程通过 反常处理机制 来捕获和处理反常,相关于线程池而言,愈加先进。

  • 从调度办法而言

    线程池通过创立一个固定数量的线程池来履行并发使命。每个使命将在一个可用的线程上运转,使命履行完毕后,线程将回来线程池以供以后运用,而且通过在行列中等候使命来保持活动状况。假如运用协程,它并不创立新的线程,在jvm渠道,其是运用少数的线程来完成并发履行,支撑在单线程中履行,并运用 挂起与康复 机制来答应并发履行。

协程功用很高?

先给结论,一般状况,协程的功用与线程池相差不大,甚至大多数常见场景,协程功用其实是不如直接运用线程池。

一起发动10w线程和协程

在协程官网,咱们大约都能看到这样一句话,一起发动10w和线程和协程等等。

咱们举个比方来看看,如下所示:

一起发动10w线程 一起发动10w协程
写给Android工程师的协程指南
写给Android工程师的协程指南

协程公然比线程快多了,那此刻肯定就有同学说了,你拿协程欺负线程,咋不用线程池呢?

运用线程池代替线程

咱们持续测试,这次改为线程池:

写给Android工程师的协程指南

线程池便是快啊!⚡️

假如你这样想,证明你或许了解错了‍♂️,咱们这儿仅仅往线程池里增加了10w个使命,由于咱们用例里中心线程数是10,所以,同一时刻,只要10个使命在被处理,所以剩余的使命都在行列中等候。即这儿打印的耗时仅仅仅仅上述代码的耗时,而不是线程池履行使命的总耗时,比较之下协程可是真真实实把10w个都跑完了,所以这两者根本没法比较。

所以咱们对上面的逻辑进行更改,如下所示:

写给Android工程师的协程指南

总耗时…,没工夫等候了,不过咱们能够大约算一下,总耗时16分钟多(10w/10*0.1/60)。

为什么呢?明明底层都是线程池?

假如留意观察的话,线程的等候咱们运用的是 sleep() ,而协程是 delay() ,两者的差异在于,前者是真真实实让咱们的线程堵塞了指守时刻,而后者则是言语等级,故距离很大。所以假如要做到相对公正,咱们应该选用支撑守时使命的线程池。

运用线程池模仿delay

为了确保相对公正,咱们运用 ScheduledExecutorService ,而且将这个线程池转为协程的调度器。

成果如下:

增加10w个使命 发动10w个协程
写给Android工程师的协程指南
写给Android工程师的协程指南

???为什么线程池更快呢?

由于协程底层,终究使命仍是需求咱们的线程池来承载,但协程还需求维护自己的微型线程,而这个模型又是言语级其他操控,所以当协程代码转为字节码之后,即需求更多的代码才能完成。比较之下,线程池就简略直接许多,故这也是为什么线程池会快一点的原因。

场景引荐

一般状况下,咱们真实耗时的使命都是IO网络 或许其他操作,所以此刻协程的运用层的额定操作简直并不影响大局。或许说面临杂乱的异步场景是,此刻功用或许并不是咱们首先考虑,而怎样更明晰的编码与封装完成,才是咱们所更关心的。相应的,比较线程池,协程就很拿手这个处理异步使命。比方协程能够通过简化异步操作,也能在很大程度上,能防止咱们不当的操作行为导致堵塞UI线程行为,然后提高运用功用。故在某个角度而言,协程的功用比较不恰当的运用线程池,是会更高。

所以假如咱们的场景对功用有这极致要求,比方运用发动结构等,那么此刻运用协程往往并不是最佳挑选。但假如咱们的场景是日常的事务开发,那么协程肯定是你的最佳挑选。

协程的运用技巧

将协程设置为可撤销

在协程中,撤销属于协作操作,也便是说,当咱们cancel掉某个job之后,相应的协程在挂起与康复之前并不会立即撤销(原因是协程的check机遇是在咱们状况机的每个过程里),即也便是说,假如你有某个堵塞操作,协程此刻并不会被撤销。

如下所示:

写给Android工程师的协程指南

如上所示,咱们会发现,当咱们 cancel() 子协程后,咱们的 readFile() 仍然会正常履行。

要解说原理也十分简略:

由于 readFile() 并不是挂起函数,而且该办法内部也没有做协程 状况判别

在协程中,咱们常用的函数 delay()withContext()ensureActive()yield() 等都供给了查看功用。

咱们改动一下上述示例,如下所示:

写给Android工程师的协程指南
写给Android工程师的协程指南

如上所示,咱们在 readFile() 中增加了 yield() 办法,而当咱们 cancel() 掉子协程时,当 Thread.sleep() 履行完毕后,遇到 yield()时,该办法就会判别当时协程效果域是否现已不在活泼,假如满意条件,则直接抛出 CancellationException 反常。

协程的同步问题?

由于 Kotlin协程 是运转在 Java线程模型 根底之上,所以相应的,也存在 同步 问题。

在多线程的状况下,操作履行的次序是不可猜测的。与编译器优化操作的次序不同,线程无法确保以特定的次序运转,而上下文切换的操作随时有或许产生。所以假如在拜访一个未经处理的状况时,线程很有或许就会拜访到过时的数据,丢掉必要的更新,或许遇到 资源竞赛 等状况。

所以,运用了协程而且触及可变状况的类必须采纳办法使其可控,比方确保协程中的代码所拜访的数据是最新的。这样一来,不同的线程之间就不会相互搅扰。

如下示例:

写给Android工程师的协程指南

上述代码很简略,需求留意的是,为了防止 println() 先于咱们的 repeat() 履行完毕,咱们运用measureTimeMillis()+coroutineScope() 进行嵌套,然后等候 coroutineScope() 内部一切子协程全部履行完毕,才退出 measureTimeMillis()

不过从成果来看,不出意外的也存在同步问题,那该怎样处理?

依照Java开发中的习气,咱们能够运用 synchronized ,或许运用 AtomicInteger 办理sum。

常规办法处理

如下所示,咱们选用 synchronized 来处理:

写给Android工程师的协程指南

如上所示,咱们运用了 synchronized 目标锁来处理同步问题。

留意:这儿咱们锁的是 this@coroutineScope ,而不是 this ,前者代表着咱们循环外的效果域目标,而直接运用this则代表了当时协程的效果域目标,并不存在竞赛关系。

运用Mutex处理

除去传统的处理办法之外,Kotlin 中还增加了额定的辅助类去处理协程同步问题,其运用起来也愈加简略,即 Mutex(互斥锁) ,这也是协程中处理同步问题的引荐办法。

如下示例:

写给Android工程师的协程指南

咱们创立了一个 Mutex 目标,并运用其 加锁办法 withLock() ,然后防止多协程下的同步问题。相应的,Mutex 也供给了 lock()unLock() 然后操控对同享资源的拜访(withLock()是这两者的封装)。

从原理上而言,Mutex 是通过 一个 AtomicInteger 类型的状况记载锁的状况(是否被占用),并运用一个 ConcurrentLinkedQueue 类型的行列来持有 等候持有锁 的协程,然后处理多个协程并发下的同步问题。

比较传统的 synchronized 堵塞线程,Mutex 内部运用了 CAS机制,而且支撑协程的挂起康复,其可扩展性,其都更具有优势;而且在协程的挂起函数中运用 synchronized,也或许会影响协程的正常调度和履行。故无论是上手难度及可读性,Mutex 无疑是更适合协程开发者的。

Mutex是功用的最佳挑选吗?

在过往,咱们提到 synchronized 都会觉得,它会直接堵塞线程,咱们都会不约而同的引荐CAS作为更好的代替。但其实 synchronizedjdk1.6 之后,现已增加了各种优化,比方增加了各种锁去减缓直接加锁所导致的上下文切换耗时。

所以,咱们比照一下上述的耗时:

写给Android工程师的协程指南
写给Android工程师的协程指南

为什么 Mutex 的功用其实不如 synchronized 呢?

原因如下

  • Mutex 在处理并发拜访时会产生额定的开支,由于 Mutex 是一个互斥锁,它需求操作系统层面的支撑来完成,包含支撑挂起和康复、上下文切换和内核态和用户态之间的切换等操作,这些操作都需求较大的系统开支和时刻,导致 Mutex 的功用较差。
  • synchronized 选用了一种愈加灵敏的办法来完成锁的机制,它会查看锁状况,假如没有被持有,则能够立即获取锁。假如锁被持有,则挑选等候,或许持续履行其他的使命。从具体的完成上来说,synchronized 底层由jvm确保,在运转进程中,或许会呈现倾向锁、轻量级锁、重量级锁等。关于 synchronized 相关的问题,咱们也能够去看看我这篇文章 浅析 synchronized 底层完成与锁相关。

最终,咱们再看一下 KotlinFlow 中关于同步问题的处理办法:

写给Android工程师的协程指南

嗯,所以Mutex还要不要用了?

假如咱们把视野向上提一级,就会了解,当咱们在选用 Kotlin 协程的时分,就现已挑选了为了运用方便去容忍牺牲一部分功用。再者说,假如你的事务真的对功用要求极致,那么协程自身其实并不是首选引荐的,此刻你应该选用线程池去处理,然后得到功用的最大化,由于协程自身的微型机制就需求做更多的额定操作。

再将视角切回到同步问题的处理上,Mutex 是协程中的引荐处理同步问题的办法,而且支撑挂起与康复,这点是其他同步处理办法无法具有的;再者说,Mutex 的上手难度比较 synchronized 低了不少。而至于功用上的距离,关于咱们的事务开发而言,简直是不会感知到,所以在协程中,Kotlin团队主张咱们运用Mutex。

协程的反常处理办法

关于协程的反常处理,其实一向都不是一个简略事,或许说,优雅的处理反常并没那么简略。

在传统的原生的反常处理中,咱们处理反常无在乎是这两种:

  • tryCatch();
  • Thread.setDefaultUncaughtExceptionHandler();

后者常用于非主线程的保底,前者用于简直任何方位。

由于协程底层也是运用的java线程模型,所以上述的办法,在协程的反常处理中,同样有效,如下所示:

写给Android工程师的协程指南

上述的 runCatching() 是kotlin中对 tryCatch() 的一种封装。

运用CoroutineExceptionHandler

在协程中,官方主张咱们运用 CoroutineExceptionHandler 去处理协程中反常,或许作为协程反常的保底手段,如下所示:

写给Android工程师的协程指南

咱们界说了一个 CoroutineExceptionHandler,并在初始化 CoroutineScope 时将其传入,然后咱们这个协程效果域下的一切子协程产生反常时都将被这个 handler 所阻拦。

这儿运用了 SupervisorJob() 的原因是,协程的反常是会传递的,比方当一个子协程产生反常时,它会影响它的兄弟协程与它的父协程。而运用了 SupervisorJob() 则意味着,其子协程的反常都将由其自己处理,而不会向外扩散,影响其他协程。

还有一点需求留意的是, CoroutineExceptionHandler 只能用于初始化 CoroutineScope 自身的初始化或许其直接子协程(即scope.launch),不然就算创立子协程时带着了 CoroutineExceptionHandler,也不会生效。

关于协程的反常处理,具体能够看我的这篇文章,里边有具体讲解:Kotlin | 关于协程反常处理,你想知道的都在这儿。

常见高阶函数

在开发中,有一些高阶函数,对咱们特别有用,这儿就将其列出来,以便咱们开发中进行运用:

写给Android工程师的协程指南

假如你对上述的办法都十分了解,那不妨为自己鼓鼓掌。

总结

在本篇,咱们着力于从全盘看起,理清 Kotlin协程 的方方面面。从 协程布景 到 挂起函数字节码完成,一瞥挂起与康复的底层完成,然后体会其相应的规划魅力,并针对一些常见问题进行剖析与解析,然后建立起协程完全了解。文章中挂起函数部分的源码部分或许稍显繁琐,但仍然主张咱们多看几遍流程,然后更好了解。相应的细节问题,也都有具体注释。

最终,让咱们再回到这个问题,协程究竟是什么呢?

在JVM渠道,Kotlin协程便是一个异步编程结构,它能够帮助咱们简化异步代码,提高可读性,然后极大减少异步回调所带来的杂乱逻辑。

从底层完成来看:

  • kotlin协程根据 java线程模型 ,故底层仍然是运用了 线程池 作为使命承载,但比较传统的线程模型,协程在其根底上搭建了一套根据言语级其他 ”微型“ 线程模型。并界说了挂起函数作为相应的子使命,其内部选用了状况机的思维,用于完成协程中的挂起与康复。
  • 在挂起与康复的完成上,运用了 suspend 关键字符号的函数被称为挂起函数。其在字节码中,会通过 CPS转化 为一个带有 Continuation 参数,回来值为 Object 的办法。而 Continuation 正是用于保存咱们的函数状况、过程,然后完成挂起康复,其内部也都包含着上一个 Continuation,正如 callback 的嵌套相同。
  • 当咱们的函数被挂起时,咱们当时的函数内部会实例化一个 ContinuationImpl() ,其内部 invokeSuspend() 又维护着当时的函数逻辑,并运用一个 label 作为状况进行流通,假如咱们的函数内部仍然有其他挂起函数,此刻也会将当时的 Continuation 目标传入子挂起函数内部,然后完成 Continuation 的传递,并更改当时的函数状况。而当咱们最底层的办法履行完毕后,此刻就会再次触发父 ContinuationImpl 内部的 invokeSuspend() 办法,然后回到调用方的逻辑内部,然后完成挂起函数的康复。以此类推,直到咱们最开端的调用办法内;

从功用上去看:

  • 协程的功用并不优于线程池或许其他异步结构,主要是其做了更多言语等级过程,但一般状况下,与其他结构的功用简直共同,由于比较IO的耗时,言语级其他损耗能够简直忽略不计;

从规划模式去看:

  • 协程使得开发者能够自行办理异步使命,而不同于线程的抢占式使命,而且写成还支撑子协程的嵌套关闭、更简洁的反常处理机制等,故比较其他异步结构,协程的理念愈加先进;

参照

  • Android官网/ Android上的Kotlin协程
  • 朱涛/ Kotlin Jetpack实战|图解协程原理
  • zsqw123/ kotlin coroutine真的功用高吗?

关于我

我是 Petterp ,一个 Android工程师 ,假如本文对你有所帮助,欢迎 点赞、谈论、收藏,你的支撑是我持续创造的最大鼓励!

欢迎重视我的 公众号(Petterp) ,等待与你一同行进 :)