Kotlin Coroutines: Essential Guide to Master Asynchronous Programming

Kotlin Coroutines: Essential Guide to Master Asynchronous Programming

Learn How to Handle Concurrency, Optimise Threads, and Build Scalable Applications with Kotlin Coroutines

Introduction

One of the major problems we face while development, is avoiding our application to get caught in bottlenecks. This leads application to blocking states and crash.

Coroutines enables us to write non-blocking code, by helping us write functions that can suspend their execution and resume their execution, as per the application needs.

Whenever a user launches an application, the tasks associated with it are initiated on a main thread. These tasks range from small logical operations to UI interactions.

However these threads are’t suitable for running high end tasks like long operations, downloading, uploading, database queries, etc. as this may block the main thread to perform other operations and will lead the application to be slow or may even crash it.

To handle this situation, we may create more background threads also known as worker threads what will be given the task for handling heavyweight operations.

However, this isn’t a scalable solution as the number of threads are limited as they are expensive and make the system go out of memory.

Coroutines

Coroutines are light weight threads that are not as the same as threads but can run in parallel with each other, wait for each other, communicate with each other just how a thread does.

Unlike threads, Coroutines are very cheap. Multiple Coroutines can be executed on a single background thread.

To start using Coroutines in out Kotlin Project, we need to follow the following steps:

  1. Add the coroutine dependency in our build.gradle file
