跳至主要內容

使用Kotlin协程的崩溃纪实(二)

guodongAndroid大约 6 分钟

使用Kotlin协程的崩溃纪实(二)

前言

由于笔者对Kotlin协程缺乏深刻的理解以及充分的实践,导致在实际工作中使用Kotlin协程时遇到一些崩溃的问题。

那么本文主要记录遇到的这些崩溃问题,这是其中之二。

背景

在Kotlin协程中如果使用下标访问集合数据,那么可能会抛出IndexOutOfBoundsException异常导致崩溃。

本文记录的崩溃情况与第一篇中的属于同一种类型,只是触发的代码逻辑和业务逻辑略有不同,所以本文的风格与第一篇雷同。

伪代码

下面是符合背景中描述的伪代码:

fun main() {
    val ml = mutableListOf<Int>()

    for (i in 0..10) {
        ml.add(i)
    }
    println(ml)

    val run = RunSuspendKt<Int>()

    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        // 协程A
        try	{
            val indexOf = ml.indexOfFirst { it == 10 }
            log("Element(10) indexOf: $indexOf")

            suspendWork(1_000L)
            val value = ml[indexOf]
            log("IndexOf($indexOf) element: $value")

            run.resumeWith(1)
        } catch (e: Exception) {
            e.printStackTrace()
            run.resumeWith(-1)
        }
    }

    val await = RunSuspendKt<Boolean>()
    await.await(500L)

    scope.launch {
        // 协程B
        log("Remove element(10): ${ml.remove(10)}")
    }

    val code = run.await()
    log("Finish with [$code]")
}

private suspend fun suspendWork(timeout: Long) = suspendCoroutine<Boolean> { continuation ->
    thread {
        Thread.sleep(timeout)
        continuation.resume(Random.nextBoolean())
    }
}

private val DF = SimpleDateFormat("HH:mm:ss.SSS")
private fun log(any: Any) {
    println("[${DF.format(Date(System.currentTimeMillis()))} ${Thread.currentThread().name}]: $any")
}

上面的伪代码大致的逻辑如下:

  1. 创建一个可变列表ml并添加**[0, 10]**这11条数据,

  2. 创建一个RunSuspend的实例run,用于主线程等待协程执行完毕,否则主线程退出,协程无法执行完毕,

  3. 创建一个协程作用域scope,启动一个协程A

    1. 在协程中首先获取Element(10)的下标(indexOf),
    2. 然后调用suspendWork挂起函数模拟执行耗时任务,
    3. suspendWork挂起函数执行完毕后,再通过第一步获取的下标(indexOf)获取对应的Element
    4. 协程体执行完毕后或抛出异常时调用run.resumeWith通知主线程协程执行完毕并停止阻塞主线程,
  4. 再创建一个RunSuspend的实例await,用于阻塞主线程500毫秒,然后启动一个新的协程B移除ml可变列表中的Element(10)

  5. 调用run.await阻塞主线程并等待协程A执行完毕。

伪代码执行的期望结果如下:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[22:00:37.575 DefaultDispatcher-worker-1]: Element(10) indexOf: 10
[22:00:38.080 DefaultDispatcher-worker-1]: Remove element(10): true
[22:00:38.581 DefaultDispatcher-worker-1]: IndexOf(10) element: 10
[22:00:38.581 main]: Finish with [1]

笔者期望伪代码可以正常的执行完毕。

但是,实际上伪代码执行的结果如下:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[22:01:37.814 DefaultDispatcher-worker-1]: Element(10) indexOf: 10
[22:01:38.330 DefaultDispatcher-worker-1]: Remove element(10): true
java.lang.IndexOutOfBoundsException: Index: 10, Size: 10
	at java.util.ArrayList.rangeCheck(ArrayList.java:657)
	at java.util.ArrayList.get(ArrayList.java:433)
	at CoroutinesCrashKt$main$1.invokeSuspend(CoroutinesCrash.kt:31)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
[22:01:38.830 main]: Finish with [-1]

