Kotlin Coroutines: Fundamentals, Cancellation, and Timeout Handling
Asynchronous programmming in Kotlin relies on suspend functions, utilizing builders like launch and async from the kotlinx.coroutines library.
Initiating a Coroutine
A coroutine serves as a lightweight thread. It is launched within a specific CoroutineScope using a builder such as launch. When started in GlobalScope, the coroutine's lifespan is tied to the entire application.
import kotlinx.coroutines.*
fun main() {
GlobalScope.launch {
delay(1500L) // Non-blocking suspension
println("Universe!")
}
println("Greetings,")
Thread.sleep(3000L) // Block main thread to keep JVM alive
}
Output:
Greetings, Universe!
Replacing GlobalScope.launch with thread and delay with Thread.sleep achieves a similar outcome. However, invoking a suspend function like delay outside a coroutine results in a compilation error, as suspend functions are restricted to coroutine contexts.
Bridging Blocking and Non-Blocking Contexts
Mixing blocking (Thread.sleep) and non-blocking (delay) calls can cause confusion. The runBlocking builder provides a clear boundary, blocking the current thread until its execution block completes.
import kotlinx.coroutines.*
fun main() = runBlocking<Unit> {
GlobalScope.launch {
delay(1500L)
println("Universe!")
}
println("Greetings,")
delay(3000L)
}
Instead of delaying arbitrarily to wait for a background task, storing the Job returned by launch and calling join() ensures the main coroutine waits for the child coroutine to finish.
Structured Concurrency
Relying on GlobalScope creates unmanaged top-level coroutines that consume memory even if forgotten. Structured concurrency solves this by launching coroutines within specific scopes. The outer coroutine (runBlocking in this case) will not complete until all launched child coroutines finish, eliminating the need for explicit join() calls.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(1500L)
println("Universe!")
}
println("Greetings,")
}
Custom Scope Builders
The coroutineScope builder declares a custom scope that suspends until all child coroutines complete. Unlike runBlocking, which blocks the underlying thread, coroutineScope merely suspends, freeing the thread for other tasks.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(300L)
println("Outer scope task")
}
coroutineScope {
launch {
delay(700L)
println("Inner scope task")
}
delay(150L)
println("Coroutine scope message")
}
println("Scope completed")
}
Extracting Suspend Functions
Refactoring a coroutine block into a separate function requires adding the suspend modifier. This allows the function to invoke other suspend functions freely.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch { executeTask() }
println("Greetings,")
}
suspend fun executeTask() {
delay(1500L)
println("Universe!")
}
If the extracted function needs to launch a coroutine, passing a CoroutineScope as a receiver or parameter is the idiomatic approach.
Daemon-like Behavior of Global Coroutines
Coroutines launched in GlobalScope do not prevent the JVM from shutting down, functioning similarly to daemon threads.
import kotlinx.coroutines.*
fun main() = runBlocking {
GlobalScope.launch {
repeat(1000) { idx ->
println("Working on iteration $idx...")
delay(600L)
}
}
delay(2000L)
}
Terminating Coroutines
Long-running background tasks often require programmatic cancellation. The Job object provides cancel() and join() methods. The combined cancelAndJoin() function is typically used to halt a coroutine and wait for its finalization.
import kotlinx.coroutines.*
fun main() = runBlocking {
val backgroundTask = launch {
repeat(1000) { idx ->
println("Task running: iteration $idx...")
delay(600L)
}
}
delay(2000L)
println("Initiating cancellation...")
backgroundTask.cancelAndJoin()
println("Task terminated.")
}
Cooperative Cancellation
Coroutine cencellation is cooperative; simply calling cancel does not forcefully stop execution. While standard suspend functions like delay check for cancellation and throw CancellationException, a tightly bound computation loop will ignore cancellation requests.
import kotlinx.coroutines.*
fun main() = runBlocking {
val computationTask = launch(Dispatchers.Default) {
var counter = 0
var nextTimestamp = System.currentTimeMillis()
while (counter < 10) {
if (System.currentTimeMillis() >= nextTimestamp) {
println("Computing: step ${counter++}")
nextTimestamp += 500L
}
}
}
delay(1500L)
println("Attempting cancellation...")
computationTask.cancelAndJoin()
println("Finished.")
}
Enforcing Cancellation in Computation
To make computation logic responsive to cancellation, check the isActive property within the loop condition, or periodically invoke a suspand function like yield().
import kotlinx.coroutines.*
fun main() = runBlocking {
val cancellableTask = launch(Dispatchers.Default) {
var counter = 0
var nextTimestamp = System.currentTimeMillis()
while (isActive) {
if (System.currentTimeMillis() >= nextTimestamp) {
println("Computing: step ${counter++}")
nextTimestamp += 500L
n }
}
}
delay(1500L)
println("Initiating cancellation...")
cancellableTask.cancelAndJoin()
println("Task successfully halted.")
}
Resource Cleanup with Finally
When a cancellable suspend function throws CancellationException, standard try-finally blocks or Kotlin's use function execute their cleanup logic as expected.
import kotlinx.coroutines.*
fun main() = runBlocking {
val resourceTask = launch {
try {
repeat(1000) { idx ->
println("Allocating step $idx...")
delay(500L)
}
} finally {
println("Releasing resources in finally block")
}
}
delay(1500L)
println("Triggering stop...")
resourceTask.cancelAndJoin()
println("Cleanup complete.")
}
Non-Cancellable Execution Blocks
Invoking a suspend function inside a finally block usually fails with CancellationException because the coroutine is already cancelled. For cleanup operations requiring suspension (like closing a database connection asynchronously), wrap the logic in withContext(NonCancellable).
import kotlinx.coroutines.*
fun main() = runBlocking {
val resilientTask = launch {
try {
repeat(1000) { idx ->
println("Executing step $idx...")
delay(400L)
}
} finally {
withContext(NonCancellable) {
println("Starting non-cancellable cleanup")
delay(1000L)
println("Cleanup finalized after delay")
}
}
}
delay(1000L)
println("Halting task...")
resilientTask.cancelAndJoin()
println("Process ended.")
}
Handling Timeouts
Manually tracking a job to cancel it after a duration is unnecessary; withTimeout handles this directly. It throws TimeoutCancellationException (a subclass of CancellationException) upon expiration.
import kotlinx.coroutines.*
fun main() = runBlocking {
withTimeout(1500L) {
repeat(1000) { idx ->
println("Processing item $idx...")
delay(400L)
}
}
}
To treat a timeout as a null result rather than an exception, use withTimeoutOrNull.
import kotlinx.coroutines.*
fun main() = runBlocking {
val outcome = withTimeoutOrNull(1500L) {
repeat(1000) { idx ->
println("Processing item $idx...")
delay(400L)
}
"Completed successfully"
}
println("Operation result: $outcome")
}