Best Practices for Coroutine Cancellation and Non-Cancellation in Kotlin

Best Practices for Coroutine Cancellation and Non-Cancellation in Kotlin

Kotlin Coroutines offer a powerful mechanism for handling concurrency while maintaining code clarity and efficiency. However, improper cancellation handling can lead to resource leaks, inconsistent states, and unexpected behavior.

Coroutine cancellation is a crucial aspect of writing efficient and reliable concurrent code in Kotlin. While coroutines help manage concurrency with ease, improper handling of cancellation can lead to memory leaks, unexpected behaviour and crashes. So understanding when and how to cancel coroutines ensures smooth application behavior and optimal resource management. Let’s explore best practices for coroutine cancellation and when to avoid cancelling them.

Understanding Coroutine Cancellation

Kotlin coroutines operate cooperatively, meaning they only cancel when they reach a suspension point or explicitly check for cancellation. The Job associated with a coroutine can be cancelled, propagating the cancellation request to its child coroutines.

val job = CoroutineScope(Dispatchers.IO).launch {
    repeat(1000) { i ->
        if (!isActive) return@launch
        println("Processing $i")
        delay(1.seconds)
    }
}

Thread.sleep(10.seconds)
job.cancel() // Cancels the coroutine after ten seconds        

Here, isActive ensures that the coroutine stops execution once it is cancelled.

Best Practices for Coroutine Cancellation

1. Regularly Check for Cancellation

Use isActive, ensureActive() inside long-running loops or computations to allow for cooperative cancellation.

while (true) {
    ensureActive() // Throws CancellationException if cancelled
    performTask()
    delay(500.milliseconds) // Simulates periodic work
}        

2. Use yield() for Fair Cooperative Scheduling

yield() is a suspending function that allows other coroutines to execute by giving up the thread. This is particularly useful in long-running loops that do not contain other suspension points like delay(). Without yield(), a coroutine running an infinite loop might block execution of other coroutines.

Example: Using yield() in a CPU-Intensive Task

suspend fun performComputation() {
    repeat(1000) {
        println("Processing item $it")
        yield() // Allows other coroutines to execute
    }
}

CoroutineScope(Dispatchers.Default).launch {
     performComputation()
}        

Here, yield() ensures that other coroutines in Dispatchers.Default get a chance to execute, preventing starvation and improving concurrency. Without yield(), this coroutine could run uninterrupted and delay the execution of other important tasks.

3. Avoid Ignoring Cancellation Exceptions

Catching CancellationException without rethrowing it can interfere with proper cancellation handling.

try {
    delay(1.seconds)
} catch (e: CancellationException) {
    throw e // Always rethrow CancellationException
} catch (e: Exception) {
    println("Handled exception")
}        

If CancellationException is caught but not rethrown, the coroutine may continue executing when it should have been cancelled. This can lead to unexpected behavior, such as unnecessary computations or partial results being processed, even though the coroutine was intended to stop.

4. Use supervisorScope for Independent Cancellation and Exception Handling

If a child coroutine fails in a regular coroutineScope, it cancels the entire scope. However, supervisorScope allows child coroutines to fail independently without affecting others. This is useful when some tasks should complete even if others fail.

Example: Independent Child Coroutine Failure

supervisorScope {
    launch {
        try {
            delay(2.seconds)
            println("Process A completed successfully")
        } catch (e: Exception) {
            println("Process A failed: ${e.message}")
        }
    }
    launch {
        delay(1.seconds)
        println("Process B completed successfully")
    }
}        

In this example, even if Process A fails, Process B continues execution because supervisorScope prevents cascading failures.

5. Cancelling and Restarting API Calls

When making API calls in response to user input, such as a search query, you should debounce the calls to avoid unnecessary network requests. Using a Job allows cancellation of previous requests before starting a new one.

Example: Cancelling Previous API Calls in ViewModel

class SearchViewModel : ViewModel() {
    private var searchJob: Job? = null

    fun search(query: String) {
        searchJob?.cancel() // Cancel the previous job if it's still running
        searchJob = viewModelScope.launch {
            delay(500.milliseconds) // Debounce time
            val results = fetchSearchResults(query)
            processResults(results)
        }
    }
}        

Here, each time search() is called, the previous search job is cancelled, ensuring only the latest query is processed after a debounce period.

6. Use withContext(NonCancellable) for Critical Operations

When a coroutine is cancelled, its finally block executes, but if it contains a priority suspending function which should not be cancelled, it may fail due to cancellation exception. Use withContext(NonCancellable) to ensure critical operations complete successfully.

try {
    withContext(NonCancellable) {
        saveDataToDatabase()
        delay(1.seconds) // Simulating long-running operation
    }
} finally {
    println("Cleanup resources")
}        

7. Timeouts and Cancellation

Use withTimeout and withTimeoutOrNull to cancel long-running operations automatically.

try {
    withTimeout(5.seconds) { // Cancels after 5 seconds
        fetchData()
    }
} catch (e: TimeoutCancellationException) {
    println("Operation timed out")
}        

If you want to handle timeouts gracefully without throwing an exception, use withTimeoutOrNull:

val result = withTimeoutOrNull(5.seconds) {
    fetchData()
}
if (result == null) {
    println("Operation timed out, returning default value")
}        

When to Cancel Coroutines

1. When Preventing Unnecessary Work

Cancel coroutines that are no longer needed, such as outdated API calls due to user input changes. This avoids wasting resources on redundant tasks.

2. When Exiting a Screen or Component

If a coroutine runs in a UI component (e.g., an Activity or Fragment), cancel it in onDestroy() to prevent memory leaks and avoid running unnecessary tasks in the background.

3. When Using Timeouts for Long Operations

Operations that shouldn’t block indefinitely should be canceled using withTimeout() or withTimeoutOrNull() to prevent resource wastage.

When Not to Cancel Coroutines

1. During Important Transactions

If you’re updating a database or writing a file, canceling the coroutine in the middle can leave data incomplete or corrupted. Always ensure such operations finish properly.

2. When Completing Critical User Actions

Imagine a user tapping “Save” on a form, but the coroutine handling the save gets canceled. This could lead to lost data, frustrating the user. In such cases, allow the operation to finish.

3. In Background Tasks That Can Resume

Some background tasks, like syncing data or fetching updates, don’t need to fail immediately when canceled. Instead, they should handle cancellation gracefully and restart later when needed.

Summary

By following best practices for coroutine cancellation, such as checking isActive, rethrowing CancellationException, using NonCancellable, leveraging supervisorScope, using yield(), and managing timeouts effectively, you can ensure your Kotlin applications are robust and reliable. Understanding when not to cancel coroutines is equally important to maintain data integrity and a smooth user experience.

To view or add a comment, sign in

More articles by Evangelist Apps

Insights from the community

Others also viewed

Explore topics