In my opinion, Kotlin Coroutines really simplify the way we write asynchronous and concurrent code. However, I identified some common mistakes that many developers make when using Coroutines.
Common Mistake #1: Instantiating a new job instance when launching a Coroutine
Sometimes you need a job
as a handle to your Coroutine in order to, for instance, cancel it later. And since the two Coroutine builders launch{}
and async{}
both take a job
as input parameter, you might think about creating a new job
instance and then pass this new instance to such a builder as launch{}
. This way, you have a reference to the job
and therefore are able to call methods like .cancel()
on it.
fun main() = runBlocking { val coroutineJob = Job() launch(coroutineJob) { println("performing some work in Coroutine") delay(100) }.invokeOnCompletion { throwable -> if (throwable is CancellationException) { println("Coroutine was cancelled") } } // cancel job while Coroutine performs work delay(50) coroutineJob.cancel() }
This code seems to be fine. When we run it, the Coroutine gets cancelled successfully:
>_ performing some work in Coroutine Coroutine was cancelled Process finished with exit code 0
However, let’s now run this Coroutine in a CoroutineScope
and cancel this scope instead of the Coroutine’s job
:
fun main() = runBlocking { val scopeJob = Job() val scope = CoroutineScope(scopeJob) val coroutineJob = Job() scope.launch(coroutineJob) { println("performing some work in Coroutine") delay(100) }.invokeOnCompletion { throwable -> if (throwable is CancellationException) { println("Coroutine was cancelled") } } // cancel scope while Coroutine performs work delay(50) scope.cancel() }
All Coroutines in a scope should be cancelled when the scope itself gets cancelled. However, when we run this updated code example, this is not the case:
>_ performing some work in Coroutine Process finished with exit code 0
Now, the Coroutine is not cancelled, as “Coroutine was cancelled” is never printed out.
Why is that?
Well, in order to make your asynchronous and concurrent code safer, Coroutines have this innovative feature called “Structured Concurrency”. One mechanism of “Structured Concurrency” is to cancel all Coroutines of a CoroutineScope
if the scope gets cancelled. In order for this mechanism to work, a hierarchy between the Scope’s job
and the Coroutine’s job
is formed, as the image below illustrates:
In our case however, something very unexpected happens. By passing your own Job
instance to the launch()
Coroutine builder, we don’t actually define this new instance as the Job
that is associated with the Coroutine itself! Instead, it becomes the parent job of the new coroutine. So the parent of our new Coroutine is not the Coroutine Scope
‘s job
, but our newly instantiated job
object.
Therefore, the job
of the Coroutine isn’t connected to the job
of the CoroutineScope
anymore:
So we broke Structured Concurrency and therefore the Coroutine is not cancelled anymore when the we cancel our scope.
The solution for this problem is to simply use the job
that launch{}
returns as a handle for our Coroutine:
fun main() = runBlocking { val scopeJob = Job() val scope = CoroutineScope(scopeJob) val coroutineJob = scope.launch { println("performing some work in Coroutine") delay(100) }.invokeOnCompletion { throwable -> if (throwable is CancellationException) { println("Coroutine was cancelled") } } // cancel while coroutine performs work delay(50) scope.cancel() }
This way, our Coroutine now is cancelled when the Scope is cancelled:
>_ performing some work in Coroutine Coroutine was cancelled Process finished with exit code 0
Common Mistake #2: Installing SupervisorJob the wrong way
Sometimes you want to install a SupervisorJob
somewhere in your job hierarchy in order to
- stop exceptions from propagating up the job hierarchy
- not cancel the siblings of Coroutines if one of them fails
Since the Coroutine builders launch{}
and async{}
can take a Job
as an input parameter, you could think about achieving this by passing a SupervisorJob
to these builders:
launch(SupervisorJob()){ // Coroutine Body }
However, like in Mistake#1, you are breaking the cancellation mechanism of Structured Concurrency. The solution for this issue is to use the supervisorScope{}
scoping function instead:
supervisorScope { launch { // Coroutine Body } }
Common Mistake #3: Not supporting cancellation
Let’s say you want to perform an expensive operation, like the calculation of a factorial number, in your own suspend
function:
// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * n suspend fun calculateFactorialOf(number: Int): BigInteger = withContext(Dispatchers.Default) { var factorial = BigInteger.ONE for (i in 1..number) { factorial = factorial.multiply(BigInteger.valueOf(i.toLong())) } factorial }
This suspend function has a problem: It doesn’t support “cooperative cancellation”. This means that even if the coroutine in which it is executed is cancelled prematurely, it will still continue to run until the calculation is completed. To avoid this issue, we periodically have to either use
Below, you can find a solution that supports cancellation by using ensureActive():
// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * n suspend fun calculateFactorialOf(number: Int): BigInteger = withContext(Dispatchers.Default) { var factorial = BigInteger.ONE for (i in 1..number) { ensureActive() factorial = factorial.multiply(BigInteger.valueOf(i.toLong())) } factorial }
The suspend functions in the Kotlin standard library (like e.g. `delay()`) are all cooperative regarding cancellation, but for your own suspend functions, you should never forget to think about cancellation.
Common Mistake #4: Switching the dispatcher when performing a network request or database query
Well, this one is not that really a “mistake”, but still makes your code a bit harder to read and maybe also a little bit less efficient: Some developers think that they need to switch to a background dispatcher in their Coroutines before calling, for instance, a suspend
function of Retrofit in order to make a network request, or a suspend
function of Room to perform a database operation.
This is not required because of the convention that all suspend function should be “main-safe”, and fortunately Retrofit and Room stick to this convention. You can read more about this topic in another article on my blog:
Common Mistake #5: Trying to handle an exception of a failed coroutine with try/catch
Exception Handling in Coroutines is hard. I have spent quite some time to really understand it and also explained it to other developers in the form of a Blogpost and several Conference Talks. I even created a Cheat Sheet that summarises this complex topic.
One of the most unintuitive aspects of exception handling with Kotlin Coroutines is that you aren’t able to catch exceptions with try/catch
of Coroutines that fail with an exception:
fun main() = runBlocking<Unit> { try { launch { throw Exception() } } catch (exception: Exception) { println("Handled $exception") } }
If you run the code above, the exception will not be handled and the app will crash:
>_ Exception in thread "main" java.lang.Exception Process finished with exit code 1
Kotlin Coroutines promise us to be able use conventional coding constructs in asynchronous code. However, in this case this is not true, as the conventional try-catch
block does not handle the exception, as most developers would expect.
If you want to handle the exception, you have to either use try-catch
directly in your Coroutine or install a CoroutineExceptionHandler
.
For more information, please have a look at the aforementioned article
Common Mistake #6: Installing a CoroutineExceptionHandler in a Child Coroutine
Let’s keep this one short and sweet: Installing a CoroutineExceptionHandler
via the coroutine builder of a child coroutine won’t have any effect. This is because exception handling is delegated to the Parent Coroutine. Therefore, you have to install your CoroutineExceptionHandler
s either in your root or parent Coroutines or in the CoroutineScope
.
Again, details can be found here
Common Mistake #7: Catching CancellationExceptions
When a Coroutine gets canceled, the suspend function that is currently executed in the Coroutine will throw a CancellationException
. This usually completes the Coroutine "exceptionally" and therefore the execution of the Coroutine will stop immediately, like in the following example:
fun main() = runBlocking { val job = launch { println("Performing network request in Coroutine") delay(1000) println("Coroutine still running ... ") } delay(500) job.cancel() }
After 500 milliseconds, the suspend
function delay()
will throw a CancellationException
, the Coroutine "completes exceptionally" and therefore stops its execution:
>_ Performing network request in Coroutine Process finished with exit code 0
Now let’s image that delay()
represents a network request and in order to handle exceptions of that network request, we surround it with a try-catch
block that catches all Exceptions:
fun main() = runBlocking { val job = launch { try { println("Performing network request in Coroutine") delay(1000) } catch (e: Exception) { println("Handled exception in Coroutine") } println("Coroutine still running ... ") } delay(500) job.cancel() }
Now, we introduced a severe bug. The catch
clause will not only catch HttpException
s for failed network requests, but also CancellationExceptions
! Therefore the Coroutine will not "complete exceptionally" but instead will continue to run:
>_ Performing network request in Coroutine Handled exception in Coroutine Coroutine still running ... Process finished with exit code 0
This will lead to wasted device resources and in some situations this can even lead to crashes.
To fix this problem, we could either catch HttpException
s only:
fun main() = runBlocking { val job = launch { try { println("Performing network request in Coroutine") delay(1000) } catch (e: HttpException) { println("Handled exception in Coroutine") } println("Coroutine still running ... ") } delay(500) job.cancel() }
or re-throw CancellationExceptions
:
fun main() = runBlocking { val job = launch { try { println("Performing network request in Coroutine") delay(1000) } catch (e: Exception) { if (e is CancellationException) { throw e } println("Handled exception in Coroutine") } println("Coroutine still running ... ") } delay(500) job.cancel() }
So these are 7 of the most common mistakes when using Kotlin Coroutines. If you know other common mistakes, then please let me know in the comments!
Also, please don’t forget to share this article so that other developers won’t make these mistakes. Thanks!
Thank you for reading, and have a great day! 🌴
Btw: If you want to deepen your knowledge about Kotlin Coroutines and Flow, I can recommend my 15+hour long online course: