0x3、第一个官方Demo的解读
如题,官方文档中给出了第一个Kotlin协程Demo:Your first coroutine,笔者加了点料:
运行输出结果如下:
讲解一波(有些名词不懂也没关系,不影响后续学习):
上节说过,Kotlin-JVM的协程是 假协程,只是对底层Thread的一次良好封装,这里通过Thread.currentThread().name 把当前线程的名字打印出来,可以看到协程所用的线程名为:DefaultDispatcher-worker-1,盲猜 线程池,毕竟高效的多线程调度基本是离不开线程池。
GlobalScope.launch 先简单地理解为创建了一个协程,第12行比第8行先执行的原因: 创建线程池要费点时间,所以没主线程同步代码的执行速度快。
delay() 是一个 挂起函数(suspend function),可在不堵塞线程的情况下延迟协程;Thread.sleep() 则会堵塞当前线程;
suspend 挂起的意思:协程作用域被挂起,但当前 线程中协程作用域外的代码不被堵塞;suspend挂起函数,只能在协程或者另外一个挂起函数中被调用。
在协程挂起(等待)时,线程会回到线程池,当等待结束,协程会从线程池中一个空闲的线程上恢复。
读者比较疑惑的问题可能是:第13行的那一句 Thread.sleep(2000L) 能去掉吗?
答:不行,这句话的作用是 堵塞主线程,JVM保活,好让协程执行完毕,如果去掉,协程里的东西没执行完,JVM就退出了。
另外再说一点:
main线程只是一个普通的用户线程,其他线程都是由main线程启动的,但在进程层面看:线程都是平级的,没有父子关系,JVM会在所有用户线程执行完毕后退出,注意是 用户线程!JVM可不会理 守护线程 生还是死,可以通过 setDaemon(true) 将线程设置为守护线程。
而打开Kotlin协程源码,全局搜下:isDaemon = true,可见一斑:
① runBlocking
另外,Kotlin还提供了一种机制来堵塞线程,可实现与Thread.sleep相同的效果,修改后的代码:
背后的机制:
runBlocking函数会建立一个 堵塞当前线程的协程,main线程会等待runBlocking中的代码执行完毕。
上述代码还可以再改进一波:
runBlocking是一个全局函数,可在任意地方调用,不过 项目中用得不多,毕竟堵塞main线程意义不大,常用于单元测试防止JVM退出。
还有一点,使用delay()函数可以起到延迟等待作用,但并非良策,实际开发中耗时任务的时间存在不确定性,可以使用Kotlin协程提供的Job(作业)来实现,这个等下会讲。
0x4、CoroutineScope → 协程作用域
① GlobalScope → 全局协程作用域
点进 GlobalScope 的源码:
定义成了一个 单例对象, 在整个JVM虚拟中只有一份对象实例,生命周期贯穿整个JVM,故使用时需要警惕 内存泄漏!!!上面讲过Kotlin协程通过作用域来实现「结构化并发」的需求,可以自定义协程作用域以满足我们的需求。
② 自定义作用域
GlobalScope继承自CoroutineScope接口,点开源码,比较简单,持有一个CoroutineContext上下文:
可以让类实现这个接口,让该类称为一个协程作用域,示例如下:
2、使用MainScope()函数
为了在Android/JavaFx等场景中更方便的使用,官方提供了 MainScope() 函数快速创建基于主线程协程作用域。
使用MainScope可以很方便的控制所有它范围内的协程的取消,官方更推荐我们定义一个抽象的Activity,示例如下:
3、使用 coroutineScope() 和 supervisorScope() 创建子作用域
注意,是创建子作用域,只能在一个已有的协程作用域中调用,前者出现异常时会把异常抛出(父协程及其他子协程会被取消),后者出现异常时不会影响其他子协程,示例如下:
0x5、创建协程 → 作用域函数
协程作用域,确定了协程间的父子关系,以及取消或异常处理等方面的传播行为。接着,可以利用作用域函数来创建协程。
① launch & async
这两个函数会创建一个「不堵塞」当前线程的新协程,区别:
- launch返回一个「Job」,用于协程监督与取消,用于无返回值的场景。
- async返回一个Job的子类「Deferred」,可通过await()获取完成时返回值。
简单的代码使用示例如下:
输出结果如下:
0x6、suspend关键字 → 挂起函数
Kotlin协程提供了 suspend
关键字,用于定义一个 挂起函数,它就是一个 标记
当你写的普通函数需要在「某些时刻挂起和恢复」,加上他就行,其他不用你理!!!
而它的真正作用:
告知编译器,这个函数需在协程中执行,编译器会将挂起函数用「有限状态机」转换为一种优化版的回调。
抽取一波业务代码,用suspend定义挂起函数,修改后的代码如下:
0x7、Job → 作业
调用launch函数会返回一个Job对象,代表一个 协程的工作任务
① 常用API
/** * 协程状态 */ isActive: Boolean //是否存活 isCancelled: Boolean //是否取消 isCompleted: Boolean //是否完成 children: Sequence<Job> // 所有子作业 /** * 协程控制 */ cancel() // 取消协程 join() // 堵塞当前线程直到协程执行完毕 cancelAndJoin() // 两者结合,取消并等待协程完成 cancelChildren() // 取消所有子协程,可传入CancellationException作为取消原因 attachChild(child: ChildJob) // 附加一个子协程到当前协程上
② 生命周期
Job的生命周期包括一系列的状态:
New(新创建)、Active(活跃)、Completing(完成中)、
Completed(已完成)、Cancelling(取消中)、Cancelled(已取消)
注意上图中的 await children,当协程处于完成中 或取消中,会等待所有子协程完成后,才进入已完成或已取消状态。
③ 取消操作详解
- 取消作用域会取消它的所有子协程;
- 同一作用域中,被取消的子协程不会影响其余兄弟协程;
- 协程通过抛出一个特殊的异常CancellationException来处理取消操作,cancel函数中默认会创建一个,也可以自己构建新的实例传入,子协程因为CancellationException而被取消,父协程是不需要进行其他额外操作的;
- 不能在已取消的作用域中再次启动新的协程;
- 协程的取消是「协作式」的,协程不会在调用cancel()时立即停止,调用后只是进入 取消中 状态,只有工作完成后才会变成 已取消 状态,所以需要我们在代码中定期检查协程是否处于活动状态。比如下述例子:
运行输出结果如下:
并没有在取消后立即停止,需要我们自己手动去判断,比如在while()循环中加入**isActive
**
while(i < 5 && isActive)
也可以使用**ensureActive()
来检查,该函数会在Job处于不活跃状时立即抛出异常,可以把它写在循环体的第一行。 还可以使用yield()
**来检查,它的第一个操作就是检查Job是否完成,已完成会抛出CancellationException来退出协程。
协程取消后会抛出CancellationException,可以使用try/catch代码块对此异常进行捕获,在finally块中完成资源释放等清理工作。
但要注意一个问题:处于取消中状态的协程不能够挂起,finally中的代码如果涉及挂起,后续代码是不会继续执行的,可以通过 withContext
+ NonCancellable
来创建一个无法取消的任务,以保证清理任务的完成,示例代码如下:
运行结果如下:
④ 异常处理
Kotlin中异常处理的玩法有三种,先是「try-catch直接捕获作用域内异常」,代码示例如下:
输出结果如下:
注:无法使用try-catch去捕获launch和async作用域的异常!!!
然后是「全局异常处理」跟Rx里的 RxJavaPlugins.setErrorHandler 捕获全局异常很相似,而全局协程作用域存在嵌套子父级关系,所以异常可能会依次抛出多个,代码示例如下:
输出结果如下:
注:只支持launch()传入,async()传入是无效的;全局异常处理并不能阻止协程取消,只是避免因异常而退出程序。
最后是「异常传播」,协程作用域中异常传播默认是 双向 的表现为:
- 父协程发生异常,所有子协程都会取消;
- 子协程发生异常,会导致父协程取消,间接导致兄弟协程也取消;
代码示例如下:
输出结果如下:
有两种方式将传播变为 单向,即子协程发生异常不会影响父协程及兄弟协程。 其中一种方式就是用 SupervisorJob 代替 Job,修改后的代码示例:
另一种是使用上面自定义作用域介绍的 supervisorScope,修改后的代码示例如下:
相同的运行结果: