More on Coroutines

Introduction

We have already looked at coroutines briefly in the context of Android. These notes will extend upon that and look at some additional features of coroutines.

To revise: Kotlin allows you to achieve the same effect as threads with coroutines. A coroutine is a function that can run in either the foreground or the background, can switch between foreground and background without having to write asynchronous code, and can be suspended (paused) to allow another operation to take place.

We also saw that coroutines must be launched from a scope. The scope represents what the coroutine is launched from, e.g. a console-mode program or an Activity. The coroutine has a lifetime equal to the lifetime of its scope. For example a coroutine launched from the Android lifecycleScope means the coroutine has the scope of the parent component (e.g. activity or fragment) and will stop when the parent is destroyed.

Coroutines using basic command-line Kotlin

For this topic we will use command-line Kotlin. You can use the IntelliJ IDEA IDE to try out these examples, but if you do, you will need to add the coroutines library as a dependency to build.gradle (choose a Gradle project) because it's separate to the Kotlin standard library.

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
If you have access to a machine with the command-line, you can also install Kotlin standalone.

There are some exercises here. As well as doing the exercises, try out the examples to get a feel of what is going on.

Basic example

Here is an example of a basic Kotlin program which launches a coroutine. The coroutine is launched from the global scope, which is the scope of the running program.

fun main() {
    GlobalScope.launch {
        for(i in 0..100) {
            println(i)
        }
    }
    println("Not in the coroutine")
}
This code is launching a coroutine which counts to 100. The code inside launch will run in the background, so Not in the coroutine will appear before the launch block has finished. But actually in this example, the main() function does not wait for the coroutine to finish, so you never see the output of the for loop. This is because a coroutine launched from a given scope will terminate when its parent scope terminates. Here, the parent scope is the GlobalScope - the scope of the program - and thus will terminate when the program terminates.

If you add

Thread.sleep(1000)
after the "Not in the coroutine" println, then the GlobalScope will pause 1000 milliseconds and will allow the coroutine to finish.

Using join() to wait for a coroutine to finish

The other approach to the above would be to tell the GlobalScope to wait for the coroutine to finish. We could use join() for this. For example, would this code work?

fun main() {
    val coroutine = GlobalScope.launch {
        for(i in 0..100) {
            println(i)
        }
    }
    println("Not in the coroutine")
    coroutine.join()
}
Even though it looks sensible, this will not work, you will get a compiler error informing you that join() can only be called in another coroutine. The problem is, our main() is not a coroutine; it is just a regular function. However, You can turn main() into a coroutine by assigning it to the result of a runBlocking call. runBlocking launches a coroutine and "blocks the current thread until all tasks inside that coroutine are finished" (Leiva). Here, this means that the main thread blocks (cannot do any other tasks) until the coroutine launched with runBlocking has finished. This will mean the program will only complete when the runBlocking coroutine has finished. It's important to note that runBlocking() will create a new scope, separate from the global scope.
fun main() = runBlocking {
    val coroutine = GlobalScope.launch {
        for(i in 0..100) {
            println(i)
        }
    }
    println("Not in the coroutine")
    coroutine.join()
}
This will work as expected, i.e the join() call will wait for the inner coroutine to complete before finishing. We can legally call join() here, as it's being called inside a coroutine (the one launched with runBlocking).

So we will see:

Not in the coroutine
0
1
...
100
as the output.

Delaying a coroutine

Within a coroutine, we call tell it to delay by a certain number of milliseconds.

fun main() = runBlocking {
    val coroutine = GlobalScope.launch {
        delay(1000) // delay the start of the counter for one second
        for(i in 0..100) {
            delay(100) // delay for 100ms each time the loop runs
            println(i)
        }
    }
    println("Not in the coroutine")
    coroutine.join()
}
This will delay execution in the coroutine by 100 milliseconds each iteration of the loop. delay() is non-blocking, ie. the delay in the coroutine will not delay other strands of execution; for example, here, the coroutine launched with GlobalScope.launch will block, but the main() coroutine will not.

Exercise

  1. Try out the above example. Before running it, ask yourself: when will "Not in the coroutine" appear? Run the program and see if you get the answer you expect.
  2. Launch two separate coroutines from your main() coroutine. One should count from 0 to 50 in steps of 2 (add step 2 to your for loop), pausing for 100 milliseconds each time. The other should count from 100 to 1000 in steps of 100 (add step 100 to your for loop), pausing for one second each time. Write a "finished" message in your main() coroutine when both coroutines have finished.

Suspend functions

Critical to coroutines is the concept of suspending functions. These are marked with suspend and allow code to be suspended within them. withContext() works because it is a suspending function itself. What if we wanted to do the loop coroutine (with delay, as in the previous example) from a function from a coroutine? Would this work?
fun main() = runBlocking {
    val coroutine = GlobalScope.launch {
       doLoop()
    }
    println("Not in the coroutine")
    coroutine.join()
}

fun doLoop() {
     for(i in 0..100) {
        delay(100)
        println(i)
     }
}
This would cause a compilation error, because delay() can only be called within a coroutine.Even though doLoop() is called from within the coroutine launched with GlobalScope.launch(), it will not work as doLoop has to be declared as a suspend function. A suspend function is a function which is capable of suspending a coroutine, as delay() does here. So we need to rewrite our code as follows:
fun main() = runBlocking {
    val coroutine = GlobalScope.launch {
       doLoop()
    }
    println("Not in the coroutine")
    coroutine.join()
}

suspend fun doLoop() {
     for(i in 0..100) {
            delay(100)
            println(i)
     }
}

Coroutines terminate when the scope used to launch them terminates

Consider this example:

fun main() = runBlocking{
    GlobalScope.launch {
        for(i in 0..10) {  
            delay(100)
            println(i)
        }
    }
    println("Not in the coroutine")
}
As we have seen, this example will not wait for the loop to finish, as we did not use join(). But what does the GlobalScope actually mean? It is the global scope, i.e. the scope of the running program. As we have seen, a coroutine launched in a given scope will be terminated when its parent scope (here the running program) is terminated. So when the program finishes, the coroutine will terminate even if the loop hasn't finished iterating yet.

Can we deal with this without using join()? Yes - in fact we can - we can launch the coroutine using plain launch, not GlobalScope.launch. This is because, if the scope is not specified, the given coroutine will be launched in the scope of its parent coroutine. As the parent coroutine is the main() coroutine launched with runBlocking, it will mean that the child coroutine (the one which does the loop) will also execute in the runBlocking scope. So it won't finish until the main() coroutine has finished.So if we do :

fun main() = runBlocking{
    launch {
        for(i in 0..10) {  
            delay(100)
            println(i)
        }
    }
    println("Not in the coroutine")
}
the runBlocking scope will wait for the coroutine to finish before finishing itself because the coroutine is now running in the scope of runBlocking rather than the program scope.

launch() is a method of CoroutineScope therefore it must be called within a coroutineScope or be called from GlobalScope.launch. It takes a block of code to run - a function which will be run as the coroutine.

Making main() a suspend function

As an alternative you can make main() a suspend function, which allows you to launch coroutines in the GlobalScope (but unlike runBlocking(), does not give you a separate scope):

suspend fun main() {
    GlobalScope.launch {
        // ...
    }
}

Jobs

What is actually returned when we launch a coroutine with launch()? We are returned a Job object. This can be used to control the coroutine, for example, cancel it. The join() method you've seen already is a method of Job; this will cause the outer coroutine (the one launched with runBlocking() to wait until the child coroutine that the job refers to has finished.

Concurrent coroutines

One of the key things that coroutines allow is concurrency. We can launch several coroutines in parallel by launching multiple jobs. The code below shows this and also shows the use of joinAll() to wait for all coroutines in a list to complete. Note how we write a function launchJob() to launch a coroutine from a given scope.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val jobs = listOf(
        launchJob(GlobalScope, 0, 10, 1, 1000L),
        launchJob(GlobalScope, 0, 20, 2, 4000L)
    )
    jobs.joinAll()
    println("All jobs finished")

}

fun launchJob(scope: CoroutineScope, start: Int, end: Int, step: Int, delay: Long): Job {
    return scope.launch {
        for (i in start..end step step) {
            // Use formatted text, %d means whole number (int or long), %4d means use a field 4 characters wide
            println("Counting from %4d to %4d, step %2d, delay %4d ------- %d".format(start, end, step, delay, i))
            delay(delay)
        }
        println("Counting from %4d to %4d, step %2d, delay %4d ------- COMPLETED!").format(start, end, step, delay))

    }
}

Exercise - Jobs

  1. Take this code:
    fun main() = runBlocking{
        launch {
            for(i in 0..10) {  
                delay(1000)
                println(i)
            }
        }
        println("Not in the coroutine")
    }
    
    Modify the example so that the main coroutine waits for 5 seconds after launching the loop coroutine, and then cancels the loop coroutine. Job has a cancel() method. Does it still count to 10?
  2. Modify the concurrency example to add a third job which counts from 0 to 5 in steps of 1 with a 10-second delay.
  3. Launch each job from the runBlocking coroutine's own scope, rather than the GlobalScope. (Hint: runBlocking takes a lambda function, with the scope as its single parameter). If you do this, can you remove anything from the code?

Reference