Kotlin协程泄漏场景分析

1,100 阅读3分钟

前言

相信各位Android开发同学们对于协程的使用已经比较熟悉了。大多数情况下我们会使用viewModelScope或者lifecycleScope来开启协程。
因为viewModelScope和lifecycleScope分别绑定了ViewModel和LifecycleOwnner的生命周期。 通过使用它们可以在ViewModel或者LifecycleOwner生命周期结束时,主动取消协程执行,从而防止协程泄漏。

但是通过使用viewModelScope和lifecycleScope真的解决所有的协程的泄漏问题吗?在实际开发中为了防止协程泄漏又有哪些手段,接下来从一个案例出发,开始今天的学习。

案例一

fun testLongTask() {
    viewModelScope.launch(Dispatchers.IO) {
        var i = 0L
        while (true) {
            i++
            if (i % 100000000L == 0L) {
                Log.i("TAG", "i=$i   ThreadName=${Thread.currentThread().name}  ${isActive}")
            }
        }
    }
}

当ViewModel生命周期结束时,ViewModel会调用clear()方法,进而调用到 CloseableCoroutineScope对象的close方法,从而取消viewModelScope代码块的执行。
按照上述的代码分析,当ViewModel销毁时,CPU将不再执行该代码块的内容。
代码运行结果:

image.png 很明显,当ViewModel销毁时,协程状态isActive为false,该代码块仍然在执行,也就是说上述代码发生了泄漏,依然在占据CPU资源。

不过我们仔细分析下代码,就知道为何会出现上面的运行结果了。

首先上面是运行在IO线程上的代码块,因为不涉及挂起函数,所以会一直在该IO线程执行。又因为是死循环,所以会一直占着IO线程资源不放,从而导致该代码块无法停止。
所以针对上述泄漏有个简单的解决方法,就是可以使用isActive作为是否继续执行的条件,如while(isActive), 这样就能在协程被取消时,结束相应代码块的执行了。

案例二

fun testLongTask() {
    viewModelScope.launch(Dispatchers.IO) {
        var i = 0L
        while (true) {
            i++
            if (i % 100000000L == 0L) {
                Log.i("TAG", "i=$i   ThreadName=${Thread.currentThread().name}  ${isActive}")
                delay(1)  //相比案例一,多了这句代码,是个挂起函数,表示延时1ms
            }
        }
    }
}

代码执行结果:

image.png

很神奇吧,加完这句delay(1),就能够退出循环,这是怎么做到的呢?根据以往的开发经验,如果有未捕获的异常抛出,会导致线程崩溃退出运行。
所以有可能是delay这个挂起方法抛出异常导致死循环的退出。
接下来把delay方法try catch一下, 运行结果如下:

image.png 果然跟我猜想的一样,delay会协程被cancel时,调用会抛出JobCancellationException,从而退出线程的执行。当该异常被捕获,则代码块则会不停地执行下去。
接下来看下delay的异常是在哪里抛出来的

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        // if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
        //代码块0
        if (timeMillis < Long.MAX_VALUE) {
            //去执行延时任务
            cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
        }
    }
}

public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
        /*
         * For non-atomic cancellation we setup parent-child relationship immediately
         * in case when `block` blocks the current thread (e.g. Rx2 with trampoline scheduler), but
         * properly supports cancellation.
         */
        cancellable.initCancellability()
        block(cancellable)   //传入cancellable,调用代码块0
        cancellable.getResult()   //代码1
    }
    

// CancellableContinuationImpl.kt
internal fun getResult(): Any? {
    //省略无关代码
if (resumeMode.isCancellableMode) {
    val job = context[Job]
    if (job != null && !job.isActive) {
        val cause = job.getCancellationException()
        cancelCompletedResult(state, cause)
        throw recoverStackTrace(cause, this) //抛异常
    }
}
return getSuccessfulResult(state)
    
}

很明显,delay使用到了suspendCancellableCoroutine这个函数,在该函数中会执行到代码1的位置,在该位置的getResult()函数中,发现当协程处于取消状态时,则会抛异常。

suspendCancellableCoroutine

suspendCancellableCoroutine可以将回调函数转化为协程,而且其会暴露一个CancellableContinuation对象,拿到continuation对象, 就可以用 resume、resumeWithException 来处理回调 和抛出 CancellationException 异常。
而且suspendCancellableCoroutine还支持取消回调
以下是suspendCancellableCoroutine在Retrofit框架的经典使用场景

suspend fun <T : Any> Call<T>.await(): T {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()  //当协程代表块被取消时,会收到回调,从而取消okhttp请求
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
          val body = response.body()
          if (body == null) {
            val invocation = call.request().tag(Invocation::class.java)!!
            val method = invocation.method()
            val e = KotlinNullPointerException("Response from " +
                method.declaringClass.name +
                '.' +
                method.name +
                " was null but response body type was declared as non-null")
            continuation.resumeWithException(e)
          } else {
            continuation.resume(body)
          }
        } else {
          continuation.resumeWithException(HttpException(response))
        }
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

通过上面的例子可以看出,suspendCancellableCoroutine很适合用来支持取消操作的业务逻辑场景。值得注意的是当在使用它时,记得调用invokeOnCancellation进行监听,在协程cancel时,进行相应的资源清理,否则有可能发生泄漏。

小结

本文先通过案例一来分析Kotlin协程可能泄漏的场景以及针对该场景的解决方案。接着通过案例二分析引出了suspendCancellableCoroutine这个函数,并通过Retrofit的源码例子演示了它的使用场景。通过本文的分析,我们知道协程的取消是协作型的,并不是我们相应的scope.cancel就能取消所有的操作。所以我们在协程做一些耗时任务时,进行取消监听,尽早地释放资源,防止造成相应的泄漏,从而提高代码健壮性。

您若喜欢,请点赞、关注,您的鼓励是我前进的动力