Fading Coder

One Final Commit for the Last Sprint

Home > Notes > Content

Handling Shared Mutable State and Concurrency in Kotlin Coroutines

Notes 1

When leveraging multi-threaded dispatchers like Dispatchers.Default, coroutines execute concurrently. This introduces classic synchronization challenges, primarily around accessing mutable variables from multiple execution paths simultaneously. A typical benchmark setup spawns numerous tasks that repeatedly modify a shared counter.

suspend fun benchmarkConcurrentOps(taskCount: Int, iterationsPerTask: Int, action: suspend () -> Unit): Long {
    val startTime = System.currentTimeMillis()
    coroutineScope {
        repeat(taskCount) {
            launch {
                repeat(iterationsPerTask) { action() }
            }
        }
    }
    return System.currentTimeMillis() - startTime
}

The Volatile Misconception

Marking a variable with @Volatile does not prevent race conditions. While it ensures memory visibility across threads, the increment operation remains a compound read-modify-write sequence. Concurrent reads will cause lost updates, resulting in final counts lower than expected.

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.Dispatchers

@Volatile
private var sharedBalance = 0

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        benchmarkConcurrentOps(100, 1000) { sharedBalance++ }
    }
    println("Final balance: $sharedBalance")
}

Atomic Data Structures

For straightforward counters, collections, or queues, leveraging concurrency-safe primitives from java.util.concurrent.atomic is highly efficient. These classes guarantee atomic read-modify-write cycles without explicit locking overhead.

import java.util.concurrent.atomic.AtomicInteger

private val transactionCount = AtomicInteger(0)

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        benchmarkConcurrentOps(100, 1000) { transactionCount.incrementAndGet() }
    }
    println("Transaction total: ${transactionCount.get()}")
}

This approach delivers optimal performance for basic operations but becomes cumbersome when managing complex state transitions or custom business logic that lacks ready-made thread-safe implementations.

Thread Confinement

Restricting all access to a specific resource to a single execution context eliminates race conditions entirely. In coroutines, this is achieved via dedicated dispatcher contexts. Wrapping every individual mutation in side a single-threaded context (withContext) incurs severe overhead due to continuous thread-switching penalties. Instead, confining the entire workload to one context prevents these switches and improves throughput.

import kotlinx.coroutines.newSingleThreadContext

private val stateDispatcher = newSingleThreadContext("StateManager")
private var internalCounter = 0

fun main() = runBlocking {
    // Entire block runs within the confined context
    withContext(stateDispatcher) {
        benchmarkConcurrentOps(100, 1000) { internalCounter++ }
    }
    println("Constrained count: $internalCounter")
}

Mutex-Based Serialization

When partial serialization is required, a Mutex provides cooperative locking. Unlike traditional monitors, lock() is a suspend function that yields control when contended rather than blocking the underlying OS thread. The withLock extension simplifies acquisition and release patterns.

import kotlinx.coroutines.sync.Mutex

private val requestMutex = Mutex()
private var processedRequests = 0

fun main() = runBlocking {
    withContext(Dispatchers.Default) {
        benchmarkConcurrentOps(100, 1000) {
            requestMutex.withLock {
                processedRequests++
            }
        }
    }
    println("Processed: $processedRequests")
}

This pattern introduces fine-grained synchronization overhead but remains ideal for scenarios where shared state must be updated periodically without a natural single-thread owner.

The Actor Pattern

The actor model enforces strict encapsulation by combining a coroutine, private mutable state, and a message channel. Only the owning coroutine can mutate its state, communicating externally via structured messages. Complex actors are best implemented as clases, while lightweight ones can be initialized inline using the actor coroutine builder.

Messages should utilize sealed hierarchies to distinguish commands from query requests. Response futures handle asynchronous replies cleanly.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.actor

sealed class MetricMessage
object IncrementMetric : MetricMessage()
data class QueryMetric(val promise: CompletableDeferred<Long>) : MetricMessage()

fun CoroutineScope.metricsActor() = actor<MetricMessage> {
    var recordedValue = 0L
    for (message in channel) {
        when (message) {
            is IncrementMetric -> recordedValue++
            is QueryMetric -> message.promise.complete(recordedValue)
        }
    }
}

fun main() = runBlocking<Unit> {
    val metricTracker = metricsActor()

    withContext(Dispatchers.Default) {
        benchmarkConcurrentOps(100, 1000) {
            metricTracker.send(IncrementMetric)
        }
    }

    val futureResult = CompletableDeferred<Long>()
    metricTracker.send(QueryMetric(futureResult))
    println("Total tracked: ${futureResult.await()}")

    metricTracker.close()
}

Since actors execute sequentially within their own coroutine scope, state mutations remain inherently safe. Actors generally outperform lock-based approaches under high contention because they maintain continuous work loops without requiring dispatcher context switches.

Tags: kotlin

Related Articles

Designing Alertmanager Templates for Prometheus Notifications

How to craft Alertmanager templates to format alert messages, improving clarity and presentation. Alertmanager uses Go’s text/template engine with additional helper functions. Alerting rules referenc...

Deploying a Maven Web Application to Tomcat 9 Using the Tomcat Manager

Tomcat 9 does not provide a dedicated Maven plugin. The Tomcat Manager interface, however, is backward-compatible, so the Tomcat 7 Maven Plugin can be used to deploy to Tomcat 9. This guide shows two...

Skipping Errors in MySQL Asynchronous Replication

When a replica halts because the SQL thread encounters an error, you can resume replication by skipping the problematic event(s). Two common approaches are available. Methods to Skip Errors 1) Skip a...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.