Multithreading #
Every serious application needs to do more than one thing at once: handle HTTP requests while processing a database, download files while updating the UI, or run several heavy computations in parallel. In Kotlin there are three layers for handling this: Thread (the most basic Java API), ExecutorService (thread pool management), and Coroutines (Kotlin’s more efficient modern approach). All three have different trade-offs — understanding when and why to choose each one is the key to writing correct, performant concurrent applications. This article covers all three in depth, with a focus on Coroutines as Kotlin’s idiomatic way.
Concurrency Problems to Understand #
Before discussing solutions, it’s important to understand the problems that can arise when several threads access the same data simultaneously:
// Race condition — a non-deterministic bug that's hard to debug
var counter = 0
fun increment() {
repeat(10_000) {
counter++ // NOT thread-safe! read-modify-write isn't atomic
}
}
val t1 = Thread { increment() }
val t2 = Thread { increment() }
t1.start(); t2.start()
t1.join(); t2.join()
println(counter) // Should be 20000, but can be smaller due to the race condition
Common concurrency problems: race conditions (results depend on timing), deadlocks (two threads waiting on each other), and starvation (one thread never gets its turn). Solutions can be explicit synchronization, thread-safe data structures, or avoiding shared mutable state altogether.
Thread — The Basic Java API #
Thread is the most basic unit of execution on the JVM. Every thread is an OS object requiring about 1MB of stack memory and thousands of nanoseconds to create.
// Method 1: direct lambda
val thread1 = Thread {
println("Thread 1 running on: ${Thread.currentThread().name}")
Thread.sleep(1000) // block this thread for 1 second
println("Thread 1 done")
}
// Method 2: thread() function from the Kotlin stdlib — more idiomatic
import kotlin.concurrent.thread
val thread2 = thread(name = "Worker-A", isDaemon = false) {
println("Thread A running")
}
thread1.start()
// thread2 is already started automatically because thread() starts it
// join() — wait for the thread to finish before continuing
thread1.join()
println("Thread 1 is done, continuing to the next code")
Synchronization with synchronized
#
To protect shared data from race conditions, use a synchronized block:
var counter = 0
val lock = Any() // object as the lock
val threads = (1..10).map {
Thread {
repeat(1000) {
synchronized(lock) {
counter++ // only one thread can enter this block at a time
}
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println(counter) // always 10000 — safe from race conditions
AtomicInteger — A More Efficient Alternative #
For simple operations like counters, AtomicInteger is more efficient than synchronized:
import java.util.concurrent.atomic.AtomicInteger
val atomicCounter = AtomicInteger(0)
val threads = (1..10).map {
Thread {
repeat(1000) {
atomicCounter.incrementAndGet() // atomic — no lock needed
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println(atomicCounter.get()) // always 10000
Limitations of Direct Threads #
Direct threads are fine for simple tasks, but they have problems at scale. Creating 10,000 threads for 10,000 requests is a performance disaster — every thread consumes memory and OS scheduling time. This is why thread pools and coroutines exist.
ExecutorService — Thread Pool Management #
ExecutorService manages a set of reusable threads — instead of creating a new thread every time there’s a task, tasks are queued and executed by existing threads in the pool.
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
// Fixed thread pool — fixed number of threads
val fixedPool = Executors.newFixedThreadPool(4)
// Submit tasks to the pool
repeat(10) { taskId ->
fixedPool.submit {
println("Task $taskId on thread: ${Thread.currentThread().name}")
Thread.sleep(500)
}
}
// Shutdown — wait for all tasks to finish, then close the pool
fixedPool.shutdown()
fixedPool.awaitTermination(10, TimeUnit.SECONDS)
println("All tasks done")
Types of Thread Pools #
// Fixed pool — N fixed threads, tasks beyond N are queued
val fixed = Executors.newFixedThreadPool(4)
// Cached pool — threads created as needed, removed if idle > 60 seconds
// Suitable for many short-lived tasks
val cached = Executors.newCachedThreadPool()
// Single thread executor — one thread, tasks execute sequentially
val single = Executors.newSingleThreadExecutor()
// Scheduled pool — for tasks that need scheduling or repetition
val scheduled = Executors.newScheduledThreadPool(2)
scheduled.scheduleAtFixedRate(
{ println("Repeating task: ${System.currentTimeMillis()}") },
0, // initial delay (seconds)
5, // interval (seconds)
TimeUnit.SECONDS
)
Future — Getting Results from a Thread Pool
#
import java.util.concurrent.Callable
val pool = Executors.newFixedThreadPool(2)
// Submit a Callable — returns a Future<T>
val future1 = pool.submit(Callable {
Thread.sleep(1000)
"Result from task 1"
})
val future2 = pool.submit(Callable {
Thread.sleep(500)
42
})
// get() blocks until the result is available
println(future1.get()) // "Result from task 1"
println(future2.get()) // 42
pool.shutdown()
Kotlin Coroutines — The Modern Approach #
A coroutine is an execution unit far lighter than a thread. You can create hundreds of thousands of coroutines in one program without running out of memory, because coroutines don’t map 1:1 to OS threads — they’re scheduled by the Kotlin runtime on top of a small number of threads.
Adding the Dependency #
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
// For Android
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
}
runBlocking and launch
#
import kotlinx.coroutines.*
fun main() = runBlocking { // creates a coroutine scope and blocks the main thread
println("Started on: ${Thread.currentThread().name}")
// launch — fire-and-forget, returns a Job
val job1 = launch {
delay(1000) // non-blocking suspension (doesn't block the thread!)
println("Job 1 done on: ${Thread.currentThread().name}")
}
val job2 = launch {
delay(500)
println("Job 2 done on: ${Thread.currentThread().name}")
}
println("Waiting for jobs...")
job1.join() // wait for job1
job2.join() // wait for job2
println("All done!")
}
// Output:
// Started on: main
// Waiting for jobs...
// Job 2 done on: main (after 500ms)
// Job 1 done on: main (after 1000ms)
// All done!
async and await — Parallel Computation with Results
#
async returns a Deferred<T> — like a Future but for coroutines. Use await() to retrieve its result.
import kotlinx.coroutines.*
suspend fun fetchUserData(id: Int): String {
delay(1000) // simulate an API call
return "User #$id"
}
suspend fun fetchUserOrders(userId: Int): List<String> {
delay(800) // simulate a database query
return listOf("Order A", "Order B")
}
fun main() = runBlocking {
val start = System.currentTimeMillis()
// Run two operations in PARALLEL with async
val deferredUser = async { fetchUserData(42) }
val deferredOrders = async { fetchUserOrders(42) }
// Wait for both to finish
val user = deferredUser.await()
val orders = deferredOrders.await()
val duration = System.currentTimeMillis() - start
println("$user: $orders")
println("Done in ${duration}ms") // ~1000ms (parallel), not 1800ms (sequential)
}
Compare with the sequential approach that wastes time:
// ANTI-PATTERN: sequential when there's no dependency between operations
val user = fetchUserData(42) // wait 1000ms
val orders = fetchUserOrders(42) // only starts now, waits another 800ms
// Total: 1800ms — inefficient
Coroutine Dispatchers — Which Thread Does a Coroutine Run On? #
A Dispatcher determines the thread or thread pool a coroutine uses.
import kotlinx.coroutines.*
fun main() = runBlocking {
// Default — optimal thread pool for CPU-intensive computation
launch(Dispatchers.Default) {
println("Default: ${Thread.currentThread().name}")
// Suitable for: heavy calculations, sorting, parsing
}
// IO — large thread pool for blocking I/O operations
launch(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
// Suitable for: file read/write, HTTP, blocking database
}
// Main — only available on Android/UI frameworks
// launch(Dispatchers.Main) { updateUI() }
// Unconfined — starts on the caller thread, continues on the suspension point's thread
launch(Dispatchers.Unconfined) {
println("Unconfined start: ${Thread.currentThread().name}")
delay(100)
println("Unconfined after delay: ${Thread.currentThread().name}")
// Suitable for: testing, very specific uses
}
// newSingleThreadContext — create a dispatcher with one dedicated thread
val customDispatcher = newSingleThreadContext("CustomThread")
launch(customDispatcher) {
println("Custom: ${Thread.currentThread().name}") // CustomThread
}
customDispatcher.close() // don't forget to close it to free resources
}
Switching Dispatchers Within a Coroutine #
suspend fun processData(data: String): String {
// Start on an IO thread to read from disk
val rawData = withContext(Dispatchers.IO) {
readFromDisk(data)
}
// Switch to Default for heavy computation
val processedResult = withContext(Dispatchers.Default) {
processHeavy(rawData)
}
return processedResult
}
Mutex — Preventing Race Conditions in Coroutines #
To protect shared data in coroutines, use Mutex (not synchronized, which blocks threads):
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
val mutex = Mutex()
var counter = 0
fun main() = runBlocking {
val jobs = (1..1000).map {
launch(Dispatchers.Default) {
mutex.withLock {
counter++ // critical — only one coroutine at a time
}
}
}
jobs.forEach { it.join() }
println(counter) // always 1000
}
AtomicInteger Still Works in Coroutines
#
import java.util.concurrent.atomic.AtomicInteger
val atomicCounter = AtomicInteger(0)
fun main() = runBlocking {
(1..1000).map {
launch(Dispatchers.Default) {
atomicCounter.incrementAndGet()
}
}.forEach { it.join() }
println(atomicCounter.get()) // always 1000, without a mutex
}
Channels — Communication Between Coroutines #
A Channel is how coroutines communicate with each other — like a thread-safe queue that can suspend when full or empty.
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
fun main() = runBlocking {
val channel = Channel<Int>()
// Producer — send data to the channel
launch {
for (i in 1..5) {
println("Sending: $i")
channel.send(i)
delay(100)
}
channel.close() // mark the channel as done
}
// Consumer — receive data from the channel
launch {
for (value in channel) { // automatically stops when the channel is closed
println("Receiving: $value")
}
}
}
// Sending: 1
// Receiving: 1
// Sending: 2
// Receiving: 2
// ...
produce — The Channel Producer DSL
#
fun CoroutineScope.evenNumbers(max: Int): ReceiveChannel<Int> = produce {
var n = 2
while (n <= max) {
send(n)
n += 2
}
}
fun main() = runBlocking {
val evens = evenNumbers(20)
for (n in evens) print("$n ")
println()
// 2 4 6 8 10 12 14 16 18 20
}
Flow — Asynchronous Data Streams #
Flow is an asynchronous data sequence that can be collected reactively. Unlike Channel, Flow is cold — it only produces data when collected.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Creating a Flow
fun numberFlow(): Flow<Int> = flow {
for (i in 1..5) {
delay(300) // simulate data arriving gradually
emit(i) // send a value to the collector
}
}
fun main() = runBlocking {
numberFlow()
.filter { it % 2 == 0 } // filter like a List
.map { it * it } // transformation
.collect { value -> // start collection
println("Value: $value")
}
}
// Value: 4
// Value: 16
// Flow from an existing collection
listOf(1, 2, 3, 4, 5).asFlow()
.map { it * 2 }
.collect { print("$it ") }
// 2 4 6 8 10
Thread vs Coroutine — When to Choose Which #
flowchart TD
A{Concurrency\nneeds?} --> B{Number of\nexecution units?}
B -- Few, dozens --> C[Thread / ExecutorService]
B -- Many, thousands+ --> D[Coroutine]
A --> E{Kind of\noperation?}
E -- CPU-intensive\nparsing, calculations --> F["Coroutine\nDispatchers.Default"]
E -- Blocking I/O\nfile, HTTP, DB --> G["Coroutine\nDispatchers.IO"]
E -- Reactive\ndata streaming --> H["Flow"]
E -- Communication\nbetween tasks --> I["Channel"]| Aspect | Thread | ExecutorService | Coroutine |
|---|---|---|---|
| Weight | ~1MB stack per thread | Depends on pool size | Very light (~a few KB) |
| Scale | Hundreds | Hundreds-thousands | Hundreds of thousands |
| Blocking | Blocks the thread | Blocks the thread | Non-blocking suspension |
| Code | Callbacks / manual join | Future.get() | await(), sequential style |
| Error handling | Hard, uncaught exceptions | Hard | Structured concurrency |
| Best for | OS-level tasks, legacy | Simple backends | All modern cases |
Structured Concurrency — The Coroutine Advantage #
One of the biggest coroutine advantages is structured concurrency — child coroutines can’t “escape” their parent scope. If the parent is cancelled, all child coroutines are cancelled too.
fun main() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch {
launch { // child coroutine
delay(5000)
println("This won't print if the job is cancelled")
}
launch { // second child coroutine
delay(3000)
println("This also won't print")
}
delay(10000)
}
delay(1000)
job.cancel() // cancels the job AND all child coroutines
job.join()
println("All coroutines cancelled")
}
Summary #
- Threads for simple cases — use
thread { }from the Kotlin stdlib for simple tasks that don’t need to scale. Alwaysjoin()before accessing the result.- ExecutorService for thread pools — more efficient than creating a new thread every time. Use
newFixedThreadPoolfor constant throughput,newCachedThreadPoolfor request bursts.- Coroutines are the modern Kotlin standard — far lighter than threads, support non-blocking suspension, and are easier to read because asynchronous code is written like synchronous code.
launchfor fire-and-forget,asyncfor values — uselaunchwhen you don’t need a result,async + await()when you need a value from parallel computation.Dispatchers.Defaultfor CPU,Dispatchers.IOfor I/O — don’t run blocking I/O onDefault(wastes CPU threads), and don’t do heavy computation onIO.Mutex, notsynchronized, in coroutines —synchronizedblocks a thread,Mutex.withLock {}only suspends the coroutine — far more efficient.Flowfor data streams — useFlowinstead ofChannelfor reactively produced data.Flowis cold and composable withfilter,map,flatMap.- Structured concurrency prevents coroutine leaks — cancel the parent scope to automatically cancel all child coroutines. Avoid
GlobalScopeunless truly necessary.