Android Thread & Relation With Coroutine

Raya Wahyu Anggara
11 min readAug 9, 2023

--

What is a thread in Android? And why It’s recommended to use coroutines in Kotlin instead of Thread?

I’ve got many questions like this in the job interview, and they want you to dive deeper into the subject with the follow-up questions.

The term “thread” itself is not specific to Android, it is a broader concept that applies to various operating systems and programming languages. Based on the docs, A thread is a thread(unit) of execution in a program(process). The JVM allows an application to have multiple threads of execution running concurrently, but only one main thread exists for each application.

Each application has only one main process and one main thread

When an application is launched, the system creates a main process for that app, and a thread of execution within that process called the main thread. This thread is very important because it creates your application components (Activities, Services, ContentProviders, BroadcastReceivers), dispatching events to the appropriate user interface widgets, including drawing events. For this reason, the main thread is sometimes called the UI thread, although it's not always the case under special circumstances, see Thread Annotation docs.

And to be noted in Application B’s image above, an Android application may have more than one process e.g. by adding android:process on the manifest. But, it still uses one main process and one main thread. Such as Chrome, although they use Multiprocess Architecture, where different tabs and components of the browser run in separate processes, still use one main thread bound to its main process, to handle all the UI rendering in the entire processes within the application.

And with so much work happening on the main thread, it is better to put the heavier tasks to another thread and not disturb its duty for rendering the UI. Or your main thread will get too busy and an “Application Not Responding” (ANR) error will occur when the main thread is blocked for too long ≥ 5 seconds.

By default, the thread does 3 things: Start, Run some tasks, and Terminate upon task completion. After termination, you can’t use the same thread and need to initiate a new one, or you will get IllegalThreadStateException.

// Example thread
class ExampleThread : Thread() {

override fun run() {
// Perform long running operation, e.g. 8 seconds
for (counter in 1..8) {
sleep(1000)
println(counter)
}
println("ExampleThread Done")

}
}

// Error if you call start on the same thread twice
val exampleThread = ExampleThread()
exampleThread.start() // running
exampleThread.start() // IllegalThreadStateException

// You need to initiate a new one
ExampleThread().start()

But for the main thread, after the task is done, it will not get terminated. It will wait for the next task, as long as the Looper does not quit, the application is running and the process hosting the main thread is alive.

Yes, a thread needs a looper to stay alive.

In Android, only the main (UI) thread has a Looper and a Message Queue associated with it by default. For other threads that you create manually (non-main threads), they don’t have a Looper and message queue associated with them by default. Why?

Because you can run a thread without a looper. a Looper is responsible for handling message queues and allows a thread to process messages sent to it. However, not all threads require message processing capabilities, and in some cases, you might want to use a simple thread for executing tasks without the overhead of a Looper.

Looper

Class is used to run a message loop for a thread, keep the thread alive, and pop work off the queue to execute. For the main thread, the Looper is created automatically when the application starts up and keeps active throughout the entire lifecycle of the application. Looper has three main methods:

  1. Looper.prepare()Initialize the current thread as a looper. This gives you a chance to create handlers that then reference this looper, before actually starting the loop. Be sure to call loop() after calling this method, and end it by calling quit().
  2. Looper.loop()Run the message queue in this thread. Be sure to call quit() to end the loop.
  3. Looper.quit()Quits the looper without processing any more messages in the message queue. Any attempt to post messages to the queue will fail and this method may be unsafe because some messages may not be delivered before the looper terminates. Consider using Looper.quitSafely() instead to ensure that all pending work is completed in an orderly manner.

It’s important to remember that if you create a custom thread with its own Looper and call Looper.loop() on that thread, you should call Looper.quit() or Looper.quitSafely()when you no longer need the thread to prevent any potential leaks or unexpected behavior.

fun startThread() {
val exampleThread = ExampleThread()
exampleThread.start()

buttonTask.setOnClickListener {
Handler(exampleThread.looper).post {
println("ExampleThread")
}
}

buttonToStop.setOnClickListener {
exampleThread.looper.quitSafely()
}
}

class ExampleThread : Thread() {

lateinit var looper: Looper

override fun run() {
Looper.prepare()
looper = Looper.myLooper()!!
Looper.loop()
// triggered after exampleThread.looper.quitSafely() was called
println("ExampleThread end")
}
}

Also calling Looper.quit() will cause the loop to stop and stops message processing. However, the thread itself will not get terminated and can be used for other purposes (see the println on the example code above, you can change it for another task), or it may get terminated by the system if it's no longer needed, based on the thread's lifecycle and the application's state.

MessageQueue

Low-level class holding the list of messages to be dispatched by a Looper. Messages are not added directly to a MessageQueue, but rather through Handler objects associated with the Looper. the message queue is sequential, meaning messages are processed in the order they are added to the queue. When a message or runnable is posted to a message queue, it is placed at the end of the queue, and messages are executed in a first-in-first-out (FIFO) manner. This ensures that messages are processed one after the other in the order they were added.

val backgroundThread = HandlerThread("Example Thread")
backgroundThread.start()
val backgroundHandler = Handler(backgroundThread.looper)

// even if the delay is 1 second
// due to FIFO, it needs to wait runnable 1 to finish in 2 seconds
backgroundHandler.postDelayed ({
println("runnable 2 running")
}, 1000L)

backgroundHandler.post {
println("runnable 1 start")
SystemClock.sleep(2000L) // delay 2 seconds
println("runnable 1 end")
}

runnable 1 start
runnable 1 end
runnable 2 running

Messages in the queue do not have inherent priorities, and all messages are treated equally. However, you can prioritize the execution of messages by posting messages with delays or using the Handler’s postDelayed() method. By specifying a delay, you can make a message wait in the queue before it is executed. This effectively gives other messages with shorter or no delays a higher priority, as they will be processed before the delayed message.

To access the message queue, you need a handler associated with the message queue’s looper.

Handler

A Handler allows you to send and process Message and Runnable objects associated with a thread's MessageQueue. Each handler instance is associated with a single thread and bound to itsLooper. It will deliver messages and runnables to that Looper's message queue and execute them on Looper's thread. Also, a thread, or looper, can be handled by multiple handlers.

Communication between threads via the handler. An example of thread B gets handled by multiple handlers

A Handler is a mechanism to communicate between threads by allowing you to schedule messages or runnables to be processed on a specific thread’s message queue. It is often used to communicate back to the main (UI) thread from background threads, but it can also be used for communication between other threads.

a Handler itself is not always associated with the main thread. It can be associated with any thread, depending on how it is initiated, used, or which looper it uses.

class LoginActivity : SomeActivity {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// associated with main thread
val handler = Handler(Looper.getMainLooper())
handler.post {
/*runnable action*/
}

// or to simplify, often we use
runOnUiThread { }
view.post { }

// associated with main ExampleBackgroundThread
// Any messages or runnables posted to backgroundHandler
// will be processed on the background thread.
val backgroundThread = HandlerThread("Example Thread")
backgroundThread.start()
val backgroundHandler = Handler(backgroundThread.looper)
backgroundHandler.post { }
}
}

At this point, we already get some gist of what is thread and how it works. Although it was common for an app to use some dedicated thread, one for networking, one for database, etc.

  • But what happens when you need to do something heavy such as decoding tens or hundreds of bitmaps, etc? Putting all this work in a single thread will make the process takes too long.
  • On the other hand, what if you can make some additional 5 or 10 threads to handle those work? It can shorten the duration to 5 to 10 times faster. But to properly communicate or manage those tasks to each thread, could be a stressful task with many tricky parts.

This is where we need to learn about the existence of Threadpools Executor.

Threadpools Executor

An ExecutorService that executes each submitted task using one of possibly several pooled threads, normally configured using Executors factory methods. It handles all of the heavy lifting of spinning up the threads, load balancing work across those threads, and even killing those threads when they’ve been idle for a while.

Thread pools address two different problems:

  1. They usually provide improved performance when executing large numbers of asynchronous tasks, due to reduced per-task invocation overhead,
  2. And they provide a means of bounding and managing the resources, including threads, consumed when executing a collection of tasks. Each ThreadPoolExecutor also maintains some basic statistics, such as the number of completed tasks.

Hmm, if I can make 10 threads to speed up my process to 10 times, how about 100 threads, 1000 🤪? Then how many threads should the threadpoll executor have?

In the end, you will face some hardware limitations such as from CPU or RAM. First of all, a thread is not free, each thread costs you around 64k minimum in memory. This adds up quickly across the many apps installed on a device, especially in situations where the call stacks grow significantly.

Then, the CPU can only execute a certain number of threads in parallel. Once you get above the limit, the CPU starts to decide which threads get the next free block of processor time based on priority. Thus when this happens, you will not get any faster even if you add more threads.

As such, your app needs to find a sweet spot between the number of cores and the point of diminishing return with the number of threads. And of course, this is also not an easy task.

Thus the RxJava, and later coroutine, appear to simplify and reduce the complexity of managing concurrency and background tasks, including those handled by thread pools and executors. They achieve this by providing higher-level abstractions and more intuitive ways to work with asynchronous operations, which can lead to cleaner and more maintainable code.

