Blog Infos
Author
Published
Topics
, , ,
Published

Kotlin coroutines provide a powerful way to write asynchronous, non-blocking code that is easy to understand and maintain. Understanding the nuances of sequential and concurrent execution with coroutines is crucial for writing efficient and error-resilient code. In this article, we will explore different ways of invoking suspend functions sequentially and concurrently, and delve into the potential issues and best practices associated with structured concurrency and GlobalScope usage. Let’s dive into the world of asynchronous programming with Kotlin coroutines and unlock the full potential of concurrent computation and parallel processing.

Sequential by default

Let’s assume that we have two suspend functions that do something useful like calling a remote service or doing a computation. But for the sake of this example, we will just add a delay to them.

suspend fun doSomethingUsefulOne(): Int {
    delay(2400)
    return 33
}

suspend fun doSomethingUsefulTwo(resultOne: Int): Int{
    delay(1500)
    return 6
}

Now if we want to use the result of first function in the second function, we would want these functions to be called sequentially. We can do so by just using a normal sequential invocation because the code in a coroutine, just like in regular code, is sequential by default.

fun calculateTimeTaken() = runBlocking {
    val time = measureTimeMillis {
        val one = doSomethingUsefulOne()
        val two = doSomethingUsefulTwo(one)
        println("Final answer is $two")
    }
    println("Completed in $time ms")
}

Concurrent using async

What if there is no dependency between the two functions, we can call the two functions concurrently in order to get faster results. This is where async comes in the picture.