dependencies{
    //statements.. 
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
  1. Import the Coroutine Library in the Kotlin file
import kotlinx.coroutines.*
fun main() { //statements.. 
}

Implementation

  • Getting to know the environment :
import kotlin.coroutines.*
fun main(){
  println("main starts here : ${Thread.currentThread().name}")
  Thread.sleep(3000)
  println("main ends here : ${Thread.currentThread().name}")
}

/*Output:
main starts here : main
main ends here : main
*/

As our main function gets executed in the main thread, we see the output as main in both the print statements, with the delay of 3 seconds.

  • Creating a background(worker) thread using the Thread keyword and .start() function:
import kotlin.coroutines.*
fun main(){
  println("main starts here : ${Thread.currentThread().name}")
  Thread{
    println("bg starts here : ${Thread.currentThread().name}")
    println("bg ends here : ${Thread.currentThread().name}")
  }.start()
  //Case1: Thread.sleep(4000)
  println("main ends here : ${Thread.currentThread().name}")
}

/*Output:
main starts here : main
main ends here : main
bg starts here : Thread-0
bg ends here : Thread-0
*/

//Case1 will lead to execution in the order main,t0,t0,main

As both threads are executed in parallel, the main thread completes its execution side by side while the background thread is also executing. Order of print statements may change depending upon the delays.

  • Building Coroutines using the launch lambda function:
import kotlin.coroutines.*

//Case 1: 
fun main(){
  println("main starts here : ${Thread.currentThread().name}")
  GlobalCoroutine.launch{
    println("c starts on t : ${Thread.currentThread().name}")
    println("c ends on t : ${Thread.currentThread().name}")
  }
  println("main ends here : ${Thread.currentThread().name}")
}

/*Output:
main starts here : main
main ends here : main
*/

//Case 2:
fun main() { 
    GlobalScope.launch {
        println("c starts on t: ${Thread.currentThread().name}")
        delay(1000) 
        println("c ends on t: ${Thread.currentThread().name}")
    }

    println("Main thread ends: ${Thread.currentThread().name}")
    Thread.sleep(500) // The main thread sleeps briefly
}

/*Output:
main starts here : main
c starts on t : DefaultDispatcher-worker-1
*/

the GlobalCoroutine.launch function creates coroutine that is not linked to any lifecycle and is a standalone in the global scope. This also doesn’t block the thread in which it is operating. As the application doesn’t wait for the coroutine, it only waits for all the threads to end.

Thread.sleep() block the whole thread, and all the coroutines that are being executed on it.

delay() is a similar function that only delays the current coroutine and doesn’t affect the thread or other coroutines that run on it. However delay is s suspending function (a function built using the suspend keyword and can only be called by a coroutine or other suspending functions)

fun main(){   
    //MAIN THREAD
    GlobalScope.launch{ 
        //T1 THREAD
        delay(1000)   //Coroutine Suspended, T1 FREE
        //T1/other THREAD
    }
    //MAIN THREAD
}

So coming back to our previous code, we will wait for our global scope coroutine, using the delay() function.

import kotlin.coroutines.*

fun main(){
  println("main starts here : ${Thread.currentThread().name}")
  GlobalCoroutine.launch{
    println("c starts on t : ${Thread.currentThread().name}")
    println("c ends on t : ${Thread.currentThread().name}")
  }
  runBlocking{
      delay(2000)
    }
  println("main ends here : ${Thread.currentThread().name}")
}

/*Output:
main starts here : main
c starts on t : DefaultDispatcher-worker-1
c ends on t : DefaultDispatcher-worker-1
main ends here : main
*/

As delay() is a suspending function we call it from a runBlocking lambda function: that creates a coroutine that runs on the current thread. Hence, in this case we delay the main thread by 2 seconds, and our Coroutine gets executed.

Another way to rewrite the above code:

import kotlin.coroutines.*

fun main() = runBlocking{
  //MAIN THREAD
  GlobalCoroutine.launch{
    //T1 THREAD
  }
  delay(2000) //MAIN delayed, thus T1 completed
  //MAIN THREAD
}

Coroutine Builders

Coroutine Builders are functions that help us create and launch coroutines.

  • launch & GlobalScope.launch

  • async & GlobalScope.async

  • runBlocking

Using GlobalScope Companion Object with the coroutine builders makes the scope of the coroutine to the application level rather than the context level. Lifecycle of Local coroutines gets destroyed when that local context is destroyed.

Therefore, we discourage the use of Global Scope Coroutines as if left unhandled, it may be left in the background, wasting the resources.

  1. launch

Creates a Coroutine in the scope of the parent Coroutine Thread.

import kotlinx.coroutines.*

fun main() = runBlocking { //MAIN
        //MAIN
    launch { //inherits MAIN
        println("Running in ${Thread.currentThread().name}")
    }
    //MAIN
}

This function returns an object of type Job and hence can be used to perform inbuilt functions of Job.

import kotlinx.coroutines.*

fun main() = runBlocking { //MAIN
        //MAIN
    val job : Job = launch { //inherits MAIN
        println("Running in ${Thread.currentThread().name}")
    }
    job.join() //will make current thread wait for job to finish
    job.cancel() //cancel coroutine
    //MAIN
}

Use Case: Fire & Forget Tasks like updating UI, etc.

  1. async

Creates a coroutine in the scope of the parent Coroutine Thread but also returns a result.

import kotlinx.coroutines.*

fun main() = runBlocking { //MAIN
        //MAIN
    async { //inherits MAIN
        println("Running in ${Thread.currentThread().name}")
    }
    //MAIN
}

This function returns an object of type DeferredJob which is a subclass of Job

import kotlinx.coroutines.*

fun main() = runBlocking { //MAIN
        //MAIN
    val jobD1 : Deferred<Unit> = async { //inherits MAIN
        println("Running in ${Thread.currentThread().name}")
    }
    jobD1.join() //will make current thread wait for job to finish
    jobD1.cancel() //cancel coroutine

    val jobD2 : Deferred<Int> = async { //inherits MAIN
        println("Running in ${Thread.currentThread().name}")
        12 //returning value 
    }
    jobD2.join() //will make current thread wait for job to finish
    jobD2.cancel() //cancel coroutine
    job2.await() //make thread wait for coroutine result 
    //MAIN
}

Use Case: Perform computation and await their result

  1. runBlocking

Creates a Coroutine that blocks the current thread until the coroutine inside it completes.

Coroutine Cancellation

Coroutine Cancellation is required when its execution is too time consuming, unnecessary or undesirable. However cancelling a coroutine is only possible when the coroutine is cooperative.

val job = launch { /* */ }
job.cancel() //cancels a job if it is cooperative
job.join() //waits for the job to finish

job.cancelAndJoin() 
//if the job will be cooperative, it will be cancelled. 
//else it will wait for the job to finish

Two Methods of making a Coroutine Cooperative :

  1. Periodically using suspending functions given by kotlin like delay(), yield(), withContext(), as they check for coroutine cancellation.

     fun main() = runBlocking{ //Main
         val job = launch{  //T1 
             for(i in 0..500){  
                 print(i) 
             }
         }
         job.join()  //wait for T1
     }
    
     //prints from 0 to 500
    
     fun main() = runBlocking{ //Main
         val job = launch{  //T1 
             for(i in 0..500){  
                 print(i) 
                 delay(50) //makes coroutine on T1 cooperative
                 //yield(50)  : if don't want coroutine to delay
             }
         }
         delay(200)
         job.cancelAndJoin() //wait for T1
     }
    
     //prints only some numbers eg: from 0 to 23.
     //as coroutine cancelled after 200ms
    

    However, when a coroutine gets cancelled with the delay() being responsible for it. The delay() function throws a cancellation exception which is a subclass of exception.

     val job : Job = launch(Dispatchers.Default){
         try{
             for(i in 0..500){
                 print(i)
                 yield(50)
             }
             delay(5)
             job.cancelAndJoin()
         }
         catch(e: CancellationException){
             print("exception caught")
         }
     }
    
  2. Explicitly check for cancellation status within the coroutine using the CoroutineScope.isActive boolean value.

     val job = launch(Dispatchers.Default){
         for(i in 0..500){
             if(!isActive) break
             print(i)
         }
         delay(10)
         job.cancelAndJoin()
     }
    
     //using 
     //return @launch 
     //instead of break will return too.
    

Timeout in Coroutines

Timeout allows us to set a time limit for the execution of a coroutine. Surpassing that time limit will automatically cancel the coroutine.

Using withTimeout() coroutine builder :

import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        withTimeout(2000) { // Timeout set to 2 seconds
            //statements
        }
    } catch (e: TimeoutCancellationException) {
        println("Timeout occurred!")
    }
}
//The withTimeout() functions throws a timeout cancellation 
//exception if the coroutine exceeds the time

Using withTimeoutOrNull() coroutine builder :

import kotlinx.coroutines.*

fun main() = runBlocking {
    val result = withTimeoutOrNull(2000) {
        //statements
        "Done" // Return value if completed in time
    }
    println("Result: $result") 
}
//returns null if the coroutine exceeds the timelimit
//and doesn't throw an exception

Composing Suspending Function

Kotlin Coroutines allows us to make suspending functions that perform task sequentially or concurrently

suspend fun task1(): String {
    delay(1000) 
    return "Task 1 Completed"
}

suspend fun task2(): String {
    delay(1000) 
    return "Task 2 Completed"
}
  1. Sequential Execution : by default tasks are executed one after another in the order they are called. Each suspending function waits for the previous one to complete before starting.
import kotlinx.coroutines.*
fun main() = runBlocking {
    val result1 = task1()
    val result2 = task2()
    println(result1 + result2) 
    //three statements take total of 2000ms
}
  1. Concurrent Execution : tasks are started simultaneously & execute in parallel. the async function launches separate child coroutines for each task, so the total time taken is the time the longest task requires. We use async as we here return a result.
fun main() = runBlocking {
    val deferred1 = async { task1() }
    val deferred2 : Deferred<Stirng> = async { task2() }
    println(deferred1.await() + deferred2.await())
}
  1. Lazy Coroutine Execution : we can use the same concept for launch and async
val lazyJob1 = async { task1() }
val lazyJob2 = async { task2() }
//task1() & task2() will execute even if we don't use
//lazyJob1 & lazyJob2

val lj1 = async(start = CoroutineStart.LAZY) { task1() } 
val lj2 = async(start = CoroutineStart.LAZY) { task2() }
//task1 task2 won't execute
print(lj1 + lj2) //task1 & task2 will execute now

Coroutine Scope

Each coroutine has its own scope in Kotlin. When inside a coroutine scope, the this keyword refers to the current scope of the coroutine.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Current scope: $this") // Refers to runBlocking's CoroutineScope
    launch {
        println("Child scope: $this") // Refers to the CoroutineScope of launch
    }
}

//Output: 
//Current scope: BlockingCoroutine{Active}@<hashcode>
//Child scope: StandaloneCoroutine{Active}@<hashcode>

Coroutine Context

Set of elements that define the coroutine. Each coroutine has a unique coroutine scope but multiple coroutine can have the same coroutine context.

Components :

  1. Dispatcher : determines the thread that the coroutine uses. a) Dispatchers.Default : optimised for CPU intensive tasks b) Dispatchers.IO : optimised for I/O tasks c) Dispatchers.Main : optimised for UI tasks on the main thread d) Dispatchers.Unconfined : starts coroutine in current thread

  2. Job : represents the coroutine and allows cancellation, modification

  3. Coroutine Name

fun main() = runBlocking{     //MAIN
    //Without Parameter : 
    //inherits context of parent coroutine 
    //even after delay, runs on same thread
    launch{
        //MAIN
        delay(1000)
        //MAIN
    }

    //With Parameter : 
    //gets it own context at global level, 
    //gets seperate background thread
    //after delay, may get another thread
    launch(Dispatchers.Default){
        //T1
        delay(1000)
        //T1 or other
    }

    //With Parameter : 
    //inherits context of parent coroutine 
    //after delay, gets another thread
    launch(Dispatchers.Unconfined){
        //main
        delay(1000)
        //T1
    }

    //using coroutineContext : 
    //inherits context of parent coroutine 
    //even after delay, runs on same thread
    launch(coroutine){
        //MAIN
        delay(1000)
        //MAIN
    }
}