1. Introduction
To convert asynchronous code written in other JVM languages and other styles to the coroutine style, we can utilize the suspendCoroutine() and suspendCancellableCoroutine() functions provided by Kotlin. These functions allow us to convert callback and future approaches to suspend functions. By doing so, we can easily leverage the powerful capabilities of Kotlin coroutines and integrate existing libraries and system calls.
There are quite a few libraries written in Java that provide asynchronous capabilities. However, they lack awareness of Kotlin coroutine concepts, such as Continuation. As a result, there are next to no pre-existing libraries that are natively built for suspend functions and coroutines. Instead, they provide the ability to set a callback for the promised result or else return a CompletableFuture. Both approaches fit poorly into the preferred Kotlin syntax.
Kotlin coroutines are a powerful tool for writing asynchronous and concurrent code in a more natural and intuitive way. Unlike traditional threading models, which can be complex and error-prone, coroutines allow developers to write asynchronous code without making it more complex to read.
In this article, we are going to look into the properties and usage of suspendCoroutine()/suspendCancellableCoroutine() functions.
2. Problems with Callback Functions
Using callbacks can lead to code that is difficult to read, understand, and maintain. This is because the code ends up becoming nested, with many levels of callbacks, creating a situation commonly known as “callback hell”.
Callback hell can make it difficult to understand the control flow of the code, which can lead to bugs and makes it hard to modify the code in the future. It also makes it harder to reuse code because the code is tightly coupled to the specific callback-based API it is using.
Let’s see an example of callback hell in Kotlin code:
fun loadData(callback: (result: Result<String>) -> Unit) {
loadDataFromServer { serverResult ->
if (serverResult.isFailure) {
callback(serverResult)
} else {
loadDataFromCache { cacheResult ->
if (cacheResult.isFailure) {
callback(cacheResult)
} else {
processResult(serverResult, cacheResult) { processedResult ->
saveResult(processedResult) { saveResult ->
if (saveResult.isFailure) {
callback(saveResult)
} else {
callback(processedResult)
}
}
}
}
}
}
}
}
As we can see, this pyramid of code is difficult to understand. It’s not apparent that we always do the same thing to each of our failures. It’s not apparent either that the only way to submit a Result.Success into the callback is to pass through four steps:
- Load data from the server
- Load data from the cache
- Process both parts together
- Save the result
There are certainly other conventions to describe an asynchronous process other than functions that accept callbacks. There are custom implementations of the CompletableFuture concept, predating Java 8. Or else we can utilize a simple Future and poll it regularly for the result. However, callbacks are by far the most ubiquitous, and they’re also quite simple to understand by themselves, so they make for a really good example.
3. Meeting suspendCoroutine()
The suspendCoroutine() function allows us to construct a suspend function from a callback-based API. It takes a lambda with a Continuation parameter as an argument. The function uses this Continuation object to suspend the current coroutine until the callback is called. Once this happens, the Continuation object resumes the coroutine and passes the result of the callback back to the calling code:
suspend inline fun <T> suspendCoroutine(
crossinline block: (Continuation<T>) -> Unit
): T
Let’s explore why the lambda parameter type is called Continuation. It might be a bit confusing to think about continuing something before we’ve even started it. Any suspend call in a coroutine represents a suspension point. The execution of a coroutine might therefore pause, to continue later. The Continuation object represents the state of the call at the time of suspension.
By definition, a call to any suspend function may yield control to other coroutines within the same coroutine dispatcher. So this is exactly what happens with the function that we’ve just constructed with the help of the suspendCoroutine() call.
The suspendCancellableCoroutine() works similarly to the suspendCoroutine(), with the difference being that it uses a CancellableContinuation, and the produced function will react if the coroutine it runs in is canceled.
3.1. Converting CompletableFuture to a Suspending Function
Now, let’s see how this approach works in the Kotlin libraries if we use a function producing CompletableFuture:
public suspend fun <T> CompletionStage<T>.await(): T {
val future = toCompletableFuture() // retrieve the future
// fast path is omitted here for brevity,
// check the source code of kotlinx-coroutines-jdk8 library for the full version
// slow path -- suspend
return suspendCancellableCoroutine { cont: CancellableContinuation<T> ->
val consumer = ContinuationConsumer(cont)
whenComplete(consumer)
cont.invokeOnCancellation {
future.cancel(false)
consumer.cont = null // shall clear reference to continuation to aid GC
}
}
}
We can see that the conversion is quite simple: Inside the continuation lambda, we subscribe to the completable future when it’s done.
4. Converting Functions With Callbacks Into Suspending Functions
The callback-based procedure avoids blocking the thread for the whole time it needs for I/O by taking the actions that we need to perform afterward as an argument. It executes the callback later when it has the result. Such a function will return the control into its flow of instructions immediately, or nearly so: as soon as it has sent the I/O request and scheduled the callback. This allows us to call more functions with callbacks on the same thread.
The suspend function will, in this case, pause the execution instead, allowing other coroutines to use its computational resources. The flow of instruction within the caller coroutine will pause until we have both the result of the asynchronous call and the thread in which to handle it. This allows us to write the actions that deal with the result in the same instruction sequence:
suspend fun loadDataSequential(callback: (result: Result<String>) -> Unit) = try {
val serverData = loadDataFromServerSequential()
val cacheData = loadDataFromCacheSequential()
val processedResult = processResultSequential(serverData, cacheData)
saveResultSequential(processedResult)
callback(Result.success(processedResult))
} catch (ex: Exception) {
callback(Result.failure(ex))
}
4.1. Refactoring for the Sequential Syntax: Fine Details
We can achieve this fantastic change in readability in the example above by wrapping our callback-based functions inside suspendCoroutine(), for instance:
suspend fun loadDataFromServerSequential(): String =
suspendCoroutine { continuation -> loadDataFromServer(continuation::resumeWith) }
That’s it: Continuation even has a resumeWith() method that accepts a Kotlin Result. So, instead of performing a side-effect by calling a callback, our new function will return a result. Such a modification will lead to better readability and testability.
Additionally, each function inside loadDataSequential() will throw an exception instead of returning Result.Failure if something goes wrong. We can deal with each exception in the catch clause. However, in practice, successes usually happen much more often. Therefore, it pays to make the so-called “sunny day” scenario as readable and clear as possible.
5. Conclusion
In this article, we’ve looked at the suspendCoroutine() and suspendCancellableCoroutine() functions. Kotlin provides them for integrating existing code that was not originally designed with coroutines in mind. We have explored how these functions work and the benefits they provide. Among the benefits are improved readability, higher-level abstractions, and improved performance. We also looked at a practical approach to turn a callback-based API into a set of suspend functions.
Suspending functions and coroutines allow us to write asynchronous code that is as readable and intuitive as synchronous code. By avoiding the use of callbacks, we can greatly reduce the complexity of our code, making it easier to read, understand, and maintain. The asynchronous code allows us to use resources more efficiently than traditional threading models in I/O-bound cases. By avoiding the overhead of creating and managing threads, we can reduce the overall memory and CPU usage of our application.
All the code examples, as usual, are in our repository over on GitHub.