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"]
AspectThreadExecutorServiceCoroutine
Weight~1MB stack per threadDepends on pool sizeVery light (~a few KB)
ScaleHundredsHundreds-thousandsHundreds of thousands
BlockingBlocks the threadBlocks the threadNon-blocking suspension
CodeCallbacks / manual joinFuture.get()await(), sequential style
Error handlingHard, uncaught exceptionsHardStructured concurrency
Best forOS-level tasks, legacySimple backendsAll 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. Always join() before accessing the result.
  • ExecutorService for thread pools — more efficient than creating a new thread every time. Use newFixedThreadPool for constant throughput, newCachedThreadPool for 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.
  • launch for fire-and-forget, async for values — use launch when you don’t need a result, async + await() when you need a value from parallel computation.
  • Dispatchers.Default for CPU, Dispatchers.IO for I/O — don’t run blocking I/O on Default (wastes CPU threads), and don’t do heavy computation on IO.
  • Mutex, not synchronized, in coroutinessynchronized blocks a thread, Mutex.withLock {} only suspends the coroutine — far more efficient.
  • Flow for data streams — use Flow instead of Channel for reactively produced data. Flow is cold and composable with filter, map, flatMap.
  • Structured concurrency prevents coroutine leaks — cancel the parent scope to automatically cancel all child coroutines. Avoid GlobalScope unless truly necessary.

← Previous: Build Tools   Next: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact