Multithreading and Asynchrony are essential concepts in Kotlin programming that enable your applications to handle multiple tasks simultaneously. Understanding these concepts deeply can help you build efficient, responsive, and robust software solutions. Let's clearly break down these key aspects.
Multithreading
Threads are a fundamental concept for achieving concurrency by running multiple sequences of instructions in parallel within a single program.
Hyperskill Theory: Threads
JVM Threads
A Thread is a JVM object mapped one-to-one to an OS thread, with its own call stack and scheduler context.
// Create and start a thread
val thread = Thread {
for (i in 1..5) {
println("Thread prints: $i on ${Thread.currentThread().name}")
Thread.sleep(200) // blocks this thread only
}
}
thread.name = "MyWorker"
thread.start()
thread.join() // wait until it finishes
println("Main thread resumes")In this example, the lambda defines the work for “MyWorker,” start() hands it off to the OS scheduler, sleep(200) pauses only that thread, and join() makes the main thread wait for completion.
Runnable Interface
A Runnable encapsulates a unit of work, separating the task definition from the thread that executes it.
val runnable = Runnable {
println("Runnable executed by ${Thread.currentThread().name}")
}
Thread(runnable, "RunnableThread").start()Here, implementing Runnable as a lambda specifies the action, printing the current thread’s name and then wrapping it in Thread(runnable, "RunnableThread") both names the thread and immediately starts it so that it executes the Runnable’s code.
Synchronization
Synchronization ensures that only one thread at a time may access a critical section, preventing race conditions on shared mutable state. This is typically achieved using the synchronized keyword or explicit locks.
Hyperskill Theory: Synchronization
class Counter { var value = 0 }
val lock = Any()
fun safeIncrement(counter: Counter) {
synchronized(lock) {
counter.value++
}
}In this snippet, the synchronized(lock) block guarantees that whenever one thread is executing counter.value++, no other thread can enter that same block on the same lock object, which prevents two threads from updating counter.value at exactly the same time and causing lost increments.
Executors & Thread Pools
An ExecutorService lets you manage a pool of reusable threads and control the maximum level of concurrency. Using thread pools is generally preferred over creating individual threads for every task, as it reduces creation overhead and manages resources efficiently.
import java.util.concurrent.Executors
val pool = Executors.newFixedThreadPool(3)
repeat(10) { index ->
pool.submit {
println("Task $index on ${Thread.currentThread().name}")
Thread.sleep(100)
}
}
pool.shutdown()
pool.awaitTermination(1, java.util.concurrent.TimeUnit.MINUTES)This example creates a fixed pool of three threads to run ten tasks, which means the same three threads are reused rather than creating ten separate threads; after submitting all tasks, calling shutdown() prevents further submissions and awaitTermination(...) blocks until either all tasks complete or the timeout elapses.
Asynchrony (Coroutines)
Coroutines provide a way to write asynchronous, non-blocking code that is more structured and easier to reason about than traditional callbacks or futures, while being significantly more lightweight than threads.
Hyperskill Theory: Coroutines
What Is a Coroutine? Understanding the Difference with Threads
A coroutine is a lightweight, suspendable unit of work that doesn’t map directly to OS threads. Unlike threads, which are managed by the operating system and have significant overhead, coroutines are managed by the Kotlin runtime and libraries.
Think of it this way:
Threads are like individual workers, each with their own dedicated workspace (stack) and scheduled independently by the OS. Creating many workers can be expensive, and if a worker is waiting for something (like a resource or I/O), their workspace is occupied and cannot be used by others.
Coroutines are like small tasks or jobs that can be picked up and put down by a pool of workers (threads). When a coroutine needs to wait (e.g., for a network response or a delay), it suspends. This means it gives up the thread it was running on, allowing that thread to pick up another waiting coroutine. When the wait is over, the suspended coroutine can resume on any available thread in the pool. This ability to suspend and resume without blocking the underlying thread is what makes coroutines so lightweight and efficient for handling large numbers of concurrent operations, especially I/O-bound tasks. Many coroutines can run concurrently on a small number of threads.
Suspending functions like delay() pause the coroutine without blocking the underlying thread.
Hyperskill Theory: Suspend functions
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(300)
println("First coroutine done")
}
println("Launched first coroutine")
}Within the runBlocking scope, launch immediately starts a new coroutine; when that coroutine calls delay(300), it suspends itself without blocking the thread, allowing the “Launched first coroutine” line to print right away, and then after 300 ms it resumes and prints “First coroutine done.”
Async/Await
async starts a coroutine that produces a deferred result (Deferred<T>), and await() suspends the current coroutine until that result is available. This is useful for performing concurrent operations and waiting for all of them to complete.
Hyperskill Theory: Async/Await
fun main() = runBlocking {
val deferred1 = async {
delay(500)
"Result #1"
}
val deferred2 = async {
delay(300)
"Result #2"
}
println("Combined: ${deferred1.await()} + ${deferred2.await()}")
}In this code, both async coroutines begin concurrently; because one delays for 500 ms and the other for 300 ms, they run in parallel, and then each await() call suspends until its respective coroutine completes so that the two returned strings can be concatenated and printed.
Dispatchers
A coroutine dispatcher determines the thread or thread pool on which a coroutine runs, allowing you to optimize for CPU or I/O work. Dispatchers.Default is optimized for CPU-bound tasks, while Dispatchers.IO is suitable for blocking I/O operations.
Hyperskill Theory: Dispatchers
runBlocking {
launch(Dispatchers.Default) {
println("CPU-bound on ${Thread.currentThread().name}")
}
launch(Dispatchers.IO) {
println("I/O-bound on ${Thread.currentThread().name}")
}
}Here, the first coroutine is dispatched to Dispatchers.Default, which is tuned for CPU-intensive tasks, while the second uses Dispatchers.IO, which is sized for blocking I/O operations; each prints its thread name to demonstrate how dispatchers map coroutines to different thread pools.
Coroutine Scopes
Coroutine scopes define the lifecycle of coroutines. A structured concurrency approach ensures that coroutines launched within a scope are cancelled when the scope is cancelled or completes, helping prevent resource leaks.
Hyperskill Theory: Coroutine scopes
Cancellation & Timeouts
Coroutines support cooperative cancellation, where a running coroutine checks for cancellation and can be aborted cleanly. Timeouts can be used to automatically cancel a coroutine if it takes too long.
Hyperskill Theory: Cancellation
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
if (!isActive) return@launch
println("Working $i")
delay(100)
}
}
delay(300)
job.cancelAndJoin()
println("Cancelled at ${System.currentTimeMillis()}")
val result = withTimeoutOrNull(200) {
delay(500)
"OK"
}
println("OrNull returned: $result")
}In this example, the launched job loops with a delay, but before each iteration it checks isActive so it can exit immediately when cancelAndJoin() is called after 300 ms; afterward, withTimeoutOrNull(200) runs a block that delays for 500 ms but returns null after 200 ms rather than throwing an exception, which is then printed as “OrNull returned: null.”
Exception Handling
Coroutines use CoroutineExceptionHandler and structured scopes (supervisorScope) to isolate and handle errors gracefully, preventing a single failing coroutine from bringing down its siblings or parent.
Hyperskill Theory: Exception handling
val handler = CoroutineExceptionHandler { _, e -> println("Caught: ${e.message}") }
runBlocking {
supervisorScope {
launch(handler) { throw RuntimeException("Failure") }
launch { println("Sibling still runs") }
}
}Here, the handler logs the exception from one child, and because we’re in a supervisorScope, its sibling continues uninterrupted.
Final Thoughts
Understanding Kotlin multithreading and asynchrony is essential for building efficient, responsive, and robust applications, and it’s a frequent topic in technical interviews. Try to implement each example and solve the practice problems without consulting external resources to reinforce your mastery. Good luck!