使用Kotlin协程的崩溃纪实(一)
前言
由于笔者对Kotlin协程缺乏深刻的理解以及充分的实践,导致在实际工作中使用Kotlin协程时遇到一些崩溃的问题。
那么本文主要记录遇到的这些崩溃问题,这是其中之一。
背景
在Kotlin协程中如果不能合理的使用for-in
循环,可能会抛出ConcurrentModificationException
异常导致崩溃。
伪代码
下面是符合背景中描述的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 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 { try { for (i in ml) { log("${suspendWork(500 L)} : $i " ) } run.resumeWith(1 ) } catch (e: Exception) { e.printStackTrace() run.resumeWith(-1 ) } } val await = RunSuspend<Boolean >() await.await(1_000L ) scope.launch { 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 " ) }
上面的伪代码大致的逻辑如下:
创建一个可变列表ml
并添加[0, 10]这11条数据,
创建一个RunSuspend
的实例run
,用于主线程等待协程执行完毕,否则主线程退出,协程无法执行完毕,
创建一个协程作用域scope,
启动一个协程A,在协程中执行for-in
循环,在循环中调用挂起函数suspendWork
模拟执行耗时任务,其每500ms输出一条日志,循环执行完毕后或抛出异常时调用run.resumeWith
通知协程执行完毕并停止阻塞主线程,
再创建一个RunSuspend
的实例await
,用于阻塞主线程1000ms,然后启动一个新的协程B为可变列表ml
新增一条数据,
调用run.await
阻塞主线程并等待协程A执行完毕。
伪代码执行的期望结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [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]
笔者期望伪代码可以正常的执行完毕。
但是,实际上伪代码执行的结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [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)
执行结果分析:
协程中的for-in
循环输出了2条日志,每次输出日志时,其实已经是每次遍历执行500ms之后了,
阻塞主线程1000ms后,我们在启动的协程B中为可变列表ml
新增了一条数据:11,
在第三次遍历时协程A中抛出了一个异常:java.util.ConcurrentModificationException
。
分析
为什么执行结果会与我们期望的结果不同呢?接下来我们分析下原因。
首先我们再仔细观察下协程中的代码:
1 2 3 4 5 6 7 8 9 10 11 scope.launch { try { for (i in ml) { log("${suspendWork(500 L)} : $i " ) } run.resumeWith(1 ) } catch (e: Exception) { e.printStackTrace() run.resumeWith(-1 ) } }
按照笔者的想法,for-in
循环的执行逻辑如下:
循环体是在同一个线程中执行,
循环体是同步且顺序执行的,
可以简单理解循环体的执行是线程安全的。
真的线程安全么?
针对第一点,目前的scope
没有指定运行的线程,那么协程默认是在Default
线程池中执行,同时循环体的每次执行都会触发两次线程切换,其一是从launch
协程体执行的线程切换至suspendWork
挂起函数执行的线程,其二是suspendWork
挂起函数执行完毕后,从suspendWork
挂断函数执行线程切换回launch
协程体执行的线程,而launch
协程体执行的线程由Default
线程池分配,线程池分配线程存在不固定性,所以循环体在同一个线程中执行不能成立,自然不能称为是线程安全的。
为什么会有这样的想法呢?
正如前言中所说,笔者对协程缺乏深刻的理解以及充分的实践,而协程的一大特点是:使用「同步代码」写出异步程序。
其实笔者就是被「同步代码」这一表象所“欺骗”,这也是笔者对协程缺乏深刻理解的佐证。
使用「同步代码」写出异步程序,对程序猿来说这是多么美好的事情,但是如果对协程理解的不够深入,不清楚它背后的逻辑,那么很容易就像笔者一样被它简单的表象所“欺骗”。
针对第二点,协程的特点是使用「同步代码」写出异步程序,在循环体中调用了挂起函数,那么循环逻辑必然是异步程序,所以第二点也不成立。
异常原因
in
在集合遍历时是一个操作符重载关键字,我们把鼠标放在in
关键字上,然后按住ctrl(windows)
或command(macos)
键,再点击鼠标左键,会看到它其实重载的是Iterator
的next()
和hasNext()
方法,所以for-in
循环其实是通过Iterator
来使用的。
在Iterator
的next()
方法中会检查集合是否被修改,如果被修改则抛出java.util.ConcurrentModificationException
异常。
如何修复?
修复方案有多种,比如:
在协程体中,先对ml
集合进行一次浅拷贝赋值给ml2
,然后遍历ml2
,如此便不会抛出上述异常,但是无法遍历ml
中新增的元素,
使用加锁的方式,遍历ml
集合时不允许其他线程对ml
集合进行更新,
其他方式,
具体选择哪种修复方案,这里可以根据业务场景的不同而选择不同的修复方案。
总结
本文记录的笔者在使用Kotlin协程过程中遇到的for-in
崩溃问题,通过伪代码笔者复现了崩溃问题,并分析了问题产生的原因以及给出一些修复方案供选择参考。
其中最重要的是发现自身的不足,发现自己的不足也是一种进步。
总之就是:
纸上得来终觉浅,绝知此事要躬行。