kotlin学习总结之九 协程(一)

一. 什么是协程

协程本质是一套由 Kotlin 官方提供的线程 API,可以理解为一个线程框架。它最大的好处是:可以在同一个代码块中进行多次线程切换,简化异步任务处理的方案。

协程和线程的区别:

  • 协程是运行在单线程中的并发程序,避免了多线程并发机制中切换线程时带来的线程上下文切换、线程状态切换、线程初始化上的性能损耗,能大幅度提高并发性能。

  • 线程是由系统调度的,线程切换或线程阻塞的开销都比较大。而协程依赖于线程,但是协程挂起时不需要阻塞线程,几乎是无代价的,协程是由开发者控制的。所以协程也像用户态的线程,非常轻量级,一个线程中可以创建任意个协程。

  • 协程是跑在线程上的,一个线程可以同时跑多个协程,每一个协程则代表一个耗时任务,需要手动控制多个协程之间的运行、切换,决定谁什么时候挂起,什么时候运行,什么时候唤醒,而不是线程那样交给系统内核来操作去竞争CPU时间片,缺点是本质是个单线程,不能利用到单个CPU的多个核心。

二. 工程配置(引入依赖)

首先,要在工程里引入协程。

这里注意,kotlin相关库的版本最好都用1.3.+的版本,并且要符合gradle插件版本在3.0.0版本以上才可以使用。

//project.gradle
classpath 'com.android.tools.build:gradle:3.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.40"

//app.gradle
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.5"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.5"

三. 协程作用域(协程运行环境)

协程是一套管理和运行异步任务的框架,所以需要有运行的环境,也叫协程的作用域,在这个作用域里,才可以使用协程来执行异步任务,协程作用域是协程运行的作用范围,换句话说,如果这个作用域销毁了,那么里面的协程也随之失效。就好比变量的作用域。

1. CoroutineScope 接口

CoroutineScope 是一个接口,要是查看这个接口的源代码的话就发现这个接口里面只定义了一个属性 CoroutineContext:

public interface CoroutineScope {
    // Scope 的 Context
    public val coroutineContext: CoroutineContext
}

2. GlobalScope(全局作用域)

GlobalScope 是 CoroutineScope 的一个单例实现,是一个单例对象,是默认的全局作用域。GlobalScope 实现了 CoroutineScope 接口,这个接口持有了协程上下文。

public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

用法:

GlobalScope.launch {
    //...
}

GlobalScope代表协程的全局作用域,在该作用域启动的协程为顶层协程,没有父任务,且该scope没有Job对象(管理任务),所以无法对整个scope执行cancel()操作,所以如果没有手动取消每个任务,会造成这些任务一直运行(覆水难收),可能会导致内存泄露等问题,所以仍适用于业务开发。

3. 自定义作用域

协程作用域的创建方式有很多,常见的有:
① 继承 CoroutineScope 接口自己实现;
② 使用 coroutineScope 方法或者 supervisorScope 方法创建;

① CoroutineScope 接口
在应用中具有生命周期的组件应该实现 CoroutineScope 接口,并负责该组件内 Coroutine 的创建和管理。

CoroutineScope(Dispatchers.Main).launch {
    //...
}

通常我们会通过创建CoroutineScope,来实现一个自己的协程作用域,并且可以指定派发器,在我们需要取消该scope下所有任务时(比如Activity退出时),调用scope.cancel()方法,就可以取消该scope下所有正在进行的任务,这才是我们所期望的。

例如对于 Android 应用来说,可以在 Activity 中实现 CoroutineScope 接口, 例如:

class ScopedActivity : Activity(), CoroutineScope {
    lateinit var job: Job
    // CoroutineScope 的实现
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        job = Job()

