Fine control over execution in Kotlin
One of Kotlin’s most powerful features is its approach to concurrent programming through coroutines. At the heart of this system lies a concept called “cooperative cancellation” - a mechanism that allows coroutines to work together efficiently while maintaining the ability to be cancelled gracefully. In this post, we’ll explore how suspension points enable this cooperation and how they affect your code’s cancellation behavior.
Understanding Suspension Points
A suspension point in Kotlin is a point in your code where a coroutine can:
- Check for cancellation: Verify if it should stop execution
- Release the thread: Allow other coroutines to run
- Resume later: Continue execution when resources are available
Think of suspension points as “cooperation checkpoints” where your coroutine says, “I’m at a good stopping point. Does anyone need me to pause or stop?”
The Basics: Jobs and Cancellation
The Job
interface is central to Kotlin’s cancellation system. It provides a cancel
method that initiates the cancellation process. Here’s a real-world example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class UserRepository(
private val api: UserApi,
private val database: UserDatabase,
private val scope: CoroutineScope
) {
private var syncJob: Job? = null
fun startSync() {
// Cancel any existing sync
syncJob?.cancel()
syncJob = scope.launch {
try {
while (true) {
println("Starting sync cycle...")
val users = withContext(Dispatchers.IO) {
api.fetchUsers() // Suspension point - network call
}
users.forEach { user ->
// Yield periodically to cooperate with cancellation
yield()
database.updateUser(user)
}
println("Sync completed")
delay(60_000) // Wait for 1 minute before next sync
}
} catch (e: CancellationException) {
println("Sync was cancelled")
throw e
} finally {
println("Cleaning up sync resources")
}
}
}
fun stopSync() {
syncJob?.cancel()
}
}
// Usage
fun main() = runBlocking {
val repository = UserRepository(mockApi(), mockDatabase(), this)
repository.startSync()
delay(3000) // Let it sync for 3 seconds
repository.stopSync()
println("Sync stopped")
}
When we call cancel on a job, three key things happen:
- The job is marked for cancellation
- At the next suspension point, the job checks this cancellation flag and stops execution
- Any child coroutines follow the same process at their next suspension points
- The cancelled job becomes unusable as a parent for new coroutines
Let’s look at a common mistake when dealing with cancellation - blocking operations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class ImageProcessor(private val scope: CoroutineScope) {
private var processingJob: Job? = null
fun processImages(images: List<Image>) {
processingJob?.cancel()
processingJob = scope.launch {
try {
images.forEach { image ->
println("Processing image: ${image.name}")
// BAD: This blocks the thread and ignores cancellation
Thread.sleep(100)
image.applyFilter()
// GOOD: This cooperates with cancellation
// delay(100)
// ensureActive() // Explicit cancellation check
// image.applyFilter()
println("Image processed: ${image.name}")
}
} catch (e: CancellationException) {
println("Image processing cancelled")
throw e
}
}
}
}
// Usage showing the difference
fun main() = runBlocking {
val processor = ImageProcessor(this)
val images = List(100) { Image("IMG_$it.jpg") }
processor.processImages(images)
delay(250) // Let it process a few images
processor.processingJob?.cancel()
println("Cancellation requested")
}
// With Thread.sleep: Continues processing all images
// With delay: Stops after ~2-3 images
Handling Long-Running Operations
Here’s a real-world example of how to handle long-running operations with proper resource management:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class FileUploader(
private val api: FileApi,
private val scope: CoroutineScope
) {
private val activeUploads = mutableMapOf<String, Job>()
fun startUpload(fileId: String, file: File) {
// Cancel existing upload if any
activeUploads[fileId]?.cancel()
activeUploads[fileId] = scope.launch {
try {
val fileStream = withContext(Dispatchers.IO) {
FileInputStream(file)
}
fileStream.use { stream ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytes = 0L
while (stream.read(buffer).also { bytesRead = it } != -1) {
ensureActive() // Check for cancellation
withContext(Dispatchers.IO) {
api.uploadChunk(fileId, buffer, bytesRead)
}
totalBytes += bytesRead
println("Uploaded $totalBytes bytes")
yield() // Cooperate with other coroutines
}
}
println("Upload completed for $fileId")
} catch (e: CancellationException) {
println("Upload cancelled for $fileId")
throw e
} finally {
activeUploads.remove(fileId)
println("Cleaned up upload resources for $fileId")
}
}
}
fun cancelUpload(fileId: String) {
activeUploads[fileId]?.cancel()
}
}
Summary
Through these real-world examples, we’ve learned that effective coroutine cancellation relies on proper suspension points. Here are the key principles for writing cancellation-aware coroutines:
- Use suspend functions that create real suspension points (
delay
,yield
,withContext
, etc.) - Avoid thread-blocking operations that prevent cooperation
- Properly handle resources with try-finally blocks
- Check cancellation status regularly with
ensureActive()
- Use structured concurrency with parent-child job relationships
- Consider using supervisorScope for independent child failures
- Always clean up resources in finally blocks
By following these guidelines, you’ll create more robust and maintainable concurrent applications that can handle cancellation gracefully.