协程Job的取消,你真的用对了吗?

2,899 阅读5分钟

前言

我们知道,调用协程的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方法前取消了,后面的代码也不再执行。

虽然说协程任务的错误取消,通常情况下也不会导致逻辑出错或者业务异常,但还是会造成一些后台资源的浪费或者内存泄漏问题。而且也由于没有太大影响,很多时候也难以被发现,像是代码刺客一样的东西在危害着项目。

如何取消协程

  1. 既然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

  1. 在代码执行语句中有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 <anonymous constructor>(completion);
    return funcation;
}

public final Object invoke(Object object) {
    return ((<undefinedtype>)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()方法只能取消自身和子协程,不会影响父协程,也不会引起程序崩溃。