Kotlin 协程中容易让人忽视的 Cancellation 陷阱

本文将用三个例子展示在 Kotlin 协程中比较容易忽视的 Cancellation 问题。

Cancellation 陷阱示例一

class MyActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
        setContent { MyComposeApplicationTheme() { val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "home") { composable("home") { HomeScreen(
                            onNextClick = { navController.navigate("photo_upload") }
                        )
                    }
                    composable("photo_upload") { val viewModel = viewModel()
                        PhotoUploadScreen(
                            isUploading = viewModel.isLoading,
                            onUploadPhotoClick = { viewModel.uploadPhoto() }
                        )
                    }
                }
            }
        }
    }
}

界面没啥东西,总共有两个屏幕,首页会跳转到PhotoUploadScreen,PhotoUploadScreen上面有一个按钮,点击会调用PhotoUploadViewModel的uploadPhoto()方法模拟图片上传。

主要关注PhotoUploadViewModel的uploadPhoto()方法代码:

class PhotoUploadViewModel: ViewModel() { var isLoading: Boolean by mutableStateOf(false)
        private set
    private val repository = ProfileRepository()
    
    fun uploadPhoto() { viewModelScope.launch { isLoading = true
            Log.d("PhotoUploadViewModel", "Uploading photo...")
            repository.uploadPhoto()
            isLoading = false
            Log.d("PhotoUploadViewModel", "Photo update finished")
        }
    }
}
class ProfileRepository { suspend fun uploadPhoto() { try { delay(5000L) // 延时 5s 模拟上传图片耗时
        } catch (e: Exception) { e.printStackTrace() 
        }
    }
}

这个例子运行之后,点击上传按钮后,马上按back键返回,会发现log输出:

Uploading photo...
Photo update finished

可见 repository.uploadPhoto() 方法中的上传代码在返回键之后还是会被执行,这意味着 viewModelScope.launch 启动的协程作用域没有被真正取消,这与我们的预期不符,当用户按下返回键关闭页面时,我们期望的是取消协程中正在后台执行的上传任务,否则这容易导致内存泄漏。

这里的问题所在,是因为我们在 ProfileRepository 的 uploadPhoto() 方法中 try-catch 了所有的异常,这其中就包括协程取消异常:CancellationException 。

协程的取消其实非常依赖于 CancellationException ,该异常不会取消它的父协程,这个异常通常会被忽略静默处理,但是假如你捕获了该异常,那么父协程就不会感知到任何取消通知,也就不会取消协程。

所以,请不要捕获 CancellationException 类型的异常,要么在try-catch时总是指定具体的异常类型(如IOException、HttpException), 要么捕获到 CancellationException 异常时总是将其抛出,如下:

class ProfileRepository { suspend fun uploadPhoto() { try { delay(5000L)
        } catch (e: Exception) { if(e is CancellationException) throw e // 捕获到 CancellationException 时重新抛出
            e.printStackTrace()
        }
    }
}

或者:

class ProfileRepository { suspend fun uploadPhoto() { try { delay(5000L)
        } catch (e: IOException) { e.printStackTrace()
        } catch (e: HttpException) { e.printStackTrace()
        }
    }
}

现在这个示例运行后,点击按钮上传,立马按返回键,协程会被真正的取消掉,log输出:

Uploading photo... 

说明 ProfileRepository 的 uploadPhoto() 方法中逻辑没有被执行了。

Cancellation 陷阱示例二

@Entity
data class Note(
    val title: String,
    val description: String,
    val isSynced: Boolean,
    @PrimaryKey
    val id: Int = 0
)
interface NoteApi { abstract fun saveNote(note: Note)
}
interface NoteDao { @Upsert
    suspend fun upsertNote(note: Note) 
}
    
class OfflineFirstRepository(private val dao: NoteDao, private val api: NoteApi) { suspend fun saveNote(note: Note) { try { dao.upsertNote(note.copy(isSynced = false))
            api.saveNote(note)
            dao.upsertNote(note.copy(isSynced = true))
        } catch (e: Exception) { if (e is CancellationException) throw e 
            e.printStackTrace()
        }
    }
}

这个示例中,主要关注 OfflineFirstRepository 的saveNote方法代码,这里的意图是先向本地数据库插入一个标志位 isSynced = false 表示同步尚未完成,随后调用 NoteApi 向后端服务器提交 note 数据,当提交完毕之后,再更新本地数据库中的标志位 isSynced = true 表示同步完成。

这里捕获到 CancellationException 会将其重新抛出,所以协程取消不会有问题。

假设代码在 dao.upsertNote(note.copy(isSynced = false)) 或 api.saveNote(note) 中时协程被取消,那么本地记录的同步标志为 false 在下次应用启动时会进行根据保存的标志位进行请求重试。