执行结果分析:

  1. 协程A中仅输出了第一步获取到Element(10)下标(indexOf)的日志,
  2. await实例阻塞主线程500毫秒后,我们在启动的协程B中移除ml可变列表中的Element(10),并输出日志移除成功:true
  3. 回到协程A,等到协程体中的suspendWork挂起函数执行完毕后,想通过下标(indexOf)获取对应的Element时,抛出异常,

分析

为什么执行结果会与我们期望的结果不同呢?接下来我们分析下原因。

首先我们再仔细观察下协程A中的代码:

scope.launch {
    try {
        val indexOf = ml.indexOfFirst { it == 10 }
        log("Element(10) indexOf: $indexOf")

        suspendWork(1_000L)
        val value = ml[indexOf]
        log("IndexOf($indexOf) element: $value")

        run.resumeWith(1)
    } catch (e: Exception) {
        e.printStackTrace()
        run.resumeWith(-1)
    }
}

按照笔者的想法,协程A中协程体的执行逻辑如下:

  1. 协程体是在同一个线程中执行,
  2. 协程体是同步且顺序执行的,

可以简单理解协程体的执行是线程安全的。

真的线程安全么?

针对第一点,目前的scope没有指定运行的线程,那么协程默认是在Default线程池中执行,同时循环体的每次执行都会触发两次线程切换:

  1. 其一是从launch协程体执行的线程切换至suspendWork挂起函数执行的线程,
  2. 其二是suspendWork挂起函数执行完毕后,从suspendWork挂断函数执行线程切换回launch协程体执行的线程,

launch协程体执行的线程由Default线程池分配,线程池分配线程存在不固定性,所以协程体在同一个线程中执行不能成立,自然不能称为是线程安全的。

为什么会有这样的想法呢?

正如前言中所说,笔者对协程缺乏深刻的理解以及充分的实践,而协程的一大特点是:使用「同步代码」写出异步程序。

其实笔者就是被「同步代码」这一表象所“欺骗”,这也是笔者对协程缺乏深刻理解的佐证。

使用「同步代码」写出异步程序,对程序猿来说这是多么美好的事情,但是如果对协程理解的不够深入,不清楚它背后的逻辑,那么很容易就像笔者一样被它简单的表象所“欺骗”。

针对第二点,协程的特点是使用「同步代码」写出异步程序,在协程体中调用了挂起函数,那么协程体中的逻辑必然是异步程序,所以第二点也不成立。

异常原因

本文记录的崩溃原因比较好分析:协程A第一步中获取的下标(idnexOf),在经过协程B移除Element(10)之后就变成了一个过期的下标(indexOf),等到协程AsuspendWork挂起函数执行完毕,想通过过期的下标(indexOf)获取对应的Element时,必然会抛出IndexOutOfBoundsException异常。

如何修复?

修复方案有多种,比如:

  1. 协程A第一步获取到下标(indexOf)之后,suspendWork挂起函数之前,通过第一步获取到的下标(indexOf)获取对应的Element
  2. 协程A中的suspendWork挂起函数执行完毕后,需判断下标(indexOf)是否过期,
  3. 使用加锁的方式,协程A操作ml集合时不允许其他协程对ml集合进行更新,
  4. 其他方式,

具体选择哪种修复方案,这里可以根据业务场景的不同而选择不同的修复方案。

总结

本文记录了笔者在使用Kotlin协程过程中遇到的下标过期导致的崩溃问题,通过伪代码笔者复现了崩溃问题,并分析了问题产生的原因以及给出一些修复方案供选择参考。

本文与第一篇中记录的情况,笔者都是被协程特点:使用「同步代码」写出异步程序所"欺骗",所以在协程体中调用其他的挂起函数时,挂起函数的前后代码最好不要有关联性,最好保持独立性,否则可能会发生意想不到的情况,归根结底还是笔者对协程缺乏深刻的理解以及充分的实践。

其中最重要的是发现自身的不足,发现自身的不足也是一种进步。

纸上得来终觉浅,绝知此事要躬行。