article banner

Exercise: CancellingRefresher

You want to make sure that if a new refresh is started, the previous one is cancelled. You have the following code:

class CancellingRefresher( private val scope: CoroutineScope, private val refreshData: suspend () -> Unit, ) { private var refreshJob: Job? = null fun refresh() { refreshJob?.cancel() refreshJob = scope.launch { refreshData() } } }

The problem is that this implementation is not correct, because if started concurrently, refresh function might not cancel some coroutines. Your task is to fix it.

This is a popular pattern on Android, but there in most cases there is no need for synchronization, because UI handlers are always called on the main thread.

Make sure that the refresh function is thread-safe using the following techniques:

  • Using synchronized block.
  • Using Mutex.
  • Using a dispatcher limited to a single thread.

Compare how much time your unit tests take for each solution.

Check if you can solve this problem using a concurrent set of jobs, just remember to remove jobs that are cancelled already.

This problem can either be solved in the below playground or you can clone kotlin-exercises project and solve it locally. In the project, you can find code template for this exercise in effective/safe/CancellingRefresher.kt. You can find there starting code and unit tests.

Once you are done with the exercise, you can check your solution here.

Playground

import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import org.junit.Test import kotlin.test.assertEquals class CancellingRefresher( private val scope: CoroutineScope, private val refreshData: suspend () -> Unit, ) { private var refreshJob: Job? = null fun refresh() { refreshJob?.cancel() refreshJob = scope.launch { refreshData() } } } class CancellingRefresherTest { @Test fun `should cancel previous refresh when starting new one`(): Unit = runTest { val userRefresher = CancellingRefresher( scope = backgroundScope, refreshData = { delay(1000) } ) coroutineScope { repeat(1000) { launch { userRefresher.refresh() } } delay(1000) repeat(1000) { launch { userRefresher.refresh() } } delay(1000) repeat(1000) { launch { userRefresher.refresh() } } } assertEquals(2000, currentTime) // Delays val children = backgroundScope.coroutineContext[Job]!!.children assertEquals(1, children.count { it.isActive }) children.forEach { it.join() } assertEquals(3000, currentTime) } @Test fun `should cancel all previous jobs`(): Unit = runTest { val userRefresher = CancellingRefresher( scope = backgroundScope, refreshData = { delay(Long.MAX_VALUE) } ) coroutineScope { repeat(50_000) { launch { userRefresher.refresh() } } } delay(1000) assertEquals(1, backgroundScope.coroutineContext.job.children.count { it.isActive }) } @Test fun `should cancel all previous jobs (real time)`(): Unit = runBlocking(Dispatchers.Default) { val backgroundScope = CoroutineScope(Job() + Dispatchers.Default) val userRefresher = CancellingRefresher( scope = backgroundScope, refreshData = { delay(Long.MAX_VALUE) } ) coroutineScope { repeat(50_000) { launch { userRefresher.refresh() } } } delay(1000) assertEquals(1, backgroundScope.coroutineContext.job.children.count { it.isActive }) backgroundScope.cancel() } }