这个示例的问题在于,协程的取消可以是在任何时机,不一定在某个方法当中,例如在 api.saveNote(note)执行完毕,但是还没有开始执行 dao.upsertNote(note.copy(isSynced = true)) 这句时,用户按下了返回键,此时协程被取消,那么同步标识就不会被更新为 true。在这个示例中,可能不会有太大问题,因为顶多在下次重启时,本地读取判断同步标识为 false 会再次向服务器提交一遍,但这需要根据你的业务场景来,假如不允许重复提交,那么就有可能产生bug。

解决的方法之一是使用 Room 中的 @Transaction 事务处理,将多个数据库操作封装到一个原子事务当中。另一个解决方法是使用 withContext(NonCancellable):

class OfflineFirstRepository(private val dao: NoteDao, private val api: NoteApi) { suspend fun saveNote(note: Note) { try { dao.upsertNote(note.copy(isSynced = false))
            api.saveNote(note)
            withContext(NonCancellable) { dao.upsertNote(note.copy(isSynced = true))
            }
        } catch (e: Exception) { if (e is CancellationException) throw e
            e.printStackTrace()
        }
    }
}

被 withContext(NonCancellable) { } 包裹的代码不能被取消,一定会执行,当 api.saveNote(note) 执行完毕后,假设用户按下返回键,那么 withContext(NonCancellable) 中的代码仍然会执行。

Cancellation 陷阱示例三

class PhotoUploadViewModel: ViewModel() {...
    fun readFile(context: Context) { viewModelScope.launch { val job = launch { FileReader(context).readFileFromAssets("60MB.bin")
            }
            delay(3L) 
            job.cancel()
            println("Read file cancelled")
        }
    }
}
class FileReader(private val context: Context) { suspend fun readFileFromAssets(name: String): ByteArray { return withContext(Dispatchers.IO) { context.assets.open(name).use { it.readBytes()
            }
        }.also { println("Read file with size ${it.size}") }
    }
}

上面代码readFile方法中会启动一个子协程去读取文件内容,这里在启动读取文件的子协程3毫秒后,马上调用 job.cancel() 取消了子协程,但是发现log输出:

Read file cancelled
Read file with size  62914560

这说明读取文件的子协程还是执行完了,读取文件的任务没有真正被取消掉。

这个问题的所在,是因为协程的取消是协作的,也就是说协程并不是一定能被取消的,如果协程正在执行不可中断的计算任务,并且没有检查取消的话,那么它是不能被取消的。这一点上跟Java的线程取消有点类似。

解决方法可以使用 isActive 或 ensureActive() 来检查 Job 状态,例如:

class FileReader(private val context:Context) { suspend fun readFileFromAssets(name: String): ByteArray { return withContext(Dispatchers.IO) { context.assets.open(name).use { val byteArrayOutputStream = ByteArrayOutputStream()
                val buffer = ByteArray(4096)
                var bytesRead: Int
                while (it.read(buffer).also { bytesRead = it } != -1) { ensureActive()
                    byteArrayOutputStream.write(buffer, 0, bytesRead)
                }
                byteArrayOutputStream.toByteArray()
            }.also { println("Read file with size ${it.size}") }
        }
    }
}

或者:

class FileReader(private val context:Context) { suspend fun readFileFromAssets(name: String): ByteArray { return withContext(Dispatchers.IO) { context.assets.open(name).use { val byteArrayOutputStream = ByteArrayOutputStream()
                val buffer = ByteArray(4096)
                var bytesRead: Int
                while (isActive && it.read(buffer).also { bytesRead = it } != -1) { byteArrayOutputStream.write(buffer, 0, bytesRead)
                }
                byteArrayOutputStream.toByteArray()
            }.also { println("Read file with size ${it.size}") }
        }
    }
}

这样,当通过 isActive 为 false 时就会退出 while 循环,或 ensureActive() 检测到协程已经取消,就会抛出 CancellationException 同样会终止 while 循环,后续的读取文件内容就不会继续执行。

注意,检测协程是否被已被取消,最好是在循环中,或者如果没有循环逻辑则每隔一段代码调用一次检测,所以上面读取文件的代码最好不要使用it.readBytes()这样一行代码去读,因为这样就没有办法可以设置检查点。

如果你想更加深入和全面的了解有关 Kotlin 协程的异常和取消相关知识,可以参考我们的另外两篇博文:

  • 【深入理解Kotlin协程】协程作用域、启动模式、调度器、异常和取消【使用篇】
  • 【深入理解Kotlin协程】使用Job控制协程的生命周期