    /*
     * 注意 coroutine builder 的 scope, 如果 activity 被销毁了或者该函数内创建的 Coroutine
     * 抛出异常了,则所有子 Coroutines 都会被自动取消。不需要手工去取消。
     */
      launch { // <- 自动继承当前 activity 的 scope context,所以在 UI 线程执行
          val ioData = async(Dispatchers.IO) { // <- launch scope 的扩展函数,指定了 IO dispatcher,所以在 IO 线程运行
            // 在这里执行阻塞的 I/O 耗时操作
          }
        // 和上面的并非 I/O 同时执行的其他操作
          val data = ioData.await() // 等待阻塞 I/O 操作的返回结果
          draw(data) // 在 UI 线程显示执行的结果
      }
    }

    override fun onDestroy() {
        super.onDestroy()
        // 当 Activity 销毁的时候取消该 Scope 管理的 job。
        // 这样在该 Scope 内创建的子 Coroutine 都会被自动的取消。
        job.cancel()
    }


}

由于所有的 Coroutine 都需要一个 CoroutineScope,所以为了方便创建 Coroutine,在 CoroutineScope 上有很多扩展函数,比如 launch、async、actor、cancel 等。

② 使用 coroutineScope 和 supervisorScope 方法创建协程作用域
coroutineScope 方法可以用来创建一个子作用域,它只能在另一个已有的协程作用域中调用,例如在另外一个 suspend 方法中调用。supervisorScope 方法和 coroutineScope 类似,也用于创建一个子作用域,区别是 supervisorScope 出现异常时不影响其他子协程, coroutineScope 出现异常时会把异常抛出。

4. MainScope

在 Android 中会经常需要实现这个 CoroutineScope,所以为了方便开发者使用, 标准库中定义了一个 MainScope() 函数,该函数定义了一个使用 SupervisorJob 和 Dispatchers.Main 为 Scope context 的实现。

class MainActivity: AppCompatActivity(), CoroutineScope by MainScope() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 在IO线程中,请求网络数据
        launch(Dispatchers.IO) {
            val res = requestService()

            // 在主线程中,更新 UI
            launch {
                updateUi(res)
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()

        // 在 Activity 销毁时取消
        cancel()
    }
}

四. 启动协程

现在有了协程运行环境和任务执行环境,接下来要做的就是启动一个协程了!
需要构造器来启动协程。官方目前提供的基础构造器有两个:

  • runBlocking
  • 通过scope对象,使用launch和async方法创建协程。

这两个构造器都会启动一个协程,区别在于后者不会阻塞当前线程,并且会返回一个协程的引用,而前者会等待协程的代码执行结束,再执行剩下的代码。

1. runBlocking

runBlocking是启动新协程的一种方法。
runBlocking启动一个新的协程,并阻塞它的调用线程,直到里面的代码执行完毕。

 public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend           
  CoroutineScope.() -> T): T {
        ...
    }

举例

println("aaaaaaaaa ${Thread.currentThread().name}")

runBlocking {
    for (i in 0..10) {
        println("$i ${Thread.currentThread().name}")
        delay(100)
    }
}

println("bbbbbbbbb ${Thread.currentThread().name}")

上面代码的输出为:

aaaaaaaaa main
0 main
1 main
2 main
3 main
4 main
5 main
6 main
7 main
8 main
9 main
10 main
bbbbbbbbb main

所有的代码都在主线程执行,按照顺序来,去掉runBlocking也是一样的。

但是,runBlocking可以指定参数,就可以让runBlocking里面的代码在其他线程执行,但同样可以阻塞外部线程。

println("aaaaaaaaa ${Thread.currentThread().name}")
runBlocking(Dispatchers.IO) { // 注意这里
    for (i in 0..10) {
        println("$i ${Thread.currentThread().name}")
        delay(100)
    }
}
println("bbbbbbbbb ${Thread.currentThread().name}")

上面的代码,给runBlocking添加了一个参数,Dispatchers.IO,这样里面的代码块就会执行到其他线程了。

来一起看看效果:

aaaaaaaaa main
0 DefaultDispatcher-worker-1
1 DefaultDispatcher-worker-1
2 DefaultDispatcher-worker-1
3 DefaultDispatcher-worker-4
4 DefaultDispatcher-worker-4
5 DefaultDispatcher-worker-6
6 DefaultDispatcher-worker-7
7 DefaultDispatcher-worker-7
8 DefaultDispatcher-worker-9
9 DefaultDispatcher-worker-1
10 DefaultDispatcher-worker-5
bbbbbbbbb main

