Cancelling coroutines is easy, right?

With a well-designed support for structured concurrency, cancelling should be easy, and calling job.cancel() will do the trick, right?

Cancellation is cooperative

Let’s start with the following piece of code:

private suspend fun doSomething() { ... }

private var fooJob: Job? = null

fun startCoroutine(coroutineScope: CoroutineScope) {
  fooJob?.cancel()

  fooJob = coroutineScope.launch {
    try {
      doSomething()
    } finally {
      fooJob = null
    }
  }
}

It tries to cancel any ongoing coroutine before launching a new one, looks innocent.

However, if startCoroutine() is called when there is already a coroutine running, it will wrongly nullify the fooJob of the new job.

This is because cancellation is cooperative. When fooJob.cancel() is called, the existing coroutine is not immediately terminated, but throws a CancellationException. As a result, the finally block of the previous job will be called after launch() is called and the fooJob is re-assigned with the new value.

To avoid such bugs, we can wait until the coroutine is terminated:

suspend fun startCoroutine(coroutineScope: CoroutineScope) {
  fooJob?.cancelAndJoin() // This is a suspend function.

  fooJob = coroutineScope.launch {
    ...
  }
}

Or check if the coroutine context is still alive before nullifying:

fun startCoroutine(coroutineScope: CoroutineScope) {
  fooJob?.cancel()

  fooJob = coroutineScope.launch {
    try {
      doSomething()
    } finally {
      if (isActive) { // isActive will be false, if the coroutine is cancelled.
        fooJob = null
      }
    }
  }
}

CancellationException

As mentioned earlier, when a coroutine is cancelled, it throws a CancellationException to communicate with all coroutines inside the scope. As a result, the following try-catch block will prevent the coroutine from being properly cancelled:

fun startCoroutine(coroutineScope: CoroutineScope) {
  coroutineScope.launch {
    try {
      doSomething()
    } catch (e: Exception) {
      // The CancellationException is caught here, so the
      // execution will continue.
      ...
    }
  }
}

To fix this, we should re-throw the CancellationException:

fun startCoroutine(coroutineScope: CoroutineScope) {
  coroutineScope.launch {
    try {
      doSomething()
    } catch (c: CancellationException) {
      throw c
    } catch (e: Exception) {
      ...
    }
  }
}

Of course, repeating this everywhere is tedious. So, let’s create a runSuspendCatching function to wrap it:

inline suspend fun <R> runSuspendCatching(block: () -> R): Result<R> =
  try {
    Result.success(block())
  } catch(c: CancellationException) {
    throw c
  } catch (e: Throwable) {
    Result.failure(e)
  }

What about Flows?

Fortunately, we don’t need to worry too much about Flows, because the catch operator already specifically handles this situation.

Conclusion

When cancelling coroutines and handling exceptions, these two cases can be easily missed, causing unexpected bugs. Unfortunately, there is no easy way to fix this, and we have to be careful dealing with them. Maybe new LINT rules could help with this. How do you think?


See also

comments powered by Disqus