multithreadingkotlincachingconcurrencythread-safety

How to fix a race condition in a Kotlin 'get-or-create' cache?


My getResource method below needs to cache a single resource, but it has a clear race condition. If multiple threads call it simultaneously, the expensive createResource function can be called more than once, leading to an incorrect state.

How can this code be modified to be thread-safe and guarantee createResource is only called once when needed? What are the standard Kotlin patterns to solve this, and what are their trade-offs?


// A simple data class representing the resource we want to cache.
data class CachedResource(val id: String)

/**
 * This class has a race condition.
 */
class ResourceProvider {

    // The shared mutable state that needs protection.
    private var cachedResource: CachedResource? = null

    // This simulates an expensive operation that we want to avoid calling unnecessarily.
    private fun createResource(id: String): CachedResource {
        println("Creating new resource for id: $id...")
        // In a real app, this might be a heavy database call or network request.
        Thread.sleep(1000) // Simulate work
        return CachedResource(id)
    }

    /**
     * This method is NOT thread-safe.
     * How can I fix the race condition inside?
     */
    fun getResource(id: String): CachedResource {
        val localCache = cachedResource

        // CHECK: If the cache is valid, return it.
        if (localCache != null && localCache.id == id) {
            return localCache
        }

        // ACT: If not, create and update the cache.
        // This is the problematic section.
        val newResource = createResource(id)
        cachedResource = newResource
        return newResource
    }
}

Solution

  • If Java lock primitives are an option, you can use ReentrantLock (javadoc link). In this case the code will look like this:

    import java.util.concurrent.locks.ReentrantLock
    
    data class CachedResource(val id: String)
    
    class ResourceProvider {
    
        private var cachedResource: CachedResource? = null
        private val lock: ReentrantLock = ReentrantLock()
    
        private fun createResource(id: String): CachedResource {
            println("Creating new resource for id: $id...")
            Thread.sleep(1_000) // Simulate work
            return CachedResource(id)
        }
    
        fun getResource(id: String): CachedResource {
            lock.lock()
            try {
    
                if (cachedResource == null || cachedResource!!.id != id) {
                    cachedResource = createResource(id)
                }
                return cachedResource!!
    
            } finally {
                lock.unlock()
            }
        }
    }
    

    An alternative implementation based on @Slaw 's comment:

    import kotlin.concurrent.withLock
    import java.util.concurrent.locks.ReentrantLock
    
    ...
    
    
        fun getResource(id: String): CachedResource {
            lock.withLock {
                if (cachedResource == null || cachedResource!!.id != id) {
                    cachedResource = createResource(id)
                }
                return cachedResource!!
            }
        }