Kotlin MPP: Concurrency

At Dyte, we leverage Kotlin Multiplatform across our products to keep our codebase consistent and maintainable. It allows us to share code across platforms, including Android, iOS, and the web, while still providing the flexibility to write platform-specific code when needed.

The need for structured concurrency

Structured concurrency allows doing multiple computations outside the UI-thread to keep the app as responsive as possible. It differs from concurrency in the sense that a task can only run within the scope of its parent, which cannot end before all of its children. This ensures that all tasks are properly managed and cleaned up, preventing memory leaks and other issues.

Kotlin Multiplatform provides an easier way to handle concurrency using it's kotlinx.coroutines library. It allows developers to write asynchronous code in a more readable and maintainable way, making it easier to handle complex scenarios.

The problem

Let us launch two-hundred coroutines all doing the same action two-thousand times. The task is to increment a shared counter. The counter is a simple integer variable.

The code is as follows:

suspend fun hugeRun(task: suspend () -> Unit) {
    val i = 200  // number of coroutines to launch
    val k = 2000 // times an action is repeated by each coroutine

    coroutineScope { // scope for coroutines
        repeat(i) {
            launch {
                repeat(k) { task() }
            }
        }
    }

    println("Completed ${i * k} actions")
}

What does it print at the end? It is highly unlikely to ever print "Counter = 400000", because two hundred coroutines increment the counter concurrently from multiple threads without any synchronization.

Approaches to handle concurrency

There is a common misconception that making a variable volatile solves concurrency problem. However, that only guarantees visibility of changes to other threads, but does not provide atomicity.

This means that if two threads read the value of a volatile variable at the same time, they may both see the same value, and both increment it, leading to a lost update.

So, how do we solve this problem?

We will explore three different approaches to handle this:

Using Atomic Primitives for Shared State Concurrency

The general solution that works both for threads and for coroutines is to use a thread-safe (aka synchronized, linearizable, or atomic) data structure that provides all the necessary synchronization for the corresponding operations that needs to be performed on a shared state. In the case of a simple counter we can use AtomicInteger class which has atomic incrementAndGet operations:

import kotlinx.atomicfu.*

fun main() {
    // Create an atomic integer
    val counter = atomic(0)

    // Launch multiple coroutines to increment the counter concurrently
    repeat(100) {
        GlobalScope.launch {
            // Atomically increment the counter
            counter.incrementAndGet()
        }
    }

    // Wait for all coroutines to complete
    Thread.sleep(100)

    // Print the final value of the counter
    println("Counter value: ${counter.value}")
}

Using Threads for Shared State Concurrency

To handle concurrency using threads, you can create multiple threads and synchronize access to shared state using locks or other synchronization mechanisms. Here's an example of how to increment a shared counter using threads:

fun main() {
    // Create a shared counter
    var counter = 0

    // Launch multiple threads to increment the counter concurrently
    repeat(100) {
        Thread {
            // Increment the counter
            counter++
        }.start()
    }

    // Wait for all threads to complete
    Thread.sleep(100)

    // Print the final value of the counter
    println("Counter value: $counter")
}

Using Coroutines for Asynchronous Operations

We can also use CoroutineScope to launch multiple coroutines to perform asynchronous operations concurrently. Here's an example of how to increment a shared counter using coroutines:

import kotlinx.coroutines.*

fun main() {
    // Define a coroutine scope
    runBlocking {
        // Launch a coroutine to perform a background task
        val job = launch {
            val result = async {
                // Simulate a long-running operation
                delay(1000)
                "Hello, KMP!"
            }
            // Wait for the result and print it
            println(result.await())
        }
        // Do other work concurrently
        println("Loading...")
        // Wait for the coroutine to complete
        job.join()
    }
}

Platform-Specific Threading Models

Kotlin Multiplatform allows you to write platform-specific code using the expect and actual keywords. This enables you to leverage platform-specific threading models to handle concurrency in a platform-agnostic way.

Here's an example of how to use platform-specific threading models to perform tasks on the main UI thread in Android and iOS:

Note: We can also use kotlinx.coroutines library to handle concurrency in a platform-agnostic way. It is not necessary to use platform-specific threading models, but they can be useful in certain scenarios.

Platform-Specific Threading Models (Android)

import android.os.Handler
import android.os.Looper

fun main() {
    // Create a handler associated with the main UI thread
    val mainHandler = Handler(Looper.getMainLooper())

    // Post a task to the main UI thread
    mainHandler.post {
        // Update UI or perform other operations on the main thread
        println("Task executed on the main UI thread (Android)")
    }
}

Platform-Specific Threading Models (iOS)

import Foundation

func main() {
    // Perform a task on the main UI thread (iOS)
    DispatchQueue.main.async {
        // Update UI or perform other operations on the main thread
        print("Task executed on the main UI thread (iOS)")
    }
}

Thread Safe Lists

Apart from the above approaches, we at Dyte use custom implementations of lists with thread safety to handle concurrency in our applications. We use two types of lists: WriteHeavyMutableList<T> and ReadHeavyMutableList<T> with the help of Locks provided by kotlinx.atomicfu like ReentrantLock.

// Short implementation of WriteHeavyMutableList
import kotlinx.atomicfu.*

internal class WriteHeavyMutableList<T> {
  val lock = reentrantLock()
  val intList = mutableListOf<T>()

  ...

  // The lock is used to synchronize access to the list
  // ensuring that only one thread can read or write to the list at a time.
  fun get(index: Int): T = lock.withLock { intList[index] }

  ...
}

This allows us to safely access and modify the list from multiple threads without the risk of data corruption or issues like concurrent modification exceptions.

Final Thoughts

Concurrency is a complex topic that requires careful consideration when designing and implementing multiplatform applications. By understanding the strengths and weaknesses of each approach, you can choose the best solution for your specific use case, as we at Dyte do.

We hope you found this post informative and engaging. If you have any thoughts or feedback, please reach out to us on LinkedIn and Twitter. Stay tuned for more related blog posts in the future!

If you haven't heard about Dyte yet, head over to dyte.io to learn how we are revolutionizing communication through our SDKs and libraries and how you can get started quickly on your 10,000 free minutes, which renew every month. You can reach us at support@dyte.io or ask our developer community.