前语

由于笔者对Kotlin协程缺少深刻的了解以及充沛的实践,导致在实际工作中运用Kotlin协程时遇到一些溃散的问题。

那么本文首要记载遇到的这些溃散问题,这是其一。

布景

在Kotlin协程中假如不能合理的运用for-in循环,可能会抛出ConcurrentModificationException反常导致溃散。

伪代码

下面是契合布景中描述的伪代码:

fun main() {
  val ml = mutableListOf<Int>()
​
  for (i in 0..10) {
    ml.add(i)
   }
  println(ml)
​
  val run = RunSuspend<Int>()
​
  val scope = CoroutineScope(EmptyCoroutineContext)
  scope.launch {
    // 协程A
        try {
      for (i in ml) {
             log("${suspendWork(500L)}: $i")
            }
            run.resumeWith(1)
     } catch (e: Exception) {
      e.printStackTrace()
      run.resumeWith(-1)
     }
   }
​
  val await = RunSuspend<Boolean>()
    await.await(1_000L)
  
  scope.launch {
    // 协程B
    log("Add 11: ${ml.add(11)}")
   }
​
  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,在协程中履行for-in循环,在循环中调用挂起函数suspendWork模仿履行耗时使命,其每500ms输出一条日志,循环履行结束后或抛出反常时调用run.resumeWith告诉协程履行结束并中止堵塞主线程,
  4. 再创立一个RunSuspend的实例await,用于堵塞主线程1000ms,然后发动一个新的协程B为可变列表ml新增一条数据,
  5. 调用run.await堵塞主线程并等待协程A履行结束。

伪代码履行的期望成果如下:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[22:10:39.588 DefaultDispatcher-worker-1]: true: 0
[22:10:40.085 DefaultDispatcher-worker-1]: Add 11: true
[22:10:40.095 DefaultDispatcher-worker-1]: false: 1
[22:10:40.598 DefaultDispatcher-worker-1]: true: 2
[22:10:41.104 DefaultDispatcher-worker-1]: true: 3
[22:10:41.610 DefaultDispatcher-worker-1]: true: 4
[22:10:42.114 DefaultDispatcher-worker-1]: false: 5
[22:10:42.619 DefaultDispatcher-worker-1]: false: 6
[22:10:43.122 DefaultDispatcher-worker-1]: false: 7
[22:10:43.628 DefaultDispatcher-worker-1]: false: 8
[22:10:44.132 DefaultDispatcher-worker-1]: true: 9
[22:10:44.637 DefaultDispatcher-worker-1]: false: 10
[22:10:44.638 main]: Finish with [1]

笔者期望伪代码能够正常的履行结束。

但是,实际上伪代码履行的成果如下:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[22:11:34.338 DefaultDispatcher-worker-1]: false: 0
[22:11:34.836 DefaultDispatcher-worker-1]: Add 11: true
[22:11:34.839 DefaultDispatcher-worker-1]: true: 1
[22:11:34.840 main]: Finish with [-1]
java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    at java.util.ArrayList$Itr.next(ArrayList.java:851)
    at CoroutinesKt.case1(Coroutines.kt:56)
    at CoroutinesKt.access$case1(Coroutines.kt:1)
    at CoroutinesKt$case1$1.invokeSuspend(Coroutines.kt)
    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)

履行成果剖析:

  1. 协程A中的for-in循环输出了2条日志,每次输出日志时,其实已经是每次遍历履行500ms之后了,
  2. 堵塞主线程1000ms后,我们在发动的协程B中为可变列表ml新增了一条数据:11,
  3. 在第三次遍历时协程A中抛出了一个反常:java.util.ConcurrentModificationException

剖析

为什么履行成果会与我们期望的成果不同呢?接下来我们剖析下原因。

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

scope.launch {
  try {
    for (i in ml) {
      log("${suspendWork(500L)}: $i")
     }
    run.resumeWith(1)
   } catch (e: Exception) {
    e.printStackTrace()
    run.resumeWith(-1)
   }
}

依照笔者的想法,for-in循环的履行逻辑如下:

  1. 循环体是在同一个线程中履行,
  2. 循环体是同步且次序履行的,

能够简略了解循环体的履行是线程安全的。

真的线程安全么?

针对第一点,现在的scope没有指定运转的线程,那么协程默许是在Default线程池中履行,同时循环体的每次履行都会触发两次线程切换,其一是从launch协程体履行的线程切换至suspendWork挂起函数履行的线程,其二是suspendWork挂起函数履行结束后,从suspendWork挂断函数履行线程切换回launch协程体履行的线程,而launch协程体履行的线程由Default线程池分配,线程池分配线程存在不固定性,所以循环体在同一个线程中履行不能成立,天然不能称为是线程安全的。

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

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

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

运用「同步代码」写出异步程序,对程序猿来说这是多么美好的工作,但是假如对协程了解的不够深入,不清楚它背面的逻辑,那么很简略就像笔者一样被它简略的表象所“诈骗”。

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

反常原因

in在调集遍历时是一个操作符重载关键字,我们把鼠标放在in关键字上,然后按住ctrl(windows)command(macos)键,再点击鼠标左键,会看到它其实重载的是Iteratornext()hasNext()办法,所以for-in循环其实是经过Iterator来运用的。

Iteratornext()办法中会查看调集是否被修正,假如被修正则抛出java.util.ConcurrentModificationException反常。

怎么修正?

修正计划有多种,比方:

  1. 在协程体中,先对ml调集进行一次浅拷贝赋值给ml2,然后遍历ml2,如此便不会抛出上述反常,但是无法遍历ml中新增的元素,
  2. 运用加锁的方法,遍历ml调集时不允许其他线程对ml调集进行更新,
  3. 其他方法,

具体挑选哪种修正计划,这儿能够依据事务场景的不同而挑选不同的修正计划。

总结

本文记载的笔者在运用Kotlin协程过程中遇到的for-in溃散问题,经过伪代码笔者复现了溃散问题,并剖析了问题发生的原因以及给出一些修正计划供挑选参考。

其中最重要的是发现本身的缺乏,发现自己的缺乏也是一种进步。

总归便是:

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