2. launch方法

launch 是最常见的协程构建器,它会启动一个新的协程(AbstractCoroutine),并将这个协程对象返回,接着会在协程中执行参数中的 block。不会阻塞调用线程。

AbstractCoroutine 继承了 Job,launch 返回的 Job 对象实际就是协程对象本身。

launch 的原型如下:

public fun CoroutineScope.launch(
    /** 上下文 */
    context: CoroutineContext = EmptyCoroutineContext,
    
    /** 如何启动 */
    start: CoroutineStart = CoroutineStart.DEFAULT,
    
    /** 启动后要执行的代码 */
    block: suspend CoroutineScope.() -> Unit
): Job

launch 方法有两个可选参数:CoroutineContext 和 CoroutineStart

  • CoroutineContext:
    是协程的上下文,默认使用 EmptyCoroutineContext,最主要的两个元素是:Job、Dispatcher。
    Job控制协程的开始、取消等,Dispatchers作用是决定把协程派发到哪个线程中执行,与指定的CoroutineScope中的coroutineContext保持一致,比如GlobalScope默认运行在一个后台工作线程内。也可以通过显示指定参数来更改协程运行的线程,Dispatchers提供了几个值可以指定:Dispatchers.Default、Dispatchers.Main、Dispatchers.IO、Dispatchers.Unconfined。

  • CoroutineStart:
    协程的启动模式。默认的(也是最常用的)CoroutineStart.DEFAULT是指协程立即执行,除此之外还有CoroutineStart.LAZY、CoroutineStart.ATOMIC、CoroutineStart.UNDISPATCHED。

  • block:
    协程主体。也就是要在协程内部运行的代码,block是一个suspend匿名方法,可以通过lamda表达式的方式方便的编写协程内运行的代码。

  • 返回值Job:
    Job是launch方法的返回值,它就是用来控制协程的运行状态的。Job中有几个关键方法:

    • start。如果是CoroutineStart.LAZY创建出来的协程,调用该方法开启协程。
    • cancel。取消正在执行的协程。如协程处于运算状态,则不能被取消。也就是说,只有协程处于阻塞状态时才能够取消。
    • join。阻塞父协程,直到本协程执行完。
    • cancelAndJoin。等价于cancel + join。

3. async方法

async 比较常见,它也会启动新的协程(AbstractCoroutine),并返回这个协程对象,然后在协程中执行 block。它与launch类似,差别在于返回值。async方法返回一个Deferred<T>类型。
Deferred继承自Job,最主要的是增加了await方法,通过await方法返回T。Deferred.await在等待返回值时会阻塞当前的协程。

参数和 launch 一样,我们看看 async 怎么获取返回值:

// 任务1:耗时一秒后返回100
val coroutine1 = GlobalScope.async {
    delay(1000)
    return@async 100
}

// 任务2:耗时1秒后返回200
val coroutine2 = GlobalScope.async {
    delay(1000)
    return@async 200
}

// 上面两个协程会并发执行

// 等待两个任务都执行完毕后,再继续下一步(打印结果)。
GlobalScope.launch {
    val v1 = coroutine1.await()
    val v2 = coroutine2.await()
    log("执行的结果,v1 = $v1, v2=$v2")
}

4. launch和async方法创建协程区别。

CoroutineScope(Dispatchers.IO).launch {  }
CoroutineScope(Dispatchers.IO).async {  }

而两者的最大不同是,async会创建一个Deferred的协程,可以用来等待该协程执行完毕再进行后续操作。
runBlocking { }在当前线程启动一个协程,阻塞当前线程。也是一个协程,不过一般不这样使用

CoroutineScope(Dispatchers.IO).launch {
  val job = async {  }
  val data = job.await()
  //do something with data
}

上述代码,在launch的协程执行到await()方法时,会将协程挂起(而不是线程挂起,不会阻塞线程),等待async异步任务执行完成后,会返回结果到data,从而进行后续逻辑。

