Computer scienceProgramming languagesKotlinConcurrency and parallelismConcurrency

Async and Await

10 minutes read

In previous topics, we have discussed the power of coroutines and the importance of properly managing asynchrony to optimize our workflow by making our applications more efficient. In this topic, we will delve into how to use coroutines when we are expecting a result from them.

Deferred

In previous discussions, we have seen the importance of using Jobs and coroutine builders. One of the examples is launch: it does not wait for the coroutine to finish but immediately returns a special handler to the launched coroutine called a Job. The coroutine itself continues working, but we can check the status or even cancel it through a Job object. One of the issues is that we cannot "retrieve" or return a result, as they are designed for fire-and-forget operations.

Like other coroutine functions, we must install its dependencies using Gradle.

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$version")
}

To address this issue, we have a new object called Deferred. The Deferred value is a non-blocking, cancellable future—it is a Job with a result. Deferred has the same state machine as a Job, with additional convenience methods to retrieve the successful or failed result of the computation that was carried out. The result of the Deferred is available once it is completed and can be retrieved by the await method, which throws an exception if the Deferred had failed. The Deferred type extends from Job, meaning it inherits all the behavior and states; additionally, it has the capability to return a deferred result (hence the name Deferred) as the outcome of the execution of a coroutine initiated through the use of the async function.

Async and Await

The async coroutine builder allows us to create a coroutine and returns its future result as an implementation of Deferred. The running coroutine is cancelled when the resulting deferred is cancelled. By default, the coroutine is immediately scheduled for execution (other options can be specified via the start parameter, for example, lazy).

The await method waits for the completion of this value without blocking a thread and resumes when the deferred computation is complete, returning the resulting value or throwing the corresponding exception if the deferred was cancelled. We can also use awaitAll to wait for the completion of given deferred values without blocking a thread. It resumes normally with the list of values when all deferred computations are complete or resumes with the first thrown exception if any of the computations complete exceptionally, including cancellation.

Let's explore the difference between using launch with a Job and using async/await with a Deferred.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Example using 'launch' and 'Job'
    val job: Job = launch {
        // Simulate some long-running operation
        delay(1000)
        println("The 'launch' block has completed.")
    }
    println("Waiting for the 'launch' block to complete.")
    job.join() // This line waits for the coroutine to complete

    // Example using 'async' and 'Deferred'
    val deferred: Deferred<Int> = async {
        // Simulate a long-running operation that returns a result
        delay(1000)
        println("The 'async' block has computed a value.")
        42 // This value will be returned when 'await' is called
    }
    println("Waiting for the 'async' block's result.")
    val result = deferred.await() // This line waits for the result of the coroutine
    println("The result from the 'async' block is: $result")
}

// Output might look something like this:
// Waiting for the 'launch' block to complete.
// The 'launch' block has completed.
// Waiting for the 'async' block's result.
// The 'async' block has computed a value.
// The result from the 'async' block is: 42

Using launch:

  • launch starts a coroutine which is fire-and-forget. It's generally used when you want to start a task and don't need to get a result back.

  • A Job instance is returned, and we can use join() to wait for the operation to complete.

  • The coroutine does not return a value.

Using async/await:

  • async starts a coroutine that is expected to return a result, wrapped in a Deferred object.

  • You can call await() on a Deferred object, which suspends the calling coroutine (without blocking the thread) until the value is ready.

  • After the async coroutine completes, await() retrieves its result.

  • If the computation fails, the exception is thrown at the point where await() is called.

By using async/await, we can retrieve computed values from coroutines, allowing for more complex workflows and concurrent operations.

In this example we use awaitAll to wait for multiple deferred computations to finish. In this scenario, let's say we have multiple data fetch operations that can run in parallel, and we want to wait for all of them to complete and return their results.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Simulate multiple async operations
    val deferreds: List<Deferred<String>> = listOf(
        async {
            delay(1200) // simulate even longer asynchronous work
            "Result 1"
        },
        async {
            delay(1000) // simulate some more time-consuming asynchronous work
            "Result 2"
        },
        async {
            delay(500) // simulate some asynchronous work
            "Result 3"
        }
    )

    // Now we want to wait for all the deferred values to complete
    println("Waiting for all async blocks to complete...")
    val results = deferreds.awaitAll() // This waits for all Deferred objects to complete

    // Once all are completed, print the results
    println("All async blocks are completed. The results are:")
    results.forEach { result ->
        println(result)
    }
}

// The output will be something like this:
// Waiting for all async blocks to complete...
// All async blocks are completed. The results are:
// Result 3
// Result 2
// Result 1

In this example, we create a list of Deferred<String> by calling async three times, each simulating an asynchronous operation that takes a different amount of time to complete. Then, we use awaitAll() to wait for all of these deferred computations to finish.

The awaitAll() function takes a variable number of Deferred objects (or an iterable like in our example) and returns a list of results. It suspends the coroutine where it's used until all the given Deferred objects are complete.

If one or more operations fail with an exception, awaitAll will throw that exception. To handle this scenario, you can wrap the awaitAll call in a try-catch block. This is particularly useful if you want to respond to failures while waiting for multiple asynchronous tasks.

Unlike launch, whose invocations are sequential, the use of async and await allows us to generate a sense of parallelism by enabling us to launch multiple operations concurrently. Therefore, the best way to leverage these constructs is to invoke more than one operation that does not depend on each other, whose results might converge in the call of some additional operation. This is the essence of the concept of parallel decomposition.

Async timeout

The functions withTimeout and withTimeoutOrNull in Kotlin are used to set timeouts on coroutines. If a coroutine needs to complete its execution within a specified time limit, withTimeout can be used, but it will throw a TimeoutCancellationException if the time is exceeded. On the other hand, withTimeoutOrNull behaves similarly, but instead of throwing an exception, it returns null if the timeout is reached, allowing for a more graceful handling of timeouts.

Here's an example with async and await to demonstrate how you might use withTimeoutOrNull:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val result: String? = withTimeoutOrNull(1000L) {
        // We are wrapping our async call with a timeout
        val deferred = async {
            doSomething() // some long-running operation
        }
        deferred.await() // this will either return a result or throw if the timeout is reached
    }

    // Check the result, and handle the case where a timeout occurred
    if (result == null) {
        println("Operation timed out")
    } else {
        println("Operation completed: $result")
    }
}

suspend fun doSomething(): String {
    delay(1200) // simulate a delay longer than the timeout period
    return "Completed"
}

// The output will be:
// Operation timed out

In the example above, doSomething simulates a long-running operation with a delay of 1200 milliseconds, which is longer than the timeout specified (1000 milliseconds). We use withTimeoutOrNull to wrap the deferred computation. If the operation does not complete within the 1000-millisecond timeout, withTimeoutOrNull will return null, and we inform the user about the timeout. If the operation completes on time, result contains the return value and it prints "Operation completed".

This pattern is useful when you want to prevent the coroutine from running indefinitely, and need the result with a time restriction, and it allows you to write code that handles both successful execution and timeout scenarios fluently.

Conclusion

In conclusion, understanding and utilizing Kotlin's launch and async coroutine builders effectively can greatly enhance the performance and responsiveness of applications. While launch is ideal for executing fire-and-forget tasks sequentially without direct results, async is perfectly suited for scenarios that require parallel decomposition. By employing async in conjunction with await, developers can initiate concurrent operations, harnessing the power of parallelism to perform multiple independent tasks simultaneously. This ability to run operations in parallel can significantly reduce overall execution time, especially when tasks are I/O-bound or computationally intensive. Furthermore, employing tools such as awaitAll and timeout functions like withTimeoutOrNull, allows for sophisticated and robust coroutine management, providing mechanisms to handle completion and timeout scenarios gracefully. Overall, mastering coroutines and their control structures is key to writing concise, efficient, and scalable Kotlin code that can handle modern application's asynchronous demands.

Let's put our learning into action with some hands-on tasks. Ready? Let's dive in!

4 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo