协程Job的取消,你真的用对了吗?
前言
我们知道,调用协程的lifecycleScope的launch方法后会生成一个Job对象,Job可以调用cancel()方法来取消,也可以由lifecycle宿主在生命周期结束时自行取消。但job取消后,并不代表其后面的代码都不执行了,在老油条同事的代码里也发现了同样的问题,cancel后并没有真正停掉后台的任务。
结论
先说结论,协程Job的cancel()方法并不会立即中断后续代码的执行,只是将任务状态isActive改为false。只有当执行下一个可取消的suspend方法时,才会抛出一个CancellationException,停掉后面的代码。 这意味着,如果一个Job在任务过程中不存在一个可取消suspend方法的调用,那么直到任务结束都不会停止,即使是调用了cancel()方法。
fun jobTest() {
runBlocking {
val job1 = launch(Dispatchers.IO) {
Log.d(TAG, "job1 start")
Thread.sleep(2_000)
Log.d(TAG, "job1 finish")
}
val job2 = launch {
Log.d(TAG, "job2 start")
delay(2_000)
Log.d(TAG, "job2 finish")
}
delay(1000)
job1.cancel()
job2.cancel()
}
}
2024-06-10 23:05:37.407 21238-21272 JobTest D job1 start
2024-06-10 23:05:37.407 21238-21327 JobTest D job2 start
2024-06-10 23:05:39.407 21238-21272 JobTest D job1 finish
如上述示例中,job1跟job2都调用了cancel()方法取消,但由于job1任务内没有suspend方法,job1在cancel后依然执行完了代码;而job2在第二个delay方法前取消了,后面的代码也不再执行。
虽然说协程任务的错误取消,通常情况下也不会导致逻辑出错或者业务异常,但还是会造成一些后台资源的浪费或者内存泄漏问题。而且也由于没有太大影响,很多时候也难以被发现,像是代码刺客一样的东西在危害着项目。
如何取消协程
- 既然job取消后会改变任务状态,可以在代码语句中根据isActive状态决定是否继续执行
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (isActive) {
//..
}
Log.d(TAG, "job finish")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-10 23:54:46.430 4094-4353 JobTest D job start
2024-06-10 23:54:47.434 4094-4330 JobTest D job cancel
2024-06-10 23:54:47.434 4094-4353 JobTest D job finish
- 在代码执行语句中有suspend修饰的挂起方法,在协程取消后执行到suspend方法会抛出异常,从而停止协程job
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (true) {
delay(1)
}
Log.d(TAG, "job finish")
}
job.invokeOnCompletion {
Log.d(TAG, "invokeOnCompletion:$it")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-10 23:59:22.531 10172-10371 JobTest D job start
2024-06-10 23:59:23.536 10172-10270 JobTest D job cancel
2024-06-10 23:59:23.539 10172-10380 JobTest D invokeOnCompletion:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@3df8870
可以看到任务抛出了JobCancellationException,并且不会执行到job finish语句。
两种任务停止方式的区别在于,第二种方式因为delay()这个suspend方法抛出了异常而终止执行,第一种由于没有遇到suspend方法并不会抛出异常,可以执行到结束。
那么只要是suspend方法就一定能停止协程吗?
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
while (true) {
emptySuspend()
}
Log.d(TAG, "job finish")
}
job.invokeOnCompletion {
Log.d(TAG, "invokeOnCompletion:$it")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
private suspend fun emptySuspend() {
return suspendCoroutine {
it.resume(Unit)
}
}
2024-06-11 00:04:45.144 14010-14234 JobTest D job start
2024-06-11 00:04:46.151 14010-14241 JobTest D job cancel
运行后等待数秒,发现并不会抛出异常。明明一直在调用suspend方法,任务取消后却不会响应。
事实上,普通suspend方法并不会处理cancel标志,只有suspendCancelable类型方法会在执行前判断cancel状态并抛出异常。而常见的delay、emit方法都是suspendCancelable类型。
将emptySuspend()方法做一个修改如下
private suspend fun emptySuspend() {
return suspendCancellableCoroutine {
it.resume(Unit)
}
}
运行后发现任务可以被cancel()掉而停止
2024-06-11 00:09:11.169 17728-17872 JobTest D job start
2024-06-11 00:09:12.174 17728-17865 JobTest D job cancel
2024-06-11 00:09:12.177 17728-17872 JobTest D invokeOnCompletion:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelled}@7cc1e91
协程取消原理
再简单从协程的实现原理解释一下为什么协程Job要在执行suspend方法时才能中断。
挂起方法
用suspend修饰的方法称为挂起方法,需要在协程作用域才能调用。
suspend fun delaySuspend() {
Log.d(TAG, "start delay: ")
delay(100)
Log.d(TAG, "delay end")
}
挂起方法会编译成Switch状态机模式,每个挂起方法都是其中一个case,每个case执行都依赖前面的case,这就是协程切换与挂起停止的原理。协程本质上是产生了一个 switch 语句,每个挂起点之间的逻辑都是一个 case 分支的逻辑。 参考 协程是如何实现的 中的例子:
Function1 lambda = (Function1)(new Function1((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
byte text;
@BlockTag1: {
Object result;
@BlockTag2: {
result = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
this.label = 1;
if (SuspendTestKt.dummy(this) == result) {
return result;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
case 2:
ResultKt.throwOnFailure($result);
break @BlockTag2;
case 3:
ResultKt.throwOnFailure($result);
break @BlockTag1;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
text = 1;
System.out.println(text);
this.label = 2;
if (SuspendTestKt.dummy(this) == result) {
return result;
}
}
text = 2;
System.out.println(text);
this.label = 3;
if (SuspendTestKt.dummy(this) == result) {
return result;
}
}
text = 3;
System.out.println(text);
return Unit.INSTANCE;
}
@NotNull
public final Continuation create(@NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function1 funcation = new constructor>(completion);
return funcation;
}
public final Object invoke(Object object) {
return (()this.create((Continuation)object)).invokeSuspend(Unit.INSTANCE);
}
});
任务取消
任务取消后,对于suspendCancelable方法的分支,会因为取消的状态而抛出JobCancellationException,停止后续代码的执行。如果在job中对于异常进行捕获,将可能导致任务取消失败。
lifecycleScope.launch(Dispatchers.IO) {
val job = launch {
Log.d(TAG, "job start")
kotlin.runCatching {
while (true) {
emptySuspend()
}
}.onFailure {
Log.e(TAG, "catch: $it")
}
Log.d(TAG, "job finish")
}
delay(1000)
job.cancel()
Log.d(TAG, "job cancel")
}
2024-06-11 00:22:22.686 25890-26199 JobTest D job start
2024-06-11 00:22:23.690 25890-26217 JobTest D job cancel
2024-06-11 00:22:23.696 25890-26199 JobTest E catch: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@e557022
2024-06-11 00:22:23.696 25890-26199 JobTest D job finish
由于捕获了JobCancellationException,导致job finish语句正常执行了。在一些情况下,可能会由于JobCancellationException被捕获导致任务没有及时取消。因此在job内捕获异常时,选择性的过滤掉JobCancellationException,将异常再度抛出
kotlin.runCatching {
// ...
}.onFailure {
if (it is CancellationException) {
throw it
}
}
协程异常处理
协程遇到无法处理的异常后,会按照停止自身子任务-停止自身任务-停止父任务的顺序依次停掉任务,并将异常抛给父作用域。当所有作用域都无法处理异常,会抛给unCautchExceptionHandler。如果异常一直没被处理,则可能引起崩溃。
值得一提的是,由Job.cancel()方法引起的CancellationException并不会传给父Job,在cancelParent之前会被过滤掉,也就是cancel()方法只能取消自身和子协程,不会影响父协程,也不会引起程序崩溃。
来源:juejin.cn/post/7378363694939635722