当然,如果在调用await()方法时,async协程已经执行完毕拥有了结果,那么不会挂起协程,而是直接返回结果到data变量里。

五. 协程派发器(任务执行环境)

有了运行环境执行异步任务,还需要有派发器将不同的任务派发到不同的线程执行,在lanch()函数中有一个CoroutineContext 参数,该参数就是制定任务环境的参数。这也是我们经常遇到的,比如网络请求在工作线程,结果回来后的UI展示,需要在主线程进行。协程调度器可以将协程的执行局限在指定的线程中,调度它运行在线程池中或让它不受限的运行。kotlin给我们提供了一些默认的Dispatcher:

  • Dispatchers.IO:工作线程池,依赖于Dispatchers.Default,支持最大并行任务数。这个调度器被优化在主线程之外执行磁盘或网络 I/O。例如包括使用 Room 组件、读写文件,以及任何网络操作。

  • Dispatchers.Main:主线程,这个在不同平台定义不一样,所以需要引入相关的依赖,比如Android平台,需要使用包含MainLooper的handler来向主线程派发。使用这个调度器在 Android 主线程上运行一个协程。这应该只用于与 UI 交互和一些快速工作。示例包括调用挂起函数、运行 Android UI 框架操作和更新 LiveData 对象。

  • Dispatchers.Default:默认线程池,核心线程和最大线程数依赖cpu数量。这个调度器经过优化,可以在主线程之外执行 cpu 密集型的工作。例如对列表进行排序和解析 JSON。

  • Dispatchers.Unconfined:无指定派发线程,会根据运行时的上线文环境决定。

通常我们用的就是Dispatchers.IO和 Dispatchers.Main,在创建scope时,可以指定派发器,如CoroutineScope(Dispatchers.Main),就是指定该scope启动的协程,都在主线程执行。调度器实现了CoroutineContext接口。

六. 启动模式

在lanch()函数中有一个CoroutineContext 参数,该参数就是制定任务环境的参数。在Kotlin协程当中,启动模式定义在一个枚举类中:

public enum class CoroutineStart {
    DEFAULT,
    LAZY,
    @ExperimentalCoroutinesApi
    ATOMIC,
    @ExperimentalCoroutinesApi
    UNDISPATCHED;
}

一共定义了4种启动模式,下表是含义介绍:

启动模式 作用
DEFAULT 默认的模式,立即执行协程体
LAZY 只有在需要的情况下运行
ATOMIC 立即执行协程体,但在开始运行之前无法取消
UNDISPATCHED 立即在当前线程执行协程体,直到第一个 suspend 调用

七. 挂起函数

协程体是一个用suspend关键字修饰的一个无参,无返回值的函数类型。被suspend修饰的函数称为挂起函数,与之对应的是关键字resume(恢复),suspend,对协程的挂起并没有实际作用,其实只是一个提醒,函数创建者对函数的调用者的提醒,提醒调用者我是需要耗时操作,需要用挂起的方式,在协程中使用。

注意:挂起函数只能在协程中和其他挂起函数中调用,不能在其他地方使用,因为普通函数没有suspend和resume这两个特性,所以必须要在协程的作用中使用。通过报错来提醒调用者和编译器,这是一个耗时函数,需要放在后台执行。

给函数前加上suspend 关键字

suspend fun testSuspendfun(){
     
  }

需要使用挂起函数常见的场景有:

  • 耗时操作:使用 withContext 切换到指定的 IO 线程去进行网络或者数据库请求、io耗时操作获取数据库数据、一些等待一会需要的操作:列表排除,json解析等;

  • 等待操作:使用delay方法去等待某个事件。

八. 取消协程

现在我们已经可以完整的运行一个协程任务了,还有一个问题,就是如何取消协程呢?这个也很重要,比如Android中的网络请求等资源数据的加载,需要在页面关闭时中断,从而减少性能流量的损耗,以及避免一些内存泄露的问题。