Coroutine

And finally, to answer the question of why It’s recommended to use coroutines in Kotlin instead of Thread?

But first, we need to understand the relation between a thread and a coroutine.

Kotlin coroutines are built on top of the underlying concurrency mechanisms, including the Handler and Looper classes. However, coroutines abstract away many of the low-level details of using Handler and Looper directly, providing a higher-level abstraction that simplifies asynchronous programming.

Here’s how coroutines relate to Handler and Looper:

  1. Dispatcher: In the context of coroutines, a dispatcher is responsible for determining which thread or threads a coroutine will run on. Dispatchers are part of the Kotlin coroutine framework and abstract away the direct usage of Handler and Looper. Different dispatchers correspond to different execution contexts, such as the main UI thread, background threads, or other custom threads.
  2. Coroutine Context: Each coroutine has a coroutine context, which includes a dispatcher along with other context elements. When you launch a coroutine with a specific dispatcher, the coroutine is scheduled to run on a thread managed by that dispatcher.
  3. Main Dispatcher: The Dispatchers.Main dispatcher, often used for UI-related operations, is tied to the main UI thread's Looper. When you launch a coroutine with the main dispatcher, it ensures that the coroutine's code runs on the main UI thread and can safely interact with UI elements.
  4. Background Dispatchers: Dispatchers like Dispatchers.Default and Dispatchers.IO are used for background tasks. Under the hood, these dispatchers utilize thread pools, including Handler and Looper threads, to execute coroutines concurrently. For example, Dispatchers.Default may use a thread pool with a number of threads equal to the number of CPU cores available.
  5. Thread Management: Coroutines abstract away thread management details. When a coroutine is suspended due to a delay or a blocking operation, the underlying thread is free to perform other tasks, ensuring efficient thread utilization.
  6. Cancellation: Coroutines provide structured concurrency and automatic cancellation. When a coroutine is canceled, any blocking operation it’s performing (such as waiting for a Handler message) will be interrupted.

Then how to prove that the coroutine is lightweight?

Every Thread has its own stack, typically 1MB. 64k is the least amount of stack space allowed per thread in the JVM. Thus, more threads, more memory and cpu usage

Remember the snippet code above to demonstrate a first-in-first-out (FIFO) manner in the message queue. When you execute something on the thread, you need to wait until the current task is completed to execute the next task. But this was not the case for coroutine.

import kotlinx.coroutines.*
import kotlin.system.*

fun main() {
runBlocking {
println("Start runBlocking")

launch {
println("Start suspend 1")
delay(1000)
println("End suspend 1")
}
launch {
println("Start suspend 2")
delay(1000)
println("End suspend 2")
}

println("Continue runBlocking")
}
println("End")
}

Start runBlocking
Continue runBlocking
Start suspend 1
Start suspend 2
End suspend 1
End suspend 2
End

A coroutine is not bound to any particular thread, and suspending a coroutine does not block the underlying thread. The underlying thread can be used to perform other tasks or execute other coroutines. Thus when suspend 1 gets suspended, suspend 2 starts executing its task. And when suspend 2 gets suspended, another coroutine in wait can occupy the thread to execute its task, e.g. the resumed suspend 1 after its suspendable operation completes. And a concurrent task can be done with fewer threads and less memory usage.

Wow, great! But how does coroutine suspend itself?

A Continuation is a unique object for each coroutine, and manages the coroutines to be suspended or resumed. This callback-like object is added as the last parameter to function marked by suspend keyword at compilation time. This continuation will live in the heap as other objects do.

Also based on the docs, Coroutines are less resource-intensive than JVM threads. Code that exhausts the JVM’s available memory when using threads can be expressed using coroutines without hitting resource limits. For example, the following code launches 50,000 distinct coroutines that each wait 5 seconds and then prints a period (‘.’) while consuming very little memory:

import kotlinx.coroutines.*

fun main() = runBlocking {
repeat(50_000) { // launch a lot of coroutines
launch {
delay(5000L)
print(".")
}
}
}

If you write the same program using threads (remove runBlocking, replace launch with thread, and replace delay with Thread.sleep), it will consume a lot of memory. Depending on your operating system, JDK version, and its settings, it will either throw an out-of-memory error or start threads slowly so that there are never too many concurrently running threads.

Also, to be noted. Thread is not lifecycle aware! While coroutine can be integrated with Android lifecycle via coroutine scope.

Thanks for reading, hope it helps.

LinkedIn : https://www.linkedin.com/in/wahyu-raya/

--

--