协程(Coroutines)
什么是协程?
协程可以看成轻量级线程,通过挂起和恢复的机制进行协程任务调度,本质上是在线程上进行任务调度。而协程和线程的关系大概可以类比成线程和进程的关系。进程可以包含多个线程,而线程也能包含多个协程,但是线程执行的时候是无序的,协程则是按顺序执行。
为什么要使用协程?
一般需要使用线程的场景都可以使用协程,比如文件IO,网络请求等耗时操作。使用协程来替代线程(包括不限于AsyncTask、Thread、Handler等方式)主要有如下优点:
- 轻量,高效
- 便于管理
- 能用同步的编码方式编写异步代码
协程创建
如果我们用GlobalScope来创建协程,那么新创建的协程是最顶层的,这意味着新协程的寿命仅受整个应用程序寿命的限制。这里的示例为了简化以GlobalScope为例。协程常用创建方式有以下几种:
launch方式创建
它返回的是一个job对象,通过job对象可以取消协程。通过launch方式创建的协程是非阻塞的,也就是说不会阻塞当前创建协程的线程但是你可以通过job的join方法阻塞线程,来等待协程执行结束。如果创建协程的地方不在协程域中则需要使用GlobalScope.launch{…}方式创建协程(这里的大括号里面表示的就是协程域),而在协程域中可以直接launch来创建协程。
GlobalScope.launch是直接开启一个新线程,而launch则是在协程域所在的线程执行。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
GlobalScope.launch {
Log.e(TAG, "run in Coroutine , current Thread is ${Thread.currentThread().name}")
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
}
companion object{
const val TAG: String = "MainActivity"
}
}
执行结果:
2020-10-23 15:36:08.120 3984-3984/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
2020-10-23 15:36:08.129 3984-4014/com.example.rxhttpdemo E/MainActivity: run in Coroutine , current Thread is DefaultDispatcher-worker-2
可以看到明显是下面的打印语句先执行,说明没有阻塞主线程,而且通过线程的名字可以明显看到两个打印语句不是工作在同一个线程。
然后我们在协程内部嵌套一个launch,如下:
...
GlobalScope.launch {
Log.e(TAG, "run in Coroutine , current Thread is ${Thread.currentThread().name}")
launch {
Log.e(TAG, "run in inner Coroutine , current Thread is ${Thread.currentThread().name}")
}
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
...
打印如下:
2020-10-23 15:41:56.330 4617-4617/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
2020-10-23 15:41:56.337 4617-4634/com.example.rxhttpdemo E/MainActivity: run in Coroutine , current Thread is DefaultDispatcher-worker-2
2020-10-23 15:41:56.338 4617-4634/com.example.rxhttpdemo E/MainActivity: run in inner Coroutine , current Thread is DefaultDispatcher-worker-2
可以看到协程是按顺序执行,并且嵌套的协程和协程域工作在的同一个线程中。
runBlocking方式创建
这种创建方式协程工作在创建协程的线程,会阻塞住创建协程的线程,只有协程执行结束才能继续线程的下一步执行,默认执行在创建协程的线程。我们把代码做下改动:
...
runBlocking {
Log.e(TAG, "run in Coroutine , current Thread is ${Thread.currentThread().name}")
launch {
Log.e(TAG, "run in inner Coroutine , current Thread is ${Thread.currentThread().name}")
}
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
...
打印结果:
2020-10-23 15:47:13.298 4751-4751/com.example.rxhttpdemo E/MainActivity: run in Coroutine , current Thread is main
2020-10-23 15:47:13.299 4751-4751/com.example.rxhttpdemo E/MainActivity: run in inner Coroutine , current Thread is main
2020-10-23 15:47:13.299 4751-4751/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
可以看到两点信息:
- 协程顺序执行
- 阻塞了主线程
然后我们在稍微改动下代码,指定创建的协程在IO线程:
...
runBlocking (Dispatchers.IO){
Log.e(TAG, "run in Coroutine , current Thread is ${Thread.currentThread().name}")
launch {
Log.e(TAG, "run in inner Coroutine , current Thread is ${Thread.currentThread().name}")
delay(2000)
}
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
...
打印结果:
2020-10-23 15:52:17.586 4955-4977/com.example.rxhttpdemo E/MainActivity: run in Coroutine , current Thread is DefaultDispatcher-worker-1
2020-10-23 15:52:17.588 4955-4977/com.example.rxhttpdemo E/MainActivity: run in inner Coroutine , current Thread is DefaultDispatcher-worker-1
2020-10-23 15:52:19.590 4955-4955/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
从打印结果时间来看就算是把协程创建在IO线程,主线程还是阻塞了大概2s。
async方式创建
这种创建方式和launch差不多,也是非阻塞式创建,都能处理一个线程中多个线程的并发问题,但是这种方式可以通过在协程域或者挂起函数中通过await()函数获取返回结果,但是如果调用await()函数就会变成阻塞式。
...
Log.e(TAG, "start")
GlobalScope.async {
async {
delay(2000)
Log.e(TAG, "run in Coroutine async1, current Thread is ${Thread.currentThread().name}")
}
async {
delay(1000)
Log.e(TAG, "run in Coroutine async2, current Thread is ${Thread.currentThread().name}")
}
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
...
打印结果:
2020-10-23 16:36:08.055 8080-8080/com.example.rxhttpdemo E/MainActivity: start
2020-10-23 16:36:08.071 8080-8080/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
2020-10-23 16:36:09.079 8080-8096/com.example.rxhttpdemo E/MainActivity: run in Coroutine async2, current Thread is DefaultDispatcher-worker-1
2020-10-23 16:36:10.078 8080-8096/com.example.rxhttpdemo E/MainActivity: run in Coroutine async1, current Thread is DefaultDispatcher-worker-1
可以看到是协程并没有阻塞主线程,而且内部两个协程耗时时间为2s,并发执行。
然后我们代码稍微改动下:
...
Log.e(TAG, "start")
GlobalScope.async {
val deferred1 = async {
delay(2000)
Log.e(TAG, "run in Coroutine async1, current Thread is ${Thread.currentThread().name}")
}
deferred1.await()
val deferred2 = async {
delay(1000)
Log.e(TAG, "run in Coroutine async2, current Thread is ${Thread.currentThread().name}")
}
deferred2.await()
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
...
打印结果:
2020-10-23 16:41:21.637 8286-8286/com.example.rxhttpdemo E/MainActivity: start
2020-10-23 16:41:21.645 8286-8286/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
2020-10-23 16:41:23.653 8286-8302/com.example.rxhttpdemo E/MainActivity: run in Coroutine async1, current Thread is DefaultDispatcher-worker-1
2020-10-23 16:41:24.654 8286-8303/com.example.rxhttpdemo E/MainActivity: run in Coroutine async2, current Thread is DefaultDispatcher-worker-2
可以看到又变成串行阻塞式运行了。因为await()是一个 挂起(suspend)函数,程序运行到这里就会被挂起直到该函数执行完成才会继续执行下一个 async。
withContext方式创建
这种方式创建和async一样都是可以返回结果的,结果是函数体中的最后一行。但是这种方式是串行执行,和async返回结果调用await()函数效果一样。
...
Log.e(TAG, "start")
GlobalScope.async {
val task1 = withContext(Dispatchers.IO) {
delay(2000)
Log.e(TAG, "run in Coroutine task1, current Thread is ${Thread.currentThread().name}")
"task1" //返回结果赋值给task1
}
val task2 = withContext(Dispatchers.IO) {
delay(1000)
Log.e(TAG, "run in Coroutine task2, current Thread is ${Thread.currentThread().name}")
"task2" //返回结果赋值给task2
}
Log.e(TAG, "task1 result is $task1 , task2 result is $task2, current Thread is ${Thread.currentThread().name}")
}
Log.e(TAG, "run in Main Thread, main Thread is ${Thread.currentThread().name}")
...
打印结果:
2020-10-23 16:48:35.920 8444-8444/com.example.rxhttpdemo E/MainActivity: start
2020-10-23 16:48:35.926 8444-8444/com.example.rxhttpdemo E/MainActivity: run in Main Thread, main Thread is main
2020-10-23 16:48:37.931 8444-8463/com.example.rxhttpdemo E/MainActivity: run in Coroutine task1, current Thread is DefaultDispatcher-worker-3
2020-10-23 16:48:38.933 8444-8461/com.example.rxhttpdemo E/MainActivity: run in Coroutine task2, current Thread is DefaultDispatcher-worker-2
2020-10-23 16:48:38.933 8444-8461/com.example.rxhttpdemo E/MainActivity: task1 result is task1 , task2 result is task2, current Thread is DefaultDispatcher-worker-2
从打印结果的时间可以看到是串行阻塞式执行,并返回结果。
挂起函数
挂起函数是实现协程机制的基础,Kotlin中通过suspend关键字声明挂起函数,挂起函数只能在协程中执行,或者在别的挂起函数中执行。前面用到的delay()和await()都是是一个挂起函数,挂起函数会挂起当前协程。协程会等待挂起函数执行完毕再继续执行其余任务。
...
GlobalScope.launch(Dispatchers.Main) {
Log.e(TAG, "do job start")
delay(2000)
Log.e(TAG, "do job end")
}
...
打印结果:
2020-10-23 16:57:06.541 8904-8904/com.example.rxhttpdemo E/MainActivity: do job start
2020-10-23 16:57:08.543 8904-8904/com.example.rxhttpdemo E/MainActivity: do job end
取消和超时
协程是可以被取消的,而且嵌套的协程,取消父协程,子协程也会自动取消,可以通过cancel()或者cancelAndJoin()函数来实现协程的取消。但是只有协程代码是可取消的,cancel()和cancelAndJoin()函数才能起作用。
取消单个协程
...
runBlocking {
val job = GlobalScope.launch {
repeat(20) {
Log.e(TAG,"job working : $it")
delay(500)
}
}
delay(2000)
job.cancelAndJoin()
Log.e(TAG,"ending")
}
...
打印结果:
2020-10-26 16:24:16.398 11796-11817/com.example.rxhttpdemo E/MainActivity: job working : 0
2020-10-26 16:24:16.899 11796-11817/com.example.rxhttpdemo E/MainActivity: job working : 1
2020-10-26 16:24:17.401 11796-11817/com.example.rxhttpdemo E/MainActivity: job working : 2
2020-10-26 16:24:17.901 11796-11817/com.example.rxhttpdemo E/MainActivity: job working : 3
2020-10-26 16:24:18.399 11796-11796/com.example.rxhttpdemo E/MainActivity: ending
可以看到协程被取消,后续没有继续打印。
然后我们把代码稍微改动下
...
runBlocking {
val job = GlobalScope.launch {
var timeFlag = System.currentTimeMillis()
var i = 0
while (i < 6) {
if (System.currentTimeMillis() >= timeFlag) {
Log.e(TAG,"job working: ${i++}")
timeFlag += 500
}
}
}
delay(1000)
Log.e(TAG,"cancelAndJoin")
job.cancelAndJoin()
Log.e(TAG,"ending")
}
...
打印结果:
2020-10-26 16:30:06.167 12778-12795/com.example.rxhttpdemo E/MainActivity: job working: 0
2020-10-26 16:30:06.667 12778-12795/com.example.rxhttpdemo E/MainActivity: job working: 1
2020-10-26 16:30:07.167 12778-12795/com.example.rxhttpdemo E/MainActivity: job working: 2
2020-10-26 16:30:07.168 12778-12778/com.example.rxhttpdemo E/MainActivity: cancelAndJoin
2020-10-26 16:30:07.667 12778-12795/com.example.rxhttpdemo E/MainActivity: job working: 3
2020-10-26 16:30:08.167 12778-12795/com.example.rxhttpdemo E/MainActivity: job working: 4
2020-10-26 16:30:08.667 12778-12795/com.example.rxhttpdemo E/MainActivity: job working: 5
2020-10-26 16:30:08.667 12778-12778/com.example.rxhttpdemo E/MainActivity: ending
结果似乎并没有像我们预期的那样停止协程,那这是为什么了?现在,我们再回过头来,理解只有协程代码是可取消的,cancel()才能起作用。那也就是说,这个示例中的launch协程的代码是不可取消的。那么什么样的代码才可以视为可取消的呢?
- kotlinx.coroutines包下的所有挂起函数都是可取消的。这些挂起函数会检查协程的取消状态,当取消时就会抛出CancellationException异常,从而终止协程。
- 如果协程正在处于某个计算过程当中,并且设置检查取消状态,那么它就是能被取消的,反之不能(上面的例子)
我们在改动下,设置检查取消状态
runBlocking {
val job = GlobalScope.launch {
var timeFlag = System.currentTimeMillis()
var i = 0
while (isActive) {//设置检查取消状态
if (System.currentTimeMillis() >= timeFlag) {
Log.e(TAG,"job working: ${i++}")
timeFlag += 500
}
}
}
delay(1000)
job.cancelAndJoin()
Log.e(TAG,"ending")
}
打印结果:
2020-10-26 16:37:58.634 13669-13686/com.example.rxhttpdemo E/MainActivity: job working: 0
2020-10-26 16:37:59.134 13669-13686/com.example.rxhttpdemo E/MainActivity: job working: 1
2020-10-26 16:37:59.634 13669-13669/com.example.rxhttpdemo E/MainActivity: ending
可以看到协程已经按照我们预期停止。
上面还提到取消协程时是通过抛出CancellationException异常来取消协程,那么我们来验证下。
runBlocking {
val job = GlobalScope.launch {
try {
repeat(20) {
Log.e(TAG, "job working : $it")
delay(500)
}
} catch (e: CancellationException) {
Log.e(TAG, "coroutine is canceled by CancellationException")
} finally {
Log.e(TAG, "finally块 release resource")
}
}
delay(2000)
job.cancelAndJoin()
Log.e(TAG, "ending")
}
打印结果:
2020-10-26 16:47:47.533 14247-14272/com.example.rxhttpdemo E/MainActivity: job working : 0
2020-10-26 16:47:48.034 14247-14271/com.example.rxhttpdemo E/MainActivity: job working : 1
2020-10-26 16:47:48.534 14247-14271/com.example.rxhttpdemo E/MainActivity: job working : 2
2020-10-26 16:47:49.036 14247-14271/com.example.rxhttpdemo E/MainActivity: job working : 3
2020-10-26 16:47:49.533 14247-14271/com.example.rxhttpdemo E/MainActivity: coroutine is canceled by CancellationException
2020-10-26 16:47:49.533 14247-14271/com.example.rxhttpdemo E/MainActivity: finally块 release resource
2020-10-26 16:47:49.533 14247-14247/com.example.rxhttpdemo E/MainActivity: ending
的确捕获到了CancellationException异常,既然当协程处于取消状态时,对于挂起函数(delay)的调用,会导致该异常的抛出,那么我们没有捕获异常的时候为什么没有在输出终端见到它的身影呢?因为kotlin的协程是这样规定的:
That is because inside a cancelled coroutine CancellationException is considered to be a normal reason for coroutine completion.
也就是说,CancellationException这个异常是被视为正常现象的取消。
嵌套协程取消
协程嵌套时,取消父协程然后所有子协程也会自动取消,但是取消的规则还是上面提到的:只有协程代码是可取消的,cancel()才能起作用。
...
runBlocking {
val parentJob = launch {
launch {
Log.e(TAG,"child Job start delay")
delay(2000)
Log.e(TAG,"child Job end delay")
}
Log.e(TAG,"parent Job start delay")
delay(1000)
Log.e(TAG,"parent Job end delay")
}
delay(1500)
parentJob.cancelAndJoin()
Log.e(TAG,"ending")
}
...
打印结果:
2020-10-26 16:54:10.756 14862-14862/com.example.rxhttpdemo E/MainActivity: parent Job start delay
2020-10-26 16:54:10.756 14862-14862/com.example.rxhttpdemo E/MainActivity: child Job start delay
2020-10-26 16:54:11.756 14862-14862/com.example.rxhttpdemo E/MainActivity: parent Job end delay
2020-10-26 16:54:12.258 14862-14862/com.example.rxhttpdemo E/MainActivity: ending
取消父协程,子协程也被取消,后续打印没有被执行。
另外子协程可以通过抛异常的方式来终止父协程,但是这种方式属于非正常方式取消协程,如果在Android平台会导致应用崩溃。而子协程通过调用cancel则对父协程没有任何影响。
超时
如果用于执行某个任务的协程,我们设定,如果它超过某个时间后,还未完成,那么我们就需要取消该协程。我们可以使用withTimeout或者withTimeoutOrNull轻松实现这一功能。而使用withTimeout函数如果超时了会抛出TimeoutCancellationException异常,我们可以捕获这个异常来判断超时,然后执行超时逻辑。使用withTimeoutOrNull则会返回null,不会抛出异常。
...
runBlocking {
try {
val result = withTimeout(1000) {
repeat(3) {
Log.e(TAG,"job working : $it")
delay(500)
}
"ending"
}
Log.e(TAG,"result is $result")
}catch (e: TimeoutCancellationException){
Log.e(TAG,"coroutine is canceled")
//do timeout logic
}
}
...
打印结果:
2020-10-26 17:03:16.430 15722-15722/com.example.rxhttpdemo E/MainActivity: job working : 0
2020-10-26 17:03:16.931 15722-15722/com.example.rxhttpdemo E/MainActivity: job working : 1
2020-10-26 17:03:17.432 15722-15722/com.example.rxhttpdemo E/MainActivity: coroutine is canceled
使用withTimeoutOrNull处理超时
...
runBlocking {
val result = withTimeoutOrNull(1000) {
repeat(3) {
Log.e(TAG,"job working : $it")
delay(500)
}
"ending"
}
Log.e(TAG,"result is $result")
}
...
打印结果:
2020-10-26 17:05:18.701 15895-15895/com.example.rxhttpdemo E/MainActivity: job working : 0
2020-10-26 17:05:19.201 15895-15895/com.example.rxhttpdemo E/MainActivity: job working : 1
2020-10-26 17:05:19.702 15895-15895/com.example.rxhttpdemo E/MainActivity: result is null
应用
下面我们就通过一个简单模拟网络请求例子来应用下协程使用。
class MainActivity : AppCompatActivity() {
private lateinit var job : Job
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//官方推荐的协程创建方式
val scope = CoroutineScope(Dispatchers.Main + Job())
job = scope.launch {
Log.e(TAG,"CoroutineScope start...")
val result = doNetworkJob()
Log.e(TAG,"result is : $result , current Thread is : ${Thread.currentThread().name}")
tv.text = result
}
Log.e(TAG,"onCreate end")
}
//模拟网络请求
private suspend fun doNetworkJob() : String{
Log.e(TAG,"do network start...")
return withContext(Dispatchers.IO){
repeat(10){
delay(500)
Log.e(TAG,"do network working $it")
}
"result from network"
}
}
override fun onStop() {
super.onStop()
job.cancel()
}
companion object {
const val TAG: String = "MainActivity"
}
}
打印结果:
2020-10-27 14:59:28.880 5208-5208/com.example.rxhttpdemo E/MainActivity: onCreate end
2020-10-27 14:59:28.898 5208-5208/com.example.rxhttpdemo E/MainActivity: CoroutineScope start...
2020-10-27 14:59:28.898 5208-5208/com.example.rxhttpdemo E/MainActivity: do network start...
2020-10-27 14:59:29.404 5208-5228/com.example.rxhttpdemo E/MainActivity: do network working 0
2020-10-27 14:59:29.905 5208-5227/com.example.rxhttpdemo E/MainActivity: do network working 1
2020-10-27 14:59:30.406 5208-5228/com.example.rxhttpdemo E/MainActivity: do network working 2
2020-10-27 14:59:30.907 5208-5227/com.example.rxhttpdemo E/MainActivity: do network working 3
2020-10-27 14:59:31.408 5208-5228/com.example.rxhttpdemo E/MainActivity: do network working 4
2020-10-27 14:59:31.909 5208-5227/com.example.rxhttpdemo E/MainActivity: do network working 5
2020-10-27 14:59:32.409 5208-5228/com.example.rxhttpdemo E/MainActivity: do network working 6
2020-10-27 14:59:32.910 5208-5227/com.example.rxhttpdemo E/MainActivity: do network working 7
2020-10-27 14:59:33.411 5208-5228/com.example.rxhttpdemo E/MainActivity: do network working 8
2020-10-27 14:59:33.913 5208-5227/com.example.rxhttpdemo E/MainActivity: do network working 9
2020-10-27 14:59:33.914 5208-5208/com.example.rxhttpdemo E/MainActivity: result is : result from network , current Thread is : main
通过挂起函数doNetworkJob来模拟网络请求,然后在主线程中启动一个协程,然后在协程中调用doNetworkJob方法,然后接受结果之后直接显示到UI,而且也并没有堵塞主线程。
总结
对于上面提到的为什么要使用协程有三个优点:
- 轻量,高效
- 便于管理
- 能用同步的编码方式编写异步代码
轻量,高效:你可以轻松开启成千上百的协程,而不会有太多的性能问题,但是线程显然不行,特别是在移动端的设备。
便于管理:协程是可取消的,而且取消父协程子协程也会自动取消,而线程创建启动之后显然无法这么方便的进行管理。
能用同步的编码方式编写异步代码:Android中网络请求之后一般我们都是通过回调函数将结果从异步线程中进行回调到UI线程然后显示到UI上,但是从上面的例子我们能直接获取到请求结果展示到UI。
尾巴
今天的学习和总结就这么多了,如果文章中有什么错误,欢迎指正。喜欢我的文章,欢迎一键三连:点赞,评论,关注,谢谢大家!