Concurrency and Race Conditions in Kotlin: A Complete Guide from Threads to Coroutines
Kotlin is a language that grew on the JVM ecosystem, inheriting Java’s entire threading model — platform threads, synchronized, java.util.concurrent, volatile — all usable directly from Kotlin. But Kotlin doesn’t stop there. JetBrains designed Coroutines as a first-class concurrency solution that’s more expressive, safer, and far lighter than traditional threads. A coroutine isn’t a language feature in the sense of a new compiler keyword — it’s a library (kotlinx.coroutines) built on top of the suspension mechanism provided by the Kotlin compiler. The result is a concurrency model that feels like ordinary synchronous code, but can run concurrently without blocking OS threads.
Understanding concurrency in Kotlin means understanding two worlds at once: the Java inheritance that remains relevant (especially for interop with Java libraries and CPU-bound code), and Coroutines with its ecosystem (Channel, Flow, CoroutineScope, structured concurrency) which is the idiomatic Kotlin way for I/O-bound concurrency. This article discusses both in depth — including the race conditions that can happen in both, how to detect them, and how to prevent them with the right synchronization mechanisms.
Threads in Kotlin: The Java Inheritance
Because Kotlin runs on the JVM, all Java threading primitives are directly available. Kotlin just adds slightly cleaner syntax:
// Creating a thread with a lambda (Kotlin idiom)
val thread = Thread {
println("Running on: ${Thread.currentThread().name}")
}
thread.start()
// Or with Kotlin's built-in extension function
val t = thread(name = "worker-1") { // from kotlin.concurrent
println("Running on: ${Thread.currentThread().name}")
}
// A thread with the daemon flag
thread(isDaemon = true, name = "background") {
while (true) {
doBackgroundWork()
Thread.sleep(1000)
}
}
The entire java.util.concurrent — ExecutorService, ReentrantLock, AtomicInteger, ConcurrentHashMap — can be used directly from Kotlin thanks to full Java interop. However, in modern Kotlin, the need to manually create threads is very rare; there’s almost always a better solution via Coroutines.
Race Conditions in Kotlin: Just as Dangerous as in Java
Race conditions in Kotlin work exactly like in Java — because both share the JVM memory model. Code accessing shared mutable state from multiple threads without synchronization produces non-deterministic behavior.
The Classic Example: An Unsafe Counter
// ANTI-PATTERN: race condition on a shared counter
var counter = 0 // shared mutable state
val threads = (1..10).map {
Thread {
repeat(1000) {
counter++ // NOT SAFE: read-add-write is not an atomic operation
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println(counter) // not 10000 — the result is unpredictable every run
The Visibility Problem in Kotlin
// ANTI-PATTERN: visibility problem
class TaskRunner {
private var running = true // no visibility guarantee across threads
fun stop() {
running = false // another thread may never see this
}
fun run() {
while (running) { // might loop forever from the CPU cache
doWork()
}
}
}
// CORRECT: @Volatile annotation (equivalent to Java volatile)
class SafeTaskRunner {
@Volatile
private var running = true // guaranteed visible to all threads
fun stop() { running = false }
fun run() {
while (running) {
doWork()
}
}
}
Synchronizing with synchronized and Lock
Kotlin supports the @Synchronized annotation and the synchronized() function:
// Using a synchronized block
class SafeCounter {
private var count = 0
private val lock = Any() // object as the monitor
fun increment() {
synchronized(lock) {
count++
}
}
fun getCount(): Int = synchronized(lock) { count }
}
// Using the @Synchronized annotation (equivalent to a Java synchronized method)
class SynchronizedCounter {
private var count = 0
@Synchronized
fun increment() { count++ }
@Synchronized
fun getCount(): Int = count
}
// Using ReentrantLock from Java
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock // Kotlin extension function
class LockCounter {
private var count = 0
private val lock = ReentrantLock()
fun increment() = lock.withLock { count++ } // withLock auto-unlocks in finally
fun getCount(): Int = lock.withLock { count }
}
Atomic Classes
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.LongAdder
// AtomicInteger for a thread-safe counter without locks
val atomicCounter = AtomicInteger(0)
atomicCounter.incrementAndGet()
atomicCounter.addAndGet(5)
atomicCounter.compareAndSet(10, 20) // CAS
// LongAdder for a high-contention increment-only counter
val adder = LongAdder()
adder.increment()
val total = adder.sum()
Introducing Coroutines: Concurrency Without New Threads
A coroutine is an execution unit that can be suspended (paused) and resumed (continued) without blocking the thread running it. Unlike a thread, which when doing I/O will “sleep” and wait there (blocking the OS thread), a coroutine performing a suspend operation releases its thread so that thread can run other coroutines.
sequenceDiagram
participant T as Thread
participant C1 as Coroutine 1
participant C2 as Coroutine 2
T->>C1: run C1
C1->>C1: doWork()
C1-->>T: suspend (waiting for I/O)
T->>C2: run C2 (thread free!)
C2->>C2: doOtherWork()
C2-->>T: suspend (waiting for I/O)
T->>C1: resume C1 (I/O done)
C1->>C1: continue after I/OA single thread can run thousands of coroutines in turn, making coroutines far lighter than threads and suitable for applications with lots of concurrent I/O.
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")
// For Spring Boot:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.0")
}
Suspend Functions: The Foundation of Coroutines
A function that can be suspended is marked with the suspend keyword. This function can only be called from another coroutine or another suspend function — not directly from regular code.
// A suspend function — can pause without blocking the thread
suspend fun fetchUser(id: Int): User {
delay(500) // suspends for 500ms, the thread is NOT blocked
return User(id, "John Doe")
}
suspend fun fetchOrders(userId: Int): List<Order> {
delay(300) // suspends, the thread is free to run other coroutines
return listOf(Order(1), Order(2))
}
// Using a suspend function — must be from within a coroutine
suspend fun loadDashboard() {
val user = fetchUser(1) // suspends here
val orders = fetchOrders(1) // then suspends again
println("${user.name}: ${orders.size} orders")
}
delay()is a suspend function fromkotlinx.coroutinesthat pauses a coroutine without blocking the thread — unlikeThread.sleep(), which blocks the OS thread. Always usedelay()inside a coroutine, notThread.sleep().
CoroutineScope and Structured Concurrency
Every coroutine must be created inside a CoroutineScope. The scope defines the coroutine’s lifecycle — when the scope is cancelled, all coroutines inside it are automatically cancelled too. This is the core of structured concurrency: a coroutine can’t “leak” out of the scope that defines it.
import kotlinx.coroutines.*
fun main() = runBlocking { // creates a scope and blocks the thread until done
println("Starting on: ${Thread.currentThread().name}")
launch { // launches a new coroutine inside this scope
delay(1000)
println("Coroutine 1 finished")
}
launch {
delay(500)
println("Coroutine 2 finished")
}
println("Waiting for all coroutines to finish...")
// runBlocking waits until all child coroutines finish
}
launch vs async
launch is used for coroutines that don’t return a value (fire-and-forget). async is used when you need a result from the coroutine (returns a Deferred<T>).
runBlocking {
// launch: no return value
val job: Job = launch {
delay(1000)
println("Work done")
}
job.join() // wait for the job to finish
// async: returns Deferred<T>, can be awaited
val deferred: Deferred<Int> = async {
delay(1000)
42 // return value
}
val result = deferred.await() // suspends until the result is available
println("Result: $result")
// Running two tasks in parallel with async
val deferredA = async { fetchUser(1) }
val deferredB = async { fetchOrders(1) }
val user = deferredA.await() // wait for both
val orders = deferredB.await()
// fetchUser and fetchOrders run SIMULTANEOUSLY, not sequentially
}
flowchart LR
subgraph Sequential
A[fetchUser] --> B[fetchOrders]
B --> C[Done - 800ms]
end
subgraph Parallel with async
D[fetchUser - 500ms] --> F[await A + B - 500ms]
E[fetchOrders - 300ms] --> F
F --> G[Done - 500ms]
endCoroutine Dispatchers: Choosing Which Thread Is Used
CoroutineDispatcher determines which thread a coroutine runs on. This is Kotlin’s idiomatic replacement for choosing between different thread pools.
// Dispatcher.Default: thread pool for CPU-intensive work
// Thread count = number of CPU cores
launch(Dispatchers.Default) {
val result = heavyComputation() // heavy computation
}
// Dispatchers.IO: thread pool for I/O operations
// More threads (default 64, or the core count if larger)
launch(Dispatchers.IO) {
val data = readFile("data.txt") // file I/O
val response = httpClient.get(url) // network call
val rows = database.query(sql) // database query
}
// Dispatchers.Main: the main/UI thread (Android, JavaFX, Swing)
launch(Dispatchers.Main) {
updateUI(result) // only in Android/GUI environments
}
// Dispatchers.Unconfined: not bound to a specific thread (rarely used)
launch(Dispatchers.Unconfined) {
println(Thread.currentThread().name) // runs on the caller's thread
}
Switching Dispatchers Within One Coroutine
suspend fun loadAndProcess(): String {
// Start with I/O: fetch data from the network
val rawData = withContext(Dispatchers.IO) {
httpClient.get("https://api.example.com/data")
}
// Switch to Default: process the data (CPU-intensive)
val processed = withContext(Dispatchers.Default) {
parseAndTransform(rawData)
}
// Return to Main for UI updates (on Android)
withContext(Dispatchers.Main) {
showResult(processed)
}
return processed
}
| Dispatcher | Thread Pool | Good For |
|---|---|---|
Default | CPU cores | Parsing, sorting, computation |
IO | 64+ threads | File, network, database |
Main | 1 (UI thread) | UI updates |
Unconfined | Caller’s thread | Testing, special cases |
Race Conditions in Coroutines
Even though coroutines feel sequential to write, race conditions can still happen when several coroutines access shared mutable state — especially if they run on different dispatchers or in coroutines launched in parallel.
Example: A Counter Race Condition in Coroutines
// ANTI-PATTERN: race condition in coroutines
var counter = 0 // shared mutable state
runBlocking {
val jobs = (1..1000).map {
launch(Dispatchers.Default) {
counter++ // NOT SAFE even though it looks like sequential code!
}
}
jobs.forEach { it.join() }
}
println(counter) // not 1000 — there are lost updates
This happens because Dispatchers.Default uses multiple threads, and counter++ still isn’t an atomic operation at the CPU level.
Solution 1: Atomic from Java
import java.util.concurrent.atomic.AtomicInteger
// CORRECT: AtomicInteger for a simple counter
val counter = AtomicInteger(0)
runBlocking {
val jobs = (1..1000).map {
launch(Dispatchers.Default) {
counter.incrementAndGet() // atomic
}
}
jobs.forEach { it.join() }
}
println(counter.get()) // always 1000
Solution 2: Mutex from Coroutines
kotlinx.coroutines.sync.Mutex is a suspend-friendly version of a mutex — when it can’t acquire the lock, it suspends the coroutine (instead of blocking the thread):
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
// CORRECT: coroutine-friendly Mutex
val mutex = Mutex()
var counter = 0
runBlocking {
val jobs = (1..1000).map {
launch(Dispatchers.Default) {
mutex.withLock { // suspends if the lock isn't available, doesn't block the thread
counter++
}
}
}
jobs.forEach { it.join() }
}
println(counter) // always 1000
Solution 3: Single-Thread Confinement
The most idiomatic pattern in Kotlin: restrict access to shared state to only one coroutine/thread:
// CORRECT: restrict counter access to a single-threaded dispatcher
val counterContext = newSingleThreadContext("CounterContext")
var counter = 0
runBlocking {
val jobs = (1..1000).map {
launch(Dispatchers.Default) {
withContext(counterContext) {
counter++ // safe because only one thread executes this
}
}
}
jobs.forEach { it.join() }
}
println(counter) // always 1000
counterContext.close()
Solution 4: The Actor Pattern with Channels
Following the “share by communicating” philosophy — one coroutine owns the state, others communicate via channels:
import kotlinx.coroutines.channels.*
// Actor: a coroutine that processes messages sequentially
sealed class CounterMsg
object IncCounter : CounterMsg()
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()
fun CoroutineScope.counterActor() = actor<CounterMsg> {
var counter = 0 // state is only held by this actor
for (msg in channel) {
when (msg) {
is IncCounter -> counter++
is GetCounter -> msg.response.complete(counter)
}
}
}
runBlocking {
val counter = counterActor()
val jobs = (1..1000).map {
launch(Dispatchers.Default) {
counter.send(IncCounter) // send a message, no shared state
}
}
jobs.forEach { it.join() }
val response = CompletableDeferred<Int>()
counter.send(GetCounter(response))
println("Counter: ${response.await()}") // always 1000
counter.close()
}
Channels: Communication Between Coroutines
Channels in Kotlin Coroutines are the equivalent of Go channels — typed “pipes” for communication between coroutines with built-in synchronization.
import kotlinx.coroutines.channels.*
runBlocking {
// Unbuffered channel: the sender suspends until there's a receiver
val channel = Channel<Int>()
launch {
for (i in 1..5) {
println("Sending $i")
channel.send(i) // suspends if there's no receiver
}
channel.close() // tell the receiver there's no more data
}
for (value in channel) { // iterate until the channel is closed
println("Received $value")
}
}
// Buffered channel
val buffered = Channel<Int>(capacity = 10)
// Rendezvous (unbuffered, default)
val rendezvous = Channel<Int>(Channel.RENDEZVOUS)
// Unlimited buffer (careful: can OOM)
val unlimited = Channel<Int>(Channel.UNLIMITED)
// Drop the oldest if full
val dropping = Channel<Int>(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
Fan-Out and Fan-In with Channels
fun CoroutineScope.produceNumbers() = produce<Int> {
var x = 1
while (true) send(x++)
}
fun CoroutineScope.processNumber(id: Int, numbers: ReceiveChannel<Int>) = launch {
for (msg in numbers) {
println("Worker $id processing $msg")
}
}
// Fan-out: one producer, many consumers
runBlocking {
val producer = produceNumbers()
repeat(3) { id ->
processNumber(id, producer) // 3 workers sharing the same channel
}
delay(1000)
producer.cancel() // stop the producer
}
// Fan-in: merge several channels into one
fun CoroutineScope.mergeChannels(vararg channels: ReceiveChannel<Int>): ReceiveChannel<Int> =
produce {
for (channel in channels) {
launch {
for (value in channel) send(value)
}
}
}
Flow: Asynchronous Data Streams
Flow is an abstraction for cold asynchronous streams — a sequence of values produced asynchronously that only starts flowing when someone collects it.
import kotlinx.coroutines.flow.*
// Creating a Flow
fun numbersFlow(): Flow<Int> = flow {
for (i in 1..5) {
delay(100) // suspend operation inside the flow builder
emit(i) // emit a value
}
}
// Collecting a Flow (terminal operator, suspend function)
runBlocking {
numbersFlow()
.filter { it % 2 == 0 } // transformation: filter
.map { it * it } // transformation: map
.collect { value -> // terminal operator
println(value) // 4, 16
}
}
Commonly Used Flow Operators
val flow = flowOf(1, 2, 3, 4, 5)
// Transformations
flow.map { it * 2 } // transform every element
flow.filter { it > 2 } // filter elements
flow.flatMapMerge { fetchData(it) } // concurrent flatMap
flow.transform { emit(it); emit(it * 2) } // emit several values per element
// Combinations
flow1.zip(flow2) { a, b -> a + b } // zip two flows
flow1.combine(flow2) { a, b -> a + b } // combine (re-emits when either changes)
merge(flow1, flow2) // merge several flows into one
// Control
flow.take(3) // only take the first 3 elements
flow.debounce(300) // wait for 300ms of quiet before emitting (good for search input)
flow.distinctUntilChanged() // skip if the value equals the previous one
flow.retry(3) // retry up to 3 times on error
flow.catch { e -> emit(defaultValue) } // error handling
// Terminal
flow.toList() // collect everything into a List (suspend)
flow.first() // take only the first element (suspend)
flow.count() // count the number of elements (suspend)
flow.reduce { acc, value -> acc + value } // reduce (suspend)
Cold vs Hot Flows
A regular Flow is cold — every collector gets the data stream from the start, and the flow only runs when there’s a collector:
val coldFlow = flow {
println("Flow starts running")
emit(1)
emit(2)
}
runBlocking {
coldFlow.collect { println("Collector 1: $it") }
// "Flow starts running" prints again for the second collector
coldFlow.collect { println("Collector 2: $it") }
}
SharedFlow and StateFlow are hot — they run regardless of whether there are collectors, and can be shared with many collectors:
SharedFlow and StateFlow: Hot Streams
SharedFlow: Broadcasting to Many Collectors
import kotlinx.coroutines.flow.*
val sharedFlow = MutableSharedFlow<String>(
replay = 1, // new subscribers immediately get the last value
extraBufferCapacity = 10 // extra buffer so emits don't suspend
)
runBlocking {
// Emitter
launch {
repeat(5) { i ->
sharedFlow.emit("Event $i")
delay(100)
}
}
// Two collectors receiving the same events
launch {
sharedFlow.collect { println("Collector A: $it") }
}
launch {
sharedFlow.collect { println("Collector B: $it") }
}
delay(1000)
}
StateFlow: A Reactive State Holder
StateFlow is a special SharedFlow that always has a current value, similar to LiveData on Android:
import kotlinx.coroutines.flow.*
class ViewModel {
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow() // exposed as read-only
suspend fun loadData() {
_uiState.value = UiState.Loading
try {
val data = repository.fetchData()
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "An error occurred")
}
}
}
// In the UI / collector
viewModel.uiState
.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> showData(state.data)
is UiState.Error -> showError(state.message)
}
}
Flow (Cold) | SharedFlow (Hot) | StateFlow (Hot) | |
|---|---|---|---|
| Starts running | When there’s a collector | Always | Always |
| Current value | None | None (except replay) | Yes, always |
| Many collectors | Each gets its own stream | All get the same events | All get the latest state |
| Good for | Data pipelines, queries | Events/event buses | UI state, config |
Structured Concurrency in Kotlin
Structured concurrency is one of Kotlin Coroutines’ biggest contributions. Its principle is simple: a coroutine can’t outlive the scope that created it. This automatically prevents coroutine leaks.
// When the scope is cancelled, all child coroutines are cancelled too
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
launch { delay(Long.MAX_VALUE) } // child 1
launch { delay(Long.MAX_VALUE) } // child 2
delay(Long.MAX_VALUE) // parent
}
scope.cancel() // all coroutines above are cancelled immediately
coroutineScope vs supervisorScope
// coroutineScope: if one child fails, all other children are cancelled
suspend fun fetchDashboard() = coroutineScope {
val user = async { fetchUser() } // if this fails...
val orders = async { fetchOrders() } // ...this is also cancelled
DashboardData(user.await(), orders.await())
}
// supervisorScope: one child's failure doesn't affect the other children
suspend fun fetchAll() = supervisorScope {
val user = async { fetchUser() }
val orders = async { fetchOrders() }
val recommendations = async { fetchRecommendations() }
// Recommendations might fail, but user and orders are still processed
DashboardData(
user = user.await(),
orders = orders.await(),
recommendations = try { recommendations.await() } catch (e: Exception) { emptyList() }
)
}
flowchart TD
subgraph "coroutineScope — failure spreads"
CS[coroutineScope] --> CA[async: fetchUser ✓]
CS --> CB[async: fetchOrders ✗ FAILS]
CB -->|cancel| CA
CB -->|fails| CS
end
subgraph "supervisorScope — failure isolated"
SS[supervisorScope] --> SA[async: fetchUser ✓]
SS --> SB[async: fetchOrders ✗ FAILS]
SS --> SC[async: fetchRecs ✓]
SB -->|doesn't affect| SA
SB -->|doesn't affect| SC
endCancellation and Exception Handling
Cooperative Cancellation
Coroutines in Kotlin are cooperative — they can only be cancelled at suspension points. CPU-bound code that never suspends can’t be cancelled automatically:
// ANTI-PATTERN: not cooperative, can't be cancelled
val job = launch {
var i = 0
while (true) { // loop without a suspension point
i++
// no suspension here — cancel() will have no effect
}
}
job.cancel() // won't work!
// CORRECT: check isActive or use yield()
val job = launch {
var i = 0
while (isActive) { // check whether still active
i++
}
}
// Or use yield() to give a chance for cancel/scheduling
val job = launch {
var i = 0
while (true) {
yield() // suspension point: checks cancel and yields to the scheduler
i++
}
}
job.cancel()
CancellationException
When a coroutine is cancelled, a CancellationException is thrown at the next suspension point. Important: don’t catch CancellationException and swallow it:
// ANTI-PATTERN: swallowing CancellationException
launch {
try {
delay(1000)
} catch (e: Exception) { // catches ALL exceptions including CancellationException!
println("Error: ${e.message}") // this shouldn't be done
// the coroutine isn't truly cancelled
}
}
// CORRECT: only catch what isn't a CancellationException
launch {
try {
delay(1000)
} catch (e: CancellationException) {
throw e // re-throw CancellationException!
} catch (e: Exception) {
println("Error: ${e.message}") // catch other errors
}
}
// Or use try/finally for cleanup
launch {
try {
delay(1000)
doWork()
} finally {
// cleanup always runs, whether finished normally or cancelled
closeResources()
}
}
CoroutineExceptionHandler
For catching uncaught exceptions from launch (not async):
val handler = CoroutineExceptionHandler { _, exception ->
println("Uncaught exception: $exception")
// log, report to Sentry, etc.
}
val scope = CoroutineScope(Dispatchers.Default + handler)
scope.launch {
throw RuntimeException("Something unexpected!")
// CoroutineExceptionHandler will be called
}
Common Anti-Patterns in Kotlin Coroutines
Anti-Pattern 1: GlobalScope
// ANTI-PATTERN: GlobalScope — the coroutine isn't bound to any lifecycle
fun loadData() {
GlobalScope.launch { // dangerous: lives forever, can leak!
val data = fetchFromNetwork()
updateUI(data)
}
}
// CORRECT: use a lifecycle-bound scope
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch { // auto-cancelled when the ViewModel is destroyed
val data = fetchFromNetwork()
_uiState.value = UiState.Success(data)
}
}
}
// Or create your own cancellable scope
class DataLoader {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun load() {
scope.launch { /* ... */ }
}
fun destroy() {
scope.cancel() // cancel all coroutines
}
}
Anti-Pattern 2: Blocking Inside a Coroutine
// ANTI-PATTERN: calling blocking code in a coroutine without an IO dispatcher
suspend fun fetchData(): String {
val response = OkHttpClient().newCall(request).execute() // BLOCKING!
// this blocks the carrier thread, reducing coroutine effectiveness
return response.body?.string() ?: ""
}
// CORRECT: wrap blocking code with withContext(Dispatchers.IO)
suspend fun fetchData(): String {
return withContext(Dispatchers.IO) {
val response = OkHttpClient().newCall(request).execute() // blocking OK here
response.body?.string() ?: ""
}
}
// CORRECT: even better, use a suspend-aware library
suspend fun fetchData(): String {
return httpClient.get(url).bodyAsText() // ktor client: already suspend, not blocking
}
Anti-Pattern 3: Not Handling Exceptions in async
// ANTI-PATTERN: an exception in async only surfaces when await is called,
// and is easy to miss
val deferred = async {
throw RuntimeException("Failed!")
}
// the exception hasn't surfaced here...
delay(100)
deferred.await() // only explodes here — easy to forget
// CORRECT: handle the exception at await
val result = try {
deferred.await()
} catch (e: Exception) {
defaultValue
}
// Or use runCatching
val result = runCatching { deferred.await() }
.getOrElse { defaultValue }
Anti-Pattern 4: Creating Dispatchers That Are Never Closed
// ANTI-PATTERN: a new dispatcher without close — thread leak
suspend fun doWork() {
val dispatcher = newSingleThreadContext("worker")
withContext(dispatcher) {
// ...
}
// the dispatcher isn't closed, the thread lives forever!
}
// CORRECT: close the dispatcher when done
suspend fun doWork() {
val dispatcher = newSingleThreadContext("worker")
try {
withContext(dispatcher) {
// ...
}
} finally {
dispatcher.close() // make sure the thread is released
}
}
Anti-Pattern 5: Race Conditions on Shared StateFlow
// ANTI-PATTERN: modifying a StateFlow from several coroutines without synchronization
val _count = MutableStateFlow(0)
repeat(1000) {
launch(Dispatchers.Default) {
_count.value = _count.value + 1 // RACE CONDITION: read-modify-write!
}
}
// CORRECT: use update() which is atomic
repeat(1000) {
launch(Dispatchers.Default) {
_count.update { it + 1 } // atomic: no lost updates
}
}
Production Concurrency Patterns in Kotlin
Retry with Exponential Backoff
suspend fun <T> retryWithBackoff(
times: Int = 3,
initialDelay: Long = 100,
maxDelay: Long = 1000,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: Exception) {
if (e is CancellationException) throw e // don't retry on cancel
println("Attempt ${attempt + 1} failed: ${e.message}, retrying in ${currentDelay}ms")
}
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
}
return block() // last attempt, let the exception propagate
}
// Usage
val result = retryWithBackoff(times = 3, initialDelay = 100) {
httpClient.get("https://api.example.com/data")
}
Timeouts
import kotlinx.coroutines.*
// withTimeout: throws TimeoutCancellationException if the limit is exceeded
val result = withTimeout(5000L) {
fetchDataFromNetwork() // must finish within 5 seconds
}
// withTimeoutOrNull: returns null on timeout, doesn't throw
val result: String? = withTimeoutOrNull(5000L) {
fetchDataFromNetwork()
} ?: "default value"
Parallel Decomposition
// Running many requests in parallel and collecting the results
suspend fun fetchAllUsers(ids: List<Int>): List<User> = coroutineScope {
ids.map { id ->
async { fetchUser(id) } // each request runs in parallel
}.awaitAll() // wait for all to finish, throws if any fails
}
// With a concurrency limit (semaphore)
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
suspend fun fetchAllUsersLimited(ids: List<Int>, maxConcurrent: Int = 10): List<User> {
val semaphore = Semaphore(maxConcurrent) // max 10 simultaneous requests
return coroutineScope {
ids.map { id ->
async {
semaphore.withPermit { // wait for a permit before starting
fetchUser(id)
}
}
}.awaitAll()
}
}
Worker Pools with Channels
fun CoroutineScope.workerPool(
workers: Int,
jobs: ReceiveChannel<Int>,
results: SendChannel<Int>
) {
repeat(workers) { workerId ->
launch(Dispatchers.IO) {
for (job in jobs) {
val result = processJob(job)
results.send(result)
println("Worker $workerId finished job $job")
}
}
}
}
runBlocking {
val jobs = Channel<Int>(Channel.UNLIMITED)
val results = Channel<Int>(Channel.UNLIMITED)
// Fill the jobs
repeat(100) { jobs.send(it) }
jobs.close()
// Run 5 workers in parallel
workerPool(workers = 5, jobs = jobs, results = results)
// Collect the results
repeat(100) { println("Result: ${results.receive()}") }
}
Testing Coroutines
Testing concurrent code is always tricky. kotlinx-coroutines-test provides special tooling:
import kotlinx.coroutines.test.*
import kotlin.test.*
// TestCoroutineScheduler for virtual time control
class UserViewModelTest {
@Test
fun `loading state changes correctly`() = runTest {
val viewModel = UserViewModel()
viewModel.loadUser(1)
// advanceUntilIdle: run all pending coroutines
advanceUntilIdle()
assertEquals(UiState.Success::class, viewModel.uiState.value::class)
}
@Test
fun `delay is virtualized without waiting real time`() = runTest {
var result = 0
launch {
delay(10_000) // 10 seconds — but the test doesn't actually wait 10 seconds!
result = 42
}
advanceTimeBy(10_001) // advance the virtual time
assertEquals(42, result)
}
}
Concurrent Kotlin Code Review Checklist
BASIC THREAD SAFETY:
□ Is every access to vars used from multiple coroutines/threads safe?
□ Is @Volatile used for flags read from different threads?
□ Are there no plain HashMap/ArrayList instances accessed from multiple threads?
COROUTINE SCOPES:
□ Is GlobalScope unused? (use lifecycle-bound scopes)
□ Is every manually created CoroutineScope cancelled when no longer used?
□ Can no coroutine "leak" out of its scope?
DISPATCHERS:
□ Is blocking I/O always inside withContext(Dispatchers.IO)?
□ Is CPU-intensive work inside withContext(Dispatchers.Default)?
□ Is there no Thread.sleep() inside a coroutine (use delay())?
CANCELLATION:
□ Is CancellationException never swallowed (catch Exception without re-throw)?
□ Do long-running CPU tasks check isActive or call yield()?
□ Is cleanup inside a finally block (not just in catch)?
EXCEPTION HANDLING:
□ Are exceptions from async {} handled at await()?
□ Is there a CoroutineExceptionHandler for uncaught exceptions from launch?
□ Is coroutineScope vs supervisorScope chosen correctly per the need?
SHARED STATE:
□ Are there no shared mutable vars accessed from Dispatchers.Default?
□ Is MutableStateFlow.update{} used (not value = value + 1)?
□ Is a coroutine Mutex (not java.util.concurrent) used for suspend contexts?
FLOW:
□ Is Flow never collected on the wrong background thread?
□ Are SharedFlow/StateFlow used for hot streams, Flow for cold?
□ Are exceptions inside flows handled with the catch operator?
RESOURCES:
□ Are newSingleThreadContext / newFixedThreadPoolContext always closed?
□ Are Channels always closed from the sender side?
□ Are there no resource leaks inside coroutines that may be cancelled?
Summary
- Kotlin inherits Java’s entire threading model —
synchronized,@Volatile,java.util.concurrent,AtomicInteger— all usable directly with Kotlin’s more concise syntax.- Race conditions can still happen in Kotlin, both on regular threads and in coroutines running on
Dispatchers.Default(multi-threaded).suspendfunctions can be paused without blocking the thread — the foundation of the entire coroutine concurrency model.launchfor fire-and-forget (returns aJob);asyncfor tasks returning a value (returns aDeferred<T>that can beawaited).CoroutineDispatcherdetermines the thread pool:Defaultfor CPU-bound,IOfor I/O-bound,Mainfor the UI thread.- Structured concurrency ensures coroutines don’t outlive their scope — automatically preventing coroutine leaks.
coroutineScopepropagates failure to all siblings;supervisorScopeisolates failures so other children keep running.Mutexfromkotlinx.coroutines.syncis a suspend-friendly version of a lock — suspending the coroutine without blocking the thread while waiting for the lock.- For simple shared counters,
AtomicInteger/AtomicLongis more efficient. For more complex state, a Mutex or single-thread confinement is more appropriate.Channelfor communication between coroutines (push-based);Flowfor data streams pulled by a collector.StateFlowfor reactive UI state (always has a current value);SharedFlowfor events/broadcasting to many collectors.MutableStateFlow.update { }for atomic state modification — avoid_state.value = _state.value + 1, which is prone to race conditions.- Don’t use
GlobalScope— always bind coroutines to a scope with a lifecycle (ViewModel scope, lifecycle scope, or a manually cancelled scope).- Don’t swallow
CancellationException— catch the specific expected exceptions, and re-throwCancellationException.- For testing, use
runTestwithadvanceUntilIdle()/advanceTimeBy()so virtual time can be controlled without waiting for the real duration.