Sobes.tech
Middle+

How can synchronization be implemented in Kotlin using coroutines?

sobes.tech AI

Answer from AI

Synchronization in Kotlin coroutines is achieved using special primitives and data structures from the kotlinx.coroutines library.

Main approaches:

  1. Mutex

    • Provides mutual exclusion to protect critical sections of code.
    • A coroutine attempting to acquire a Mutex is blocked until it is released.
    • Has functions lock() and unlock(), as well as a more idiomatic withLock.
    import kotlinx.coroutines.sync.Mutex
    import kotlinx.coroutines.sync.withLock
    import kotlinx.coroutines.*
    
    val mutex = Mutex()
    var counter = 0
    
    suspend fun increment() {
        mutex.withLock {
            counter++
        }
    }
    
    suspend fun main() = coroutineScope {
        repeat(10_000) {
            launch {
                increment()
            }
        }
        println(counter) // Expect 10000
    }
    
  2. Semaphore

    • Limits the number of coroutines that can access a resource or execute a block of code simultaneously.
    • Manages a pool of permits.
    • Has functions acquire() and release(), as well as withPermit.
    import kotlinx.coroutines.sync.Semaphore
    import kotlinx.coroutines.sync.withPermit
    import kotlinx.coroutines.*
    
    val semaphore = Semaphore(2) // Up to 2 coroutines can work simultaneously
    
    suspend fun doLimitedWork(id: Int) {
        semaphore.withPermit {
            println("Coroutine $id acquired a permit. Working...")
            delay(100) // Simulate work
            println("Coroutine $id released a permit.")
        }
    }
    
    suspend fun main() = coroutineScope {
        repeat(5) { i ->
            launch {
                doLimitedWork(i)
            }
        }
    }
    
  3. Atomic operations (from kotlinx.coroutines.atomic)

    • Provide thread-safe operations on primitive types and references.
    • Use low-level CPU instructions (CAS - Compare-and-Swap).
    • Suitable for simple operations without explicit locks.
    import kotlinx.coroutines.atomic.AtomicInt
    import kotlinx.coroutines.*
    
    val atomicCounter = AtomicInt(0)
    
    suspend fun atomicIncrement() {
        atomicCounter.incrementAndGet()
    }
    
    suspend fun main() = coroutineScope {
        repeat(10_000) {
            launch {
                atomicIncrement()
            }
        }
        println(atomicCounter.value) // Expect 10000
    }
    
  4. Shared data structures on a single-threaded dispatcher

    • The simplest approach. Running coroutines on a single-threaded context (Dispatchers.Default.limitedParallelism(1) or newSingleThreadContext) guarantees sequential execution of code, avoiding data races. It is not an explicit synchronization primitive but ensures synchronization by serializing access.
    import kotlinx.coroutines.*
    
    val mySingleThreadContext = newSingleThreadContext("SingleThread")
    var sharedData = mutableListOf<Int>()
    
    suspend fun addToSharedData(value: Int) {
        sharedData.add(value) // Safe as it runs on a single thread
    }
    
    suspend fun main() = withContext(mySingleThreadContext) {
        repeat(10_000) {
            launch {
                 addToSharedData(it)
            }
        }
        println(sharedData.size) // Expect 10000
    }
    
  5. Channels

    • Not primitives of synchronization in themselves, but can be used for safe data transfer between coroutines, indirectly solving access synchronization issues.
    • Provide a way to transfer a stream of data from one coroutine to another.

The choice of approach depends on the use case scenario. Mutex and Semaphore provide classic locking mechanisms, Atomic operations are efficient for simple atomic updates, and a single-threaded dispatcher is convenient when access to shared data must be strictly sequential. Channels are used for communication and coordination, not direct protection of shared resources.