//1.通过协程cancel()
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
  launch {
    while (true) {
      log("inner-launch")
    }
  }
  while (true) {
    log("outer-launch")
  }
}
scope.launch {
  delay(1000)
  scope.cancel()
}
//2.通过CorouinteScope.cancel)(
val scope = CoroutineScope(Dispatchers.IO)
val outerJob = scope.launch {
  launch {
    while (true) {
      log("inner-launch")
    }
  }
  while (true) {
    log("outer-launch")
  }
}
scope.launch {
  delay(1000)
  outerJob.cancel()
}

kotlin协程的取消规则是这样的:

  • 父协程调用cancel(),会取消自己以及所有子(内部)协程。
  • 子协程调用cancel(),默认不会取消父协程。

可以通过调用CoroutineScope的cancel()方法,取消掉该scope产生的所有协程。

据此,以上两个demo的行为是这样的:

  1. outer和inner的协程全部被取消。
  2. outer和inner以及scope开启的所有协程被取消。

但是,运行上面的demo我们会发现,log会一直输出东西,这是为什么呢?因为协程的cancel()原理是改变了协程对象的内部状态,但并没有终止逻辑代码的调用,也就是说协程状态和代码运行是两个部分,具体的原理我们在下面会说。那我们应该怎么办呢?

答案很简单,既然改变了协程的状态,那么我们用协程状态字段来判断协程是否被取消了即可,将判断条件代码改成如下:

while (isActive) {
  log("outer-launch")
}

isActive是协程的一个状态字段。

八. 异常捕获

现在我们成功通过协程执行了一段代码,对于执行代码,必不可少就是对可能的异常进行捕获和处理。
kotlin的协程,也有一套自己的捕获异常机制。

//1.根协程为launch
CoroutineScope(Dispatchers.IO).launch {
  async{ launch { throw IllegalStateException("this is an error") } }
}
//2.根协程为async
CoroutineScope(Dispatchers.IO).async {
  async{ launch { throw IllegalStateException("this is an error") } }
}
//3.捕获async{}.await()异常
CoroutineScope(Dispatchers.IO).async {
  try {
    val job = async { throw IllegalStateException("this is an error") }
    val data = job.await()
    //do something with data
  } catch (e: Exception) {
    log(e.message)
  }
}

先来简单描述下协程的异常机制:

在协程内部通过try-catch捕获的异常,由我们自己处理(和java异常一样)。

未捕获的异常,协程本身默认不处理,而是一层一层的交由父协程,直到根协程进行处理。

根协程处理异常时,会使用注册的CoroutineExceptionHandler对象进行处理;Android的协程依赖包,会引入并自动注册一个该对象,处理行为与java处理一致(直接交由UncaughtExceptionHandler)。

有些协程类型重写了处理异常方法,默认不处理异常,比如async式协程,这类协程作为根协程的话,最终会导致异常丢失,继续执行后续逻辑。

内部协程出现异常,会逐层cancel掉父协程。

据此机制,我们看下上面三个demo的异常处理情况:

  • 根协程为launch式协程时,会使用Android提供的handler进行异常抛出,最终表现就是应用崩溃。

  • 根协程为async式协程时,不会处理异常,最终表现就是没异常的抛出(但继续执行下去其实很危险)。

  • async式协程的await()方法,在返回异常时,会进行抛出,所以我们可以通过try-catch这个await()方法,来捕获async式协程产生的异常。

这里需要注意的是,如果根协程为launch式协程,即使我们使用(3)描述的办法,依然没有办法阻止崩溃,因为launch协程会处理异常并抛出。

综上所述,对于我们想捕获的异常,最靠谱的办法还是在协程内部自己捕获异常进行处理,避免因为未捕获而直接崩溃。

Kotlin 协程之一:基础使用
破解 Kotlin 协程(1) - 入门篇
Kotlin Coroutine(协程) 简介
kotlin 协程在 Android 中的使用(Jetpack 中的协程、Retofit中使用协程)
kotlin协程-Android实战
Kotlin Primer·第七章·协程库
Kotlin协程学习
Kotlin Coroutines基础和原理初探
kotlin极简教程

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