Users expect programs to be fast and responsive. Meanwhile, many operations are naturally slow and may require a lot of CPU, memory, disk, and network resources. The solution to that controversy is asynchronous programming: async programs are not sequential but rather react to events (e.g., when the computation is done, or a resource becomes available, or the user clicks a button). But there is a price to pay — async code is hard to wire and understand. Let's see what the most common approaches are.
Problem example and Threads
Assume this simple code sends some large piece of data to the Internet:
fun sendData() {
val data = prepareRequest() // long-running operation
val result = submitRequest(data) // another long-running operation
processResult(result)
}
The sendData function has two long-running operations inside, so it's a long operation itself. If we call sendData from the main thread, it will completely block the UI until it's done, so one common solution is to run it on a separate parallel thread. However, now we need to deliver the result back to the main UI thread; moreover, threads consume extra memory, so we can create only a limited number of them. Delivering the result back to the main thread is not a trivial task either, and we won't get into such details here.
Callbacks
A different solution is to let one function call another function back when it's done. We pass the continuation function to the first one as an argument. This argument is called a callback.
fun sendData() {
// first call inside `sendData` is the same, it executes `prepareRequest` immediately
prepareRequest { data ->
// code inside this lambda by convention will be executed by `prepareRequest` when it's done
submitRequest(data) { result ->
// another lambda is nested inside `submitRequest` and will be called when it's done
processResult(result)
}
}
}
The last parameter of prepareRequest is now the callback lambda that calls the submitRequest function with prepared data as the first argument and another callback lambda that calls processResult as the last argument. When prepareRequest is done, it calls the given lambda, so does submitRequest when it's done. An important difference is that the top-level sendData function now returns immediately, and the nested code will be executed when the data is ready. So now we have a proper asynchronous code and the operations are executed when the data is ready.
Note that this fairly simple code is already deeply nested and we don't even have error handling in place. In real life, this tends to grow out of control pretty fast. We can pass callback functions as parameters (callback lambdas are just parameters), but that obscures the logic of the code and also becomes messy when more than one operation is performed.
fun sendData() {
prepareRequest { data ->
// `processResult` callback can be passed directly if the input arguments match,
// but we can't pass `submitRequest` the same way to `prepareRequest`
// because it doesn't know which callback `submitRequest` should call when done;
// only the root function `sendData` knows that
submitRequest(::processResult)
}
}
It would be much nicer if we could have the same async behavior expressed in a more linear way.
Futures and Reactive extensions
There are multiple libraries and SDKs that do just that with so-called Futures/Promises or Reactive Extensions (we won't dive into details here, check out the links if you want to learn more). A typical promise code would look like that:
fun sendData() {
prepareRequest()
.thenCompose { data -> // this line means "execute this lambda" when done without errors
submitRequest(data)
}
.thenAccept { result -> // this is similar but also means it's the final operation
processResult(result)
}
}
Now submitRequest and processResult are on the same level again (even if we still have an extra nesting), but we can now add more then steps in between without going deeper. It looks nicer, but it has its own problems: errors are still not easy to handle and traditional if/else and loops are not supported out of the box. RX libraries (RxJava, RxJS) partially solve that last problem by treating each result as a stream of values and providing a rich set of helper functions (called operators) to work with that stream, but that adds even more complexity.
Coroutines and Suspending functions
Here is where coroutines come into play. A coroutine may be thought of as a lightweight thread that performs suspendable computations. It means the function can suspend its execution on some blocking operation and resume later when the operation is done, just like with callbacks.
suspend fun sendData() {
val data = prepareRequest() // suspending function
val result = submitRequest(data) // also suspending function
processResult(result)
}
Our example now looks exactly like in the beginning, except for the extra suspend keyword, which tells the compiler that this function may suspend itself.
Only suspending functions can call other suspending functions.
We can use all familiar keywords like if/else/for/try/catch again, but the main thread blocking is not an issue anymore. Moreover, this solution doesn't depend on any 3rd party library or even the platform anymore (it will work even with JS backend, which doesn't have threads), and the paradigm is very similar to the well-known goroutines (Go) or async/await (C# and others).
Conclusion
Coroutines provide a way to perform async operations without changing the familiar imperative paradigm. The code stays readable, while the lightweight nature of coroutines allows us to start thousands of them when we need to without worrying about memory consumption.