"Effortlessly Manage Concurrency with Kotlin Coroutines: A Step-by-Step Tutorial"

ยท

6 min read

Coroutines. Hmm. What are they?

Coroutines are lightweight threads built and running on top of underlying JVM threads. Coroutines can be thought of as simple threads without much memory footprint. Surprisingly coroutines can switch threads and suspend work.

Using coroutines

To use a coroutine, we must create a so-called coroutine scope. This is a context in which the work (we need to execute) will be launched. Think of this like an environment where we define the control and what needs to be done by the coroutine.

I tend to think of a coroutine scope as a construction where there are many workers under the control of a particular head. Each worker waits for instructions from the head of the task. They can then go ahead and carry out the assignment.

Types of coroutine scopes

Coroutine scopes come in several forms all provided through the kotlinx.coroutines library. These are:-

  1. GlobalScope - alive during the lifecycle of an application. In Android, this coroutine is alive as long as the app process is running

  2. runBlocking - sounds dirty already ๐Ÿ˜ฌ doesn't it? You are right. It is blocking in nature which well goes without saying - prevents execution in other threads until it finishes work. You might not use this one quite often so no fuss.

  3. CoroutineScope - runs as long as its children are alive. When the children end their work, the coroutine ends too.

  4. lifecycleScope - attached to the lifecycle of a fragment/activity.

  5. viewModelScope - equals the scope of the ViewModel it is running in.

Too much talk. Show me the code.

Building our first coroutine.

Creating a coroutine has never been easier. We use the builder pattern and can achieve this in two ways. Using the launch { } or the async { } block. These two lambdas create a context.

fun main(args: Array<String>){
    launch {
    //code
    }

    async {
    // code
    }
}

But why two, when one could do the job? Oh, not too fast?

Here's the catch. launch { } returns a job (more on jobs in the next section) while async { } returns a Deferred<T>. In short, we want some coroutine to execute some code for us. That's alright. But what if we want to return a result out of the coroutine execution? That's where async comes to the rescue.

fun main(args: Array<String>) = runBlocking {
    launch {
        // this code returns a job
    }
    val someValue = async {
        // this code returns a Deferred<T>
    }

    // let's consume the value
    someValue.await()
    // use some value to do something else
}

We're using runBlocking { } to create a coroutine scope since we need to be in one before calling the launch { } and the async { } lambdas.

Job

A job is a cancellable thing. It runs a specified block of code and completes it upon completion of this block. Jobs give us handles so we can perform specific operations like join(), start(), cancel() etc.When a job is canceled, all of its children's jobs are canceled too.

Drawing 2023-02-26 20 04 15 excalidraw

To create a coroutine job in kotlin, we must do so inside a coroutine builder. For that, we use the launch{} lambda, which returns a job.

fun main(args: Array<String>) {
    runBlocking {
        val job = launch {
            println("Job started")
            delay(timeMillis = 1000)
        }
        job.invokeOnCompletion {
            println("Job completed...")
        }
    }
}

// output
Job started
Job compeleted...

This block creates a job inside the runBlocking {} coroutine scope. We're using runBlocking { } since we need a coroutine scope in which our tasks will run in. The code prints a message after which it delays for 1 second before printing the message Job completed.

Creating multiple jobs

Creating multiple jobs is commonplace. The jobs will run typically on different threads so the developer is at liberty to harness the real power of coroutines in multi-threading. To do that we can create multiple launch blocks where the jobs will run independently:

fun main(args: Array<String>) {
    runBlocking {
        val job1 = launch {
            println("Job 1 started")
            delay(timeMillis = 3000)
        }
        val job2 = launch {
            println("Job 2 started")
            delay(2000)
        }
        job1.invokeOnCompletion {
            println("Job 1 completed")
        }
        job2.invokeOnCompletion {
            println("Job 2 completed")
        }
    }
}

This block outputs:

Job 1 started
Job 2 started
Job 2 completed
Job 1 completed

invokeOnCompletion runs after the job completes. Job 1 prints Job 1 started then delays for 3 seconds. This moves the control to the next job. Job 2 prints Job 2 started and was delayed for 2 seconds. This is less time than the first job hence it job 2 will complete first. This prints Job 2 completed. In the end, the message Job 1 completed is printed.

The join() method

The join() method forces a job to complete before heading over to other parts of the program. This can be seen in the following:-

fun main(args: Array<String>) {
    runBlocking {
        val job1 = launch {
            println("Job 1 started")
            delay(timeMillis = 3000)
        }
        val job2 = launch {
            job1.join() // Job 1 has to complete first
            println("Job 2 started")
            delay(2000)
        }
        job1.invokeOnCompletion {
            println("Job 1 completed")
        }
        job2.invokeOnCompletion {
            println("Job 2 completed")
        }
    }
}

This will output:-

Job 1 started
Job 1 completed
Job 2 started
Job 2 completed

Waiting for a result from a coroutine

As it sounds, this pattern allows us to execute some asynchronous tasks and return some values. This is done using the async { } builder. Inside the block, a value is returned as a result of the execution. This returns a Deferred<T>. When we need to use the value somewhere else, we call the await() method on the Deferred<T> object.


fun asyncAwaitLaunchFunc() {
    runBlocking {
        var count = 0

        //execute a deferrable block and return a result as deferred
        val deferredCount = async {
            delay(timeMillis = 2000)
            count += 10
            return@async count
        }

        val job1 = launch {
            // await for the deferrable result launched with aysnc
            val countValue = deferredCount.await()
            delay(timeMillis = 1000)
            count = countValue * 5
        }

        job1.invokeOnCompletion {
            println("Job 1 finished...count is $count")
        }
    }
}

// output
Job 1 finished...count is 50

In the async { } block we delay for 2 seconds and then increment the count by 10. The result is then returned as Deferred<Int>. Inside the launch { } block, we use await() to wait for the value and then delay for another 1 second before multiplying the value by 5. Finally, we print out the result using the invokeOnCompletion { } method. Again, this will run upon the completion of this job.

That's it for this article. In the next one, we look at Dispatchers and context switching. All the code for this section can be obtained at GitHub.

ย