Preventing canceling a coroutine
This article explains how to prevent canceling a coroutine using NonCancellable
.
The problem
Write a coroutine that cannot be canceled. Parts of this coroutine will involve plenty of computation, while other parts will only involve wainitg for a result of an I/O operation.
Once that coroutine has completed, invoke an action on a Fragment
, but only if the Fragment
is still alive.
The project
The project in which I used this technique is Victor Events.
The change is contained within a particular commit, but the reader is encouraged to follow the present article and the snippets presented herein, instead of trying to decipher the commit on their own, as the commit may contain garbage like renaming of a value, or adding a line break. The code might have also changed a few times since I made that commit, whereas in the article I am making effort to present the code’s most relevant and-up to date version.
The Fragment
Since the UI action should be invoked only if the Fragment
is still alive, I am using the lifecycleScope
extension property:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == LOGIN_REQUEST && resultCode == Activity.RESULT_OK) {
prompt.visibility = View.GONE
ok.visibility = View.GONE
privacy_policy.visibility = View.GONE
cancel.visibility = View.GONE
progress.visibility = View.VISIBLE
lifecycleScope.launch {
comms.refreshAdminStatuses()
yield()
back()
}
}
}
The above code is invoked after a log-in attempt has been made. If the user has successfully logged in, most of the GUI elements are hidden, progress bar is brought to the front and an operation is launched, which is meant to check which communities the logged-in user is an admin of.
After refreshing of the admin status of each community, the Fragment
is closed by calling findNavController().popBackStack()
. However, the Fragment
may be already destroyed, so the back()
function is only called if lifecycleScope
hasn’t been canceled.
The problem is that the refreshing coroutine shall not be canceled while it is already running, as this could result with the repository content not being conistend - some items for instance might be updated only in memory cache, but the might not persisted.
One of the ways of preventing canceling of a coroutine is to use a CoroutineScope
that is always active - GlobalScope
. At first I was experimenting with the following code, and I still believe it’s okay:
val refreshJob = GlobalScope.launch { comms.refreshAdminStatuses() }
lifecycleScope.launch {
refresJob.join()
back()
}
Still, Kotlin allows fluently switching a CoroutineContext
without launching an isolated coroutine, and this is the solution on which I am going to focus subsequently in the article.
NonCancellable
NonCancellable
extends Job
, and this is its cancel()
implementation:
override fun cancel(cause: CancellationException?) {}
The above line of code literally does nothing, so of course children of this Job
will not be canceled when this empty implementation of cancel()
is called.
I wrote some dummy code to demonstrate the behavior of NonCancellable
in my playground project. One can still see it in a commit:
GlobalScope.launch {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
withContext(NonCancellable) {
println("wiktor inside the block")
delay(2000)
yiield()
println("wiktor finished successfully")
}
yield()
println("wiktor not cancelled")
}
delay(1000)
scope.cancel()
println("wiktor canceled")
}
The code launches a block setting its parent Job
to NonCancellable
. It then logs a line to confirm the block is running.
After one second it cancels the CoroutineScope
the block is running in, and confirms it by logging another line.
Then the line wiktor finished successfully
is logged to prove that the code called with a NonCanlellable
was never canceled.
The line wiktor not canceled
, however, is not logged, as it is called outside of NonCanlellable
. The yield()
function throws a CancellationException
then and prevents calling println()
.
Neither delay()
nor yield()
throw a CancellationException
when they are called from inside of a NonCancellable
. Because of that I have to call yield()
again, outside of the block, to prevent further execution of the code inside of the cancelled scope
.
Canceling the CoroutineScope
is discussed in the talk ‘Android Suspenders (Android Dev Summit ‘18’)’ (beginning at 18:09). Although NonCancellable
is not discussed there, watching that particular talk allowed me to come up with these solutions.
Refreshing and transforming data
Explaining the code that refreshes the admin status of each community is outside of the scope of the present article, althought it has already somewhat been explained in another article in this blog.
This is the abbreviation of the code:
suspend fun refreshAdminStatuses() =
refresh { it.copy(isAdmin = fsReader.isAdmin(it.lcName)) }
private suspend fun refresh(transform: suspend (Comm) -> Comm): List<Comm> =
withContext(NonCancellable + Dispatchers.Default) {
val commsCopy = commsCache.toList()
val transformed = mutableListOf<Comm>()
commsCopy.launchEach { transformed.add(transform(it)) }
.joinAll()
commsCache.clear()
commsCache.addAll(transformed.filter { !it.isDummy })
notifyDataSetChanged()
commDao.clear()
commsCache.launchEach { commDao add it }
transformed
}
First, the reader will noticed that I use the syntax:
withContext(NonCancellable + Dispatchers.Main)
As I showed in one of the above section, this code is called from within lifecycleScope
, which by default uses Dispatchers.Main
. Here I am replacing the Job
of the CoroutineContext
with NonCancellable
, and furthermore I am setting the CoroutineDispatcher
to Dispatchers.Default
, which is more suitable for executing work that requires much computation.
The transform
operation, however, is using Dispatchers.IO
to perform reading from Firebase Datastore:
suspend infix fun isAdmin(name: String): Boolean = if (isLoggedIn)
name.admins.document(login.email).doesExist() else false
private suspend fun DocumentReference.doesExist() = read().exists()
protected suspend fun DocumentReference.read() = withContext(Dispatchers.IO) {
...
}
The above code demonstrates that each read()
operation is called with Dispatchers.IO
, while most of the computation is performed with Dispatchers.Default
, as discussed previously, and eventually it will trigger an action on the Fragment
, using Dispatchers.Main
, but only if the Fragment
is still alive.
Conclusion
The present article promotes using coroutines for performing tasks that require frequent switching of contexts, or that should never be canceled if they are already running.
I am not aware how to do the above with RxJava. I am aware that in this library, used frequently as an alternative to coroutines, context switching can be achieved with subscribeOn() and observeOn(), but I am not sure how to switch the context dozens of times during one operation, or how to prevent the operation from being canceled.
If the reader knows how to solve these problems purely with RxJava, please submit a ticket, and if I like the solution, I will edit the present article to include it.