Android already had available concurrency mechanisms in the Java language, so why add Kotlin Coroutines? And why is Android so intertwined with the Kotlin language?
Let’s take a closer look.
As a Java developer, I know Threads, why do I need to learn about coroutines when doing Android development?
Coming from the Java world, it is sometimes difficult to understand where the need for coroutine abstractions. The tools and concepts of coroutines mostly already exist in Java. Coroutines are implemented using Threads anyway, so why not use Threads directly when writing Kotlin code?
This question sits atop is the larger question of Kotlin itself. The first Android applications were written only in Java. Why did Google (the big company behind the Android platform and APIs) turn to Kotlin in the 2010s instead of sticking to Java? At the time, Kotlin barely existed, whereas Java possessed the advantages of maturity, tooling, and ecosystem.
Kotlin was not created by Google for Android, as some assume. Kotlin emerged from the Czech software company, JetBrains, the people who make IntelliJ IDEA and Android Studio. JetBrains set out to write a new language almost from scratch, an « improved Java ». Somehow, they convinced Google that using it for Android would be a good idea. Since then, Google has invested enormously in Kotlin. Android today seems unseparable from Kotlin fundamentals such CoroutineScope, high-order functions, and extension functions, just to name a few.
What even are threads in Java? A refresher
You may skip this section if your eyeballs have already rolled up when reading the title.
Why would you need threads?
By default, a simple Java application will run on a single thread. A single thread can only execute a single block of code at a time. The order of execution is quite fixed, and the outcome is predictable. Everything is (mostly) simple 🙂
However, when an application can receive user inputs, this can create the need for a second thread of execution. This input thread can interact and interweave with « inner » code execution. Or worse, there is still only one thread, and the user may see the UI freeze up while processing happens.
When an application can receive network events, this also usually creates one or more new Threads. Again, these these threads may interact with inner code execution.
The code of an application can also create Threads specifically to parallelize processing for long-running tasks.
This could be the case for:
- lengthy calculations (image processing comes to mind);
- hardware IO (reading or writing files, reading or writing to a database);
- or network requests.
In a multithreaded context, some code will remain single-threaded, and does not need to handle any synchronization.
The parts of code which modify data and are accessible to multiple threads at the same time need to be made thread-safe. Being thread-safe means that the data modifications are consistent for any given thread. Of course, adding these concurrency safeguards also adds memory and processing overhead, complexity, and bug opportunities.
If there is concurrency, then code which is unsafe to be run by more than one thread at a time. These so-called « critical sections » will require protection. For example, when accessing a unique resource or modifying some variable (cue example with a counter being mishandled by threads). Such mechanisms are called locks, and Java provides locking and unlocking tools in order to achieve thread safety. In Java, this is done with the « synchronized » keyword on the most relevant scope and lock. All objects also have the wait, waitAll, and join methods.
Of course, handling locks can result in one lock blocking another lock, or deadlock. Locking is complicated, impacts performance, and is error-prone.
This strategy for managing concurrency is called « cooperative thread interruption ». Threads cannot be stopped when they are running, but the code they execute should be written in a way which allows them to be interrupted. As you can imagine, it can get messy.
Java concurrent programming
The basic tools of concurrency in Java are the Thread class, and the Runnable and Callable interfaces. Runnable is used to run a task, and Callable to run a task and get a result. Though it is possible to create and start a thread directly in your code, it is often better to dissociate the thread creation from its execution. This is done by having a dedicated “Executor service” which schedules and executes tasks. The executor service also handles thread creation (and pools, execution strategies, and the like).
You configure the executor service based on whether the task is short-lived, long-running, spends a lot of time waiting, etc. Conversely, you may also want to avoid over-consuming hardware resources in your application, and tune your executor service to achieve this.
Kotlin coroutines
Kotlin 1.3 was released in 2018, heralding the addition of coroutines and asynchronous programming in the language.
A coroutine is an instance of a suspendable computation.
Kotlin coroutines does away with the manipulation of individual Threads, by focusing on the concurrent code as being “suspendable”. This notion is similar to Java’s wait / notifyAll concepts. The coder no longer needs to manipulate the Thread or Executor that will run the code.
A high-level description
The coroutine abstraction is an implementation of “structured concurrency”. New coroutines (concurrent code) can only be started in a specific CoroutineScope which also defines the lifetime of a coroutine. When this scope ends, the coroutine is stopped and cleaned up. This avoids threads leaking (using up memory), and errors that appear during thread execution being lost.
The coroutine scope defines a scope for new coroutines. Every “coroutine builder” (like launch, async, etc.) is an extension on CoroutineScope and inherits its coroutineContext to automatically propagate all its elements and any cancellation. The context conventionally contains an instance of a Job, making it possible to call cancel on this job if needed.
A low-level description
The « launch » function starts a new computation that executes the block passed as a parameter.
Coroutines run on top of threads and can be suspended. This means that when performing its block of code, if there is some inactivity, the block is suspended and releases the underlying thread. When a coroutine is suspended, it’s computation’s state is stored in memory. Meanwhile, the thread is free to be occupied by other tasks. When the activity resumes, for example when the network call yields a response, the database finishes a query, computation returns a result, etc., it returns the result. Then, the coroutine is returned to a thread (not necessarily the same one), and it is resumed with the received result.
This suspendable quality is implemented with an internal state machine, so that the code can be called repeatedly and have different actions. In coroutines, the points in the code where suspensions occur are called suspension points. The Continuation object is responsible for managing the execution of the coroutine, including handling suspension and resumption points. It encapsulates the logic needed to suspend a coroutine, save its state, and resume its execution from the point it left off.
Notes on Kotlin concurrency keywords and functions
Low level-concurrency concepts in Kotlin such as “async” and “await” are not Kotlin keywords, nor part of the standard library. Instead, Kotlin provides “suspending functions” (via the « suspend » keyword) to handle asynchronous operations. The library “kotlinx.coroutines” (dependency “kotlinx-coroutines-core”) can be added as a dependency to manipulate concurrency primitives such as “launch”, “async”, etc.
“RunBlocking” and “launch” are called coroutine builders because they set up or use a CoroutineScope to run a block of concurrent code.
But wait, how were Kotlin/Android developers doing concurrency before 2018?
Remember (rhetorically speaking) that Google announced that it was fully supporting Kotlin in 2017. At the time, coroutines did not officially exist in the language. Therefore those early adopters of Kotlin were using the good old Java tools for concurrency. This also means that today’s Android lifecycles were completely overhauled to use Coroutines as they do now.
Running an application on a smartphone at that time meant running with a very limited hardware platform, in terms of memory and CPU. In this context, it would make sense that the creators of the Kotlin language and their friends at Google had a strong incentive to build a concurrency abstraction which helped application writers make sure they used resources efficiently, safely, and cleaned up as soon as resources are no longer needed. Google integrated these concepts very early on in the Android lifecycle components.
Android takes full advantage of the coroutine concept of structured concurrency. In particular, with the lifecycle of the Activity which can be used to define CouroutineScopes, so that when an activity is no longer active, it can cancel any coroutines associated with it.
Direct threading problems that Kotlin coroutines solve
Job cleanup when its parent scope is killed
As often stated, threads are expensive. Creating too many threads can actually make an application underperform in some situations. Threads are objects which impose overhead during object allocation and garbage collection. Making sure these threads are scoped makes it easier to clean them up as soon as they are no longer needed. Unlike Java Threads which can potentially exist as long as the application runs.
Furthermore, the work being done in threads can itself be the source of performance issues. Simply killing these threads is a way to avoid whatever memory and processing issues they may have. In other words, it is still possible to create memory leaks in Kotlin Coroutines, but the concept of forced clean-up once you leave the coroutine’s context curtails the performance impact of such bugs.
RunBlocking and other coroutine builders establish a Coroutine scope. The code that has created the scope will wait until all its children coroutines are done before completing. So, no child Thread is left behind. This is not the case in a Java application. If an application window is closed, the application process may continue running. That is, unless the developper has explicitly added a WindowListener which calls “System.exit() when a windowClosing event is received.
Android as an architecture extensively uses Coroutines and coroutine scopes in all entities of an application’s lifecycle. For example, the coroutines tied to a particular activity or frame can be cleaned up thanks to the job references maintained via their scope. Once their parent is no longer on-screen, the jobs can be cancelled.
Exceptions in coroutines propagate up the call stack like regular function calls.
Coroutine tasks are easier to cancel thanks to the retained Job handles stored in the CoroutineContext.
Simplifying callback hell for asynchronous tasks
With Java, setting up callbacks for events can lead to code that is very difficult to read and maintain. In summary, callback hell occurs when callbacks pile up, making code hard to manage. Modern approaches like Promises, RxJava, or async/await help mitigate this issue by providing cleaner alternatives for handling asynchronous tasks.
This is called « callback hell », and here is an example. The method fetches data from a server. It uses callbacks to handle the asynchronous events when the connection is established, when the file may be read, and when the file data is read.
// ChatGPT Example of callback hell in Java Threads
fetchDataFromServer(new Callback() {
@Override
public void onSuccess(Data data) {
// First callback: Data received from server
readDataFromFile(data, new Callback() {
@Override
public void onSuccess(Data fileData) {
// Second callback: File data read
processFileData(fileData, new Callback() {
@Override
public void onSuccess(Result result) {
// Third callback: Processed result
// ... more nested callbacks ...
}
});
}
});
}
});
There are some solutions in Java to this code-architecture problem.
- Promises and Futures: Use abstractions like Futures to handle asynchronous operations more elegantly.
- RxJava: Libraries like RxJava provide a functional approach, allowing you to compose asynchronous operations using operators and lambdas.
With Kotlin Coroutines, it is possible to write these 3 asynchronous tasks (fetch, read, process) in a more sequential way. See the « main » function in the code below. It sets up the same tasks as the precedent callback example, delegating the work to suspending functions.
import kotlinx.coroutines.*
suspend fun fetchDataFromServer(): Data {
// ... relevant code for fetching
return Data(/* fetched data */)
}
suspend fun readDataFromFile(data: Data): Data {
// ... code for reading data from a file
return Data(/* file data */)
}
suspend fun processFileData(fileData: Data): Result {
// ... code for processing file data
return Result(/* processed result */)
}
fun main() = runBlocking {
try {
val data = fetchDataFromServer()
val fileData = readDataFromFile(data)
val result = processFileData(fileData)
println("Final result: $result")
} catch (e: Exception) {
// ... error handling
}
}
Simplifying and unifying the choice of a thread pool
As stated previously, Kotlin does little bit of gate-keeping around threads. You as the developer are less tempted to create new threads foolishly. Java of course also makes it possible implement the same type of architecture by using Thread pools and executors. However, these tools exist alongside the basic Runnable interface and new Thread(). Kotlin coroutines point you in the direction of not manipulating threads directly, and manages a couple of threadpools itself.
Kotlin pre-defines a small set of thread pools
Coroutine dispatchers are responsible for determining which thread or thread pool a coroutine will execute on. There are several built-in dispatchers available in Kotlin, each designed for specific use cases. The most commonly used dispatchers in Kotlin are the IO dispatcher and the Default dispatcher. The main thread is handled by Dispatchers.Main, but you should never opt to use it for parallel work, as it will impact UI responsiveness.
As its name suggests the IODispatcher handles system input/output (I/O) operations. This dispatcher uses a dedicated pool of threads. This means that coroutines running on the IO dispatcher won’t block the main thread or other coroutines running on other dispatchers. It should be used for blocking operations such as reading and writing files, performing database queries, or making network requests. These do not consume a lot of CPU resources but may take a long time to finish.
By default, the number of threads in the IO dispatcher thread pool is set to either 64 or to the number of CPU cores available to the system, whichever is higher. it is possible to modify the number of threads in the pool by modifying the value of the system property kotlinx.coroutines.io.parallelism.
DefaultDispatcher: this one is used if you do not specify a dispatcher. For example GlobalScope.launch() builder will use it. It is backed by its own pool of threads. The maximum number of threads used by this dispatcher is equal to the number of CPU cores available to the system, and the minimum is two. It is suitable for CPU-bound tasks that require a lot of computation and benefit from parallelism. Short-running calculations and small-scale data processing tasks are the ideal candidates.
When should you pick Coroutines vs threads?
For Android programming, this is simple: when in doubt, use coroutines!
As much as possible, scope your custom threaded code to the appropriate Android lifecycle. This way they can be cleaned up with no additional coding effort on your part.
Beware too many underlying Thread pools
Because threads are involved even when Coroutines are used, this points to a possible performance issue with third-party libraries. Knowing that coroutines are build upon threads and ThreadPoolExecutors means that when you use Network caching libraries (OkHttp, for example), image loading and processing (Coil), task scheduling (WorkManager), and the Android framework itself, they all need thread pools.
If you are not careful, all of these parts of your application can build and maintain their own thread pools. This can sometimes be too much of a good thing. Too many non-shared and redundant ThreadPoolExecutors can also impact memory and processing. It may be necessary to examine just how many threads are created when running your application, and to replace some default pools by injecting your own shared thread pool to those third-party libraries that need it. This article by Chao Zhang describes precisely this process.
Sources
Writing this was very interesting and helped make sense of some things. Here are some useful sources you may want to check out on this topic . And, more selfishly, this is also a bookmark backup of sorts for me.
- https://www.techyourchance.com/why-google-adopted-kotlin-for-android/
- https://kotlinlang.org/docs/coroutines-basics.html (obviously)
- https://betterprogramming.pub/kotlin-coroutine-internals-49518ecf2977
- https://www.baeldung.com/kotlin/io-and-default-dispatcher
- https://kt.academy/article/cc-suspension
- https://chao2zhang.medium.com/reduce-reuse-recycle-your-thread-pools-%EF%B8%8F-81e2f54d8a1d
- https://developer.android.com/topic/performance/threads#how-many-threads-should-you-create
- https://android-developers.googleblog.com/2017/05/android-announces-support-for-kotlin.html
Ping : Android Paging3 Cheat Sheet with Coroutines and Flow - Caravelle Code