fun calculateTimeTakenInConcurrentCall() = runBlocking {
    val time = measureTimeMillis {
        val one = async { doSomethingUsefulOne() }
        val two = async { doSomethingUsefulTwo() }
        println("Final answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

Before we move ahead, let’s understand the difference between launch and async in detail

  1. Return Value
  • launch: Returns a Job object, which represents a handle to the launched coroutine. A Job doesn’t produce a result.
  • async: Returns a Deferred objects, which represents a future result of a computation. We can use Deferred to retrieve the result or to handle exceptions if the coroutine produces an error.

2. Use case

  • launch: Used for launching a coroutine when we are not interested in the result
  • async: Used when you need to perform some computation asynchronously and obtain the result. It’s suitable for tasks like concurrent computations, parallel processing, or fetching data from multiple sources.

3. Main thread blocking

  • launch: Will not block the main thread, but on the other hand, rest of the code will not wait for the launch result since launch is not a suspend call.
  • async: Blocks the main thread on the entry point of the await() function in the program.

4. Error Propagation

  • launch: Does not propagate exceptions to the caller. Any exception thrown within the coroutine needs to be handled within the coroutine itself.
  • async: Propagates exceptions to the caller through the Deferred object. We can handle exceptions using await or by invoking Deferred.await() inside a try-catch block.

Note: We will go into the exception handling details in the future blogs.

Lazily started async

Just like we can initialise a variable lazily, we can start a coroutine also lazily. In this case, coroutine will be started only when it’s result is needed, that is, it’s await or start function is being called.

fun calculateTimeTakenInSynchronousLazyCalls() = runBlocking {
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
        println("Final answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

When you run this code, you will see that it took more time than it did without the lazy start. Why did this happen?

This is because we started both our coroutines in the same println statement leading to a sequential behaviour, since await starts the execution and waits for it’s finish, which is not the intended use-case of laziness.

If we want that both the coroutines run concurrently and still want control when to start them we will have to explicitly use start functions.

fun calculateTimeTakenInAsynchronousLazyCalls() = runBlocking {
    val time = measureTimeMillis {
        val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
        val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
        one.start()
        two.start()
        println("Final answer is ${one.await() + two.await()}")
    }
    println("Completed in $time ms")
}

This way, we have defined the coroutines before and then we call the start functions when intended and then use the await function to get the result. By calling the start functions of both the coroutines we are getting concurrent behaviour.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Practical Patterns for Kotlin Coroutines in Production

Unlock the full potential of Kotlin Coroutines with a focused exploration of their practical applications in real-world scenarios. This talk will guide you through essential best practices, demonstrate robust patterns for common asynchronous tasks, and…
Watch Video

Practical Patterns for Kotlin Coroutines in Production

Marcin Moskała
Developer during the day, author at night, trainer
Kt. Academy

Practical Patterns for Kotlin Coroutines in Production

Marcin Moskała
Developer during the ...
Kt. Academy

Practical Patterns for Kotlin Coroutines in Production

Marcin Moskała
Developer during the day, ...
Kt. Academy

Jobs

Do you recall the era when we relied on AsyncTasks in Android? Those functions were callable from any part of the code. However, a significant issue arose with them: if the function or component that initiated the AsyncTask terminated due to an error, the AsyncTask continued to run in the background. This often led to memory leaks, necessitating explicit handling of such scenarios to prevent issues.

Coroutines address this problem by implementing structured concurrency. While they provide a means to opt out of structured concurrency by creating functions in an Async-style manner, it’s strongly advised against relying on this approach.

But still for our knowledge let’s understand how we can do so.

We can define async style functions that call our suspend functions asynchronously using the async coroutine builder using GlobalScope reference to opt out of structured concurrency.

val one = GlobalScope.async { doSomethingUsefulOne() }

Note: When we use GlobalScope, studio will give us an error:

This is a delicate API and its use requires care. Make sure you fully read and understand documentation of the declaration that is marked as a delicate API.

To get rid of this error, we will have to explicitly opt-in to use this API by adding this annotation on your function: @OptIn(DelicateCoroutinesApi::class)

Let’s try to execute our two suspend functions using GlobalScope and see what happens.

@OptIn(DelicateCoroutinesApi::class)
fun calculateTimeTakenInAsyncCall() {
    val time = measureTimeMillis {
        val one = GlobalScope.async { doSomethingUsefulOne() }
        val two = GlobalScope.async { doSomethingUsefulTwo() }
        runBlocking {
            println("Final answer is ${one.await() + two.await()}")
        }
    }
    println("Completed in $time ms")
}

Note a few points here:

  1. calculateTimeTakenInAsyncCall fun is neither suspend nor it is wrapped inside a runBlocking or a coroutineScope like our previous functions (calculateTimeTakenInAsynchronousLazyCalls)
  2. GlobalScope can be used from anywhere and the code inside this scope will execute asynchronously with the invoking code.
  3. Though we can initiate asynchronous actions outside of a coroutineScope using a GlobalScope but waiting for a result must involve blocking or suspending.

Run this code and you will see that there is not much difference in the output.

But then Why async style functions are not recommended?

To understand this, let’s deliberately introduce an error in our doSomethingUsefulTwo function and observe how our code behaves when we utilise GlobalScope by adding a few more print statements.

suspend fun doSomethingUsefulOne(): Int {
    delay(2400)
    println("Task one is getting executed")
    return 33
}

suspend fun doSomethingUsefulTwo(): Int {
    delay(1500)
    // this will cause an Arithmetic Exception as we are diving 5 by 0
    val suffix = Math.floorDiv(5, 0)
    println("Task two is getting executed")
    return 6 + suffix
}
@OptIn(DelicateCoroutinesApi::class)
fun calculateTimeTakenInAsyncCallError() {
    try {
        val time = measureTimeMillis {
            val one = GlobalScope.async<Int> {
                try {
                    doSomethingUsefulOne()
                } catch (e: CancellationException) {
                    println("Task one is cancelled")
                    0
                } finally {
                    println("Task one finally block executed ")
                }
            }
            val two = GlobalScope.async<Int> {
                try {
                    doSomethingUsefulTwo()
                } catch (e: CancellationException) {
                    println("Task two is cancelled")
                    0
                } finally {
                    println("Task two finally block executed")
                }
            }
            runBlocking {
                delay(500)
                println("Final answer is ${one.await() + two.await()}")
            }
        }
        println("Completed in $time ms")
    } catch (e: Exception) {
        println(e)
    }
}

When we run this code, we will see that even if an error occurred before the await functions could get called, the doSomethingUsefulOne suspend functions completes it’s execution in the background as it was running independently and didn’t consider the scope lifecycle.

But in real word applications, this can back-fire in non-trivial ways. That is why, it is recommended to invoke long running operations in a coroutine scope.

Let’s see our output if we run the same code in the above example but using a coroutineScope or runBlocking instead of a GlobalScope

fun calculateTimeTakenInScopedCallWithError() = try {
    runBlocking {
        val time = measureTimeMillis {
            val one = async<Int> {
                try {
                    doSomethingUsefulOne()
                } catch (e: CancellationException) {
                    println("Task one is cancelled")
                    0
                } finally {
                    println("Task one finally block executed")
                }
            }
            val two = async<Int> {
                try {
                    doSomethingUsefulTwo()
                } catch (e: CancellationException) {
                    println("Task two is cancelled")
                    0
                } finally {
                    println("Task two finally block executed")
                }
            }
            delay(500)
            println("Final answer is ${one.await() + two.await()}")
        }
        println("Completed in $time ms")
    }
} catch (e: Exception) {
    println(e)
}

This time our task one got cancelled before completing because coroutineScope follows structured concurrency. When a coroutine inside a scope throws an exception, it is being transferred

When can we use use global scope

There are some scenarios where using GlobalScope might be appropriate.

  1. Top-level operations
    If you have top level operations that need to be performed independent of the application’s lifecycle, such as lagging or monitoring tasks, using GlobalScope might be suitable.
  2. Short-lived tasks
    For short-lived tasks or one-off operations where the coroutine’s lifecycle is not critical, GlobalScope can be used. An example of this could be a coroutine responsible for making a one-time network request during application startup.
  3. Application-level initialisation
    During application initialisation, GlobalScope can be used in coroutines used to perform asynchronous operations that don’t depend on the lifecycle of specific components.

However, even in these scenarios, it is essential to be cautious and consider the implications of using GlobalScope like

  1. Lack of lifecycle awareness
  2. Difficulty in cancellation
  3. Thread pool pollution

That’s it for this article. Hope it was helpful! If you like it, please hit like.

Other articles of this series:

Coroutine Cancellation and Timeouts

Coroutine Basics

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE
Menu