Skip to content
AndroidAndroid

Understanding Job Types in Kotlin CoroutinesEntender los tipos de Job en las corrutinas de Kotlin

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 11 July 2026Actualizado el 11 de julio de 20266 min read6 min de lectura
AndroidKotlinCoroutinesMobile DevelopmentConcurrency
Understanding Job Types in Kotlin Coroutines
Share:Compartir:

Kotlin Coroutines

Kotlin Coroutines expose four Job types, and each one controls failure and cancellation differently. A standard Job cancels its siblings when a child fails, a SupervisorJob isolates each child’s failure so others keep running, a CompletableJob lets you complete or fail the work from outside the coroutine, and NonCancellable keeps critical cleanup running even during cancellation. Pick the type that matches how you want failures to propagate through your coroutine hierarchy.

What Is a Job?

A Job represents a cancellable piece of work with a lifecycle. Think of it as a handle to a coroutine that allows you to control and monitor its execution. Every coroutine you launch creates a Job, whether you explicitly capture it or not.

The Job interface provides several key capabilities. It tracks the state of the coroutine through properties like isActive, isCompleted, and isCancelled. It allows you to cancel the coroutine and all its children. It enables you to wait for completion using the join() function. It also establishes the parent-child relationships that structured concurrency uses.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Every launch creates a Job
    val job: Job = launch {
        delay(1000)
        println("Coroutine completed")
    }
    
    // Check job state
    println("Is active: ${job.isActive}")           // true
    println("Is completed: ${job.isCompleted}")     // false
    println("Is cancelled: ${job.isCancelled}")     // false
    
    // Wait for completion
    job.join()
    
    println("Is completed now: ${job.isCompleted}") // true
}

Job lifecycle states

When a Job is created, it starts in the New state if created lazily, or moves immediately to Active. In the Active state, the coroutine is running and executing its code. From Active, the Job can transition to Completing when its body finishes but it waits for children to complete. Once all children complete, the Job moves to Completed.

Alternatively, from any state, a Job can be cancelled, moving it to Cancelling and eventually Cancelled. The distinction between Cancelling and Cancelled matters because cleanup code in finally blocks runs during the Cancelling phase.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(start = CoroutineStart.LAZY) {
        try {
            println("Starting work...")
            delay(5000)
            println("Work completed")
        } finally {
            println("Cleanup in finally block")
        }
    }
    
    println("Job state - New: ${job.isActive}")  // false (not started yet)
    
    job.start()
    println("Job state - Active: ${job.isActive}")  // true
    
    delay(100)
    job.cancel()
    println("Job state - Cancelling: ${job.isCancelled}")  // true
    
    job.join()
    println("Job state - Cancelled: ${job.isCancelled && job.isCompleted}")  // true
}

The lazy start option gives you fine-grained control over when a coroutine begins execution. This is useful when you need to set up the coroutine but delay its start until certain conditions are met.

The Standard Job: Hierarchical Failure Propagation

The standard Job implements what is called structured concurrency with bidirectional cancellation. When a child coroutine fails with an exception, the failure propagates upward to the parent Job, which then cancels all other children. This behaviour ensures that related work fails together, preventing partial completion scenarios that could leave your application in an inconsistent state.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parentJob = launch {
        val child1 = launch {
            try {
                println("Child 1: Starting long operation")
                delay(5000)
                println("Child 1: Completed")
            } catch (e: CancellationException) {
                println("Child 1: Was cancelled")
            }
        }
        
        val child2 = launch {
            delay(500)
            println("Child 2: About to fail")
            throw RuntimeException("Child 2 failed!")
        }
        
        val child3 = launch {
            try {
                println("Child 3: Starting work")
                delay(3000)
                println("Child 3: Completed")
            } catch (e: CancellationException) {
                println("Child 3: Was cancelled")
            }
        }
    }
    
    try {
        parentJob.join()
    } catch (e: Exception) {
        println("Parent caught: ${e.message}")
    }
    
    println("Parent job cancelled: ${parentJob.isCancelled}")
}

In this example, when Child 2 throws an exception after 500 milliseconds, the parent Job receives the exception and cancels both Child 1 and Child 3. This cascading cancellation is the default behaviour for tightly coupled operations.

Consider a scenario where you are fetching user data from multiple endpoints. If one critical endpoint fails, you probably want to cancel the other requests rather than proceeding with incomplete data. The standard Job behaviour handles this automatically.

SupervisorJob: Independent Child Failure

Sometimes you want child coroutines to fail independently without affecting their siblings. A SupervisorJob creates a scope where each child’s failure is isolated, so the other children keep running.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    
    val scope = CoroutineScope(coroutineContext + supervisor)
    
    val child1 = scope.launch {
        try {
            println("Child 1: Starting resilient operation")
            delay(3000)
            println("Child 1: Completed successfully")
        } catch (e: CancellationException) {
            println("Child 1: Was cancelled")
        }
    }
    
    val child2 = scope.launch {
        delay(500)
        println("Child 2: Throwing exception")
        throw RuntimeException("Child 2 failed!")
    }
    
    val child3 = scope.launch {
        try {
            println("Child 3: Starting independent work")
            delay(2000)
            println("Child 3: Completed successfully")
        } catch (e: CancellationException) {
            println("Child 3: Was cancelled")
        }
    }
    
    // Wait for all children
    joinAll(child1, child2, child3)
    
    println("Supervisor still active: ${supervisor.isActive}")
    
    supervisor.cancel()
}

When Child 2 fails, Child 1 and Child 3 continue executing and complete successfully. The SupervisorJob does not propagate the failure to siblings. However, you must handle exceptions in each child coroutine, or use a CoroutineExceptionHandler at the scope level.

The supervisorScope builder provides a convenient way to create a supervised scope without manually creating a SupervisorJob:

import kotlinx.coroutines.*

suspend fun fetchMultipleResources() = supervisorScope {
    val results = mutableListOf<String>()
    
    val job1 = async {
        delay(1000)
        "Resource 1 loaded"
    }
    
    val job2 = async {
        delay(500)
        throw IOException("Network error for Resource 2")
    }
    
    val job3 = async {
        delay(800)
        "Resource 3 loaded"
    }
    
    // Each result is handled independently
    listOf(job1, job2, job3).forEach { deferred ->
        try {
            results.add(deferred.await())
        } catch (e: Exception) {
            println("One resource failed: ${e.message}")
        }
    }
    
    results
}

fun main() = runBlocking {
    val resources = fetchMultipleResources()
    println("Successfully loaded: $resources")
}

On a dashboard, one widget failing should not stop the others from showing their content.

Real-World Use Case: Android ViewModel with SupervisorJob

Android ViewModels often use a SupervisorJob for independent loads:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

class DashboardViewModel(
    private val userRepository: UserRepository,
    private val analyticsRepository: AnalyticsRepository,
    private val notificationsRepository: NotificationsRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow(DashboardUiState())
    val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
    
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        // Log the error but don't crash the app
        println("Caught exception: ${throwable.message}")
    }
    
    fun loadDashboard() {
        // Each section loads independently using supervisorScope behaviour
        // viewModelScope already uses SupervisorJob internally
        
        viewModelScope.launch(exceptionHandler) {
            loadUserProfile()
        }
        
        viewModelScope.launch(exceptionHandler) {
            loadAnalytics()
        }
        
        viewModelScope.launch(exceptionHandler) {
            loadNotifications()
        }
    }
    
    private suspend fun loadUserProfile() {
        _uiState.update { it.copy(userLoading = true) }
        try {
            val user = userRepository.getUser()
            _uiState.update { it.copy(user = user, userLoading = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(userError = e.message, userLoading = false) }
        }
    }
    
    private suspend fun loadAnalytics() {
        _uiState.update { it.copy(analyticsLoading = true) }
        try {
            val analytics = analyticsRepository.getWeeklyStats()
            _uiState.update { it.copy(analytics = analytics, analyticsLoading = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(analyticsError = e.message, analyticsLoading = false) }
        }
    }
    
    private suspend fun loadNotifications() {
        _uiState.update { it.copy(notificationsLoading = true) }
        try {
            val notifications = notificationsRepository.getRecent()
            _uiState.update { it.copy(notifications = notifications, notificationsLoading = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(notificationsError = e.message, notificationsLoading = false) }
        }
    }
}

data class DashboardUiState(
    val user: User? = null,
    val userLoading: Boolean = false,
    val userError: String? = null,
    val analytics: AnalyticsData? = null,
    val analyticsLoading: Boolean = false,
    val analyticsError: String? = null,
    val notifications: List<Notification> = emptyList(),
    val notificationsLoading: Boolean = false,
    val notificationsError: String? = null
)

If the analytics API is down, the profile and notifications can still load. viewModelScope uses a SupervisorJob, so that isolation is already there.

CompletableJob: External Completion Control

A CompletableJob extends the Job interface with methods to manually complete or fail the job from outside. This is useful when you need to signal completion based on external events rather than the coroutine’s natural termination.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val completableJob = Job()  // Job() returns a CompletableJob
    
    val scope = CoroutineScope(Dispatchers.Default + completableJob)
    
    scope.launch {
        var counter = 0
        while (isActive) {
            println("Working... count: ${++counter}")
            delay(500)
        }
        println("Work stopped")
    }
    
    delay(2500)
    
    // Complete the job externally
    completableJob.complete()
    println("Job completed externally")
    
    // Or you could fail it with an exception:
    // completableJob.completeExceptionally(RuntimeException("External failure"))
    
    completableJob.join()
    println("All work finished")
}

The CompletableJob interface provides two key methods: complete() marks the job as completed successfully, and completeExceptionally(exception) marks it as failed with the given exception. Both methods return true if the job was completed as a result of the call, or false if it was already completed.

A practical use case is managing connection lifecycles:

import kotlinx.coroutines.*

class WebSocketManager {
    private var connectionJob: CompletableJob? = null
    private var scope: CoroutineScope? = null
    
    fun connect(url: String) {
        connectionJob = SupervisorJob()
        scope = CoroutineScope(Dispatchers.IO + connectionJob!!)
        
        scope?.launch {
            println("Connecting to $url...")
            // Simulate connection setup
            delay(1000)
            println("Connected!")
            
            // Start listening for messages
            launch {
                listenForMessages()
            }
            
            // Start heartbeat
            launch {
                sendHeartbeats()
            }
        }
    }
    
    private suspend fun listenForMessages() {
        while (currentCoroutineContext().isActive) {
            println("Listening for messages...")
            delay(2000)
        }
    }
    
    private suspend fun sendHeartbeats() {
        while (currentCoroutineContext().isActive) {
            println("Sending heartbeat...")
            delay(5000)
        }
    }
    
    fun disconnect() {
        println("Disconnecting...")
        connectionJob?.complete()
        connectionJob = null
        scope = null
    }
    
    fun disconnectWithError(reason: String) {
        println("Disconnecting due to error: $reason")
        connectionJob?.completeExceptionally(RuntimeException(reason))
        connectionJob = null
        scope = null
    }
}

fun main() = runBlocking {
    val manager = WebSocketManager()
    
    manager.connect("wss://example.com/socket")
    
    delay(8000)
    
    manager.disconnect()
    
    delay(1000)
    println("Application shutdown")
}

NonCancellable: Ensuring Critical Operations Complete

Sometimes you have cleanup or critical operations that must complete even when a coroutine is being cancelled. The NonCancellable Job context allows code to run to completion regardless of cancellation requests.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            println("Starting operation...")
            delay(5000)
            println("Operation completed normally")
        } catch (e: CancellationException) {
            println("Operation was cancelled")
            throw e  // Always rethrow CancellationException
        } finally {
            // This would fail without NonCancellable:
            // delay(1000)  // Throws CancellationException in cancelled coroutine
            
            // Use NonCancellable for cleanup that involves suspension
            withContext(NonCancellable) {
                println("Starting critical cleanup...")
                delay(1000)  // Now this works even during cancellation
                println("Critical cleanup completed")
            }
        }
    }
    
    delay(500)
    println("Cancelling job...")
    job.cancelAndJoin()
    
    println("Job is cancelled: ${job.isCancelled}")
}

Use NonCancellable for a database transaction, a file write, or a network call that has to finish so the data stays consistent:

import kotlinx.coroutines.*

class TransactionManager(private val database: Database) {
    
    suspend fun performTransaction(
        operations: suspend () -> Unit
    ) = coroutineScope {
        val transaction = database.beginTransaction()
        
        try {
            operations()
            transaction.commit()
            println("Transaction committed successfully")
        } catch (e: CancellationException) {
            // Even if cancelled, we must rollback properly
            withContext(NonCancellable) {
                println("Rolling back transaction due to cancellation...")
                transaction.rollback()
                println("Rollback completed")
            }
            throw e
        } catch (e: Exception) {
            withContext(NonCancellable) {
                println("Rolling back transaction due to error: ${e.message}")
                transaction.rollback()
                println("Rollback completed")
            }
            throw e
        }
    }
}

// Simulated database classes for the example
class Database {
    fun beginTransaction() = Transaction()
}

class Transaction {
    suspend fun commit() {
        delay(100)
        println("Database: Commit executed")
    }
    
    suspend fun rollback() {
        delay(100)
        println("Database: Rollback executed")
    }
}

fun main() = runBlocking {
    val db = Database()
    val manager = TransactionManager(db)
    
    val job = launch {
        manager.performTransaction {
            println("Performing database operations...")
            delay(5000)  // Long running operation
            println("Operations completed")
        }
    }
    
    delay(500)
    println("Cancelling transaction...")
    job.cancelAndJoin()
    
    println("Transaction handling complete")
}

Without NonCancellable, the rollback operation would be skipped during cancellation, potentially leaving your database in an inconsistent state.

Which Job Type Should You Use?

Standard Job should be used when child failures should cancel siblings, operations are tightly coupled and must succeed together, and you want automatic cleanup on any failure. Common scenarios include multi-step form submissions, dependent API calls, and atomic operations.

SupervisorJob is ideal when children should fail independently, partial success is acceptable, and you are building dashboards or aggregating multiple data sources. Use it for parallel loading of UI sections, batch processing where individual failures are acceptable, and long-running services with multiple independent tasks.

CompletableJob fits scenarios requiring external lifecycle control, when completion depends on events outside the coroutine, and when building custom scope management. Examples include connection managers, resource pools, and custom flow control.

NonCancellable is necessary for cleanup operations involving suspension, when operations must complete regardless of cancellation, and for maintaining data integrity during shutdown. Database transactions, file system operations, and audit logging are typical use cases.

Advanced Pattern: Custom Scope with Mixed Job Types

For complex applications, you might need to combine different Job types strategically:

import kotlinx.coroutines.*

class ApplicationScope {
    // Top-level supervisor allows services to fail independently
    private val supervisorJob = SupervisorJob()
    private val scope = CoroutineScope(Dispatchers.Default + supervisorJob)
    
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Uncaught exception in application scope: ${throwable.message}")
    }
    
    // Critical service that should cancel all related work on failure
    fun launchCriticalService(block: suspend CoroutineScope.() -> Unit): Job {
        return scope.launch(exceptionHandler) {
            // Uses regular Job - children fail together
            coroutineScope {
                block()
            }
        }
    }
    
    // Independent service that shouldn't affect others
    fun launchIndependentService(block: suspend CoroutineScope.() -> Unit): Job {
        return scope.launch(exceptionHandler) {
            // Each call is isolated by the parent SupervisorJob
            block()
        }
    }
    
    // Batch processor where individual items can fail
    fun <T> launchBatchProcessor(
        items: List<T>,
        processor: suspend (T) -> Unit
    ): Job {
        return scope.launch(exceptionHandler) {
            supervisorScope {
                items.forEach { item ->
                    launch {
                        try {
                            processor(item)
                        } catch (e: Exception) {
                            println("Failed to process item $item: ${e.message}")
                        }
                    }
                }
            }
        }
    }
    
    fun shutdown() {
        supervisorJob.cancel()
    }
}

fun main() = runBlocking {
    val appScope = ApplicationScope()
    
    // Launch critical service - all children fail together
    appScope.launchCriticalService {
        launch { 
            delay(1000)
            println("Critical task 1 done") 
        }
        launch { 
            delay(500)
            println("Critical task 2 done") 
        }
    }
    
    // Launch independent service
    appScope.launchIndependentService {
        delay(800)
        println("Independent service completed")
    }
    
    // Process batch with individual failure tolerance
    appScope.launchBatchProcessor(listOf("A", "B", "C", "D")) { item ->
        if (item == "C") {
            throw RuntimeException("Failed on item C")
        }
        delay(300)
        println("Processed item: $item")
    }
    
    delay(3000)
    appScope.shutdown()
    
    println("Application shutdown complete")
}

Performance Considerations

Creating a Job is inexpensive. Launching thousands of them for trivial work adds up. For high-frequency, short-lived work, use one coroutine with a loop.

Cancellation checking is cooperative and requires your code to check isActive or call suspending functions. Tight loops without suspension points will not respond to cancellation:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var sum = 0L
        
        // BAD: Won't respond to cancellation
        // for (i in 1..1_000_000_000) {
        //     sum += i
        // }
        
        // GOOD: Checks for cancellation periodically
        for (i in 1..1_000_000_000) {
            sum += i
            if (i % 1_000_000 == 0) {
                yield()  // Check for cancellation
            }
        }
        
        println("Sum: $sum")
    }
    
    delay(100)
    job.cancelAndJoin()
    println("Job cancelled")
}

The yield() function is a lightweight way to check for cancellation and give other coroutines a chance to run. For CPU-intensive work, calling yield() periodically ensures your code remains responsive to cancellation.

How Do You Test Coroutines That Use Jobs?

Testing coroutine code requires controlling the execution environment. The kotlinx-coroutines-test library provides tools for this:

import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.Test
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class JobTest {
    
    @Test
    fun `test job cancellation`() = runTest {
        var wasCompleted = false
        var wasCancelled = false
        
        val job = launch {
            try {
                delay(1000)
                wasCompleted = true
            } catch (e: CancellationException) {
                wasCancelled = true
                throw e
            }
        }
        
        advanceTimeBy(500)
        job.cancel()
        
        advanceUntilIdle()
        
        assertFalse(wasCompleted)
        assertTrue(wasCancelled)
        assertTrue(job.isCancelled)
    }
    
    @Test
    fun `test supervisor job isolation`() = runTest {
        val results = mutableListOf<String>()
        
        supervisorScope {
            launch {
                delay(100)
                results.add("Task 1 completed")
            }
            
            launch {
                delay(50)
                throw RuntimeException("Task 2 failed")
            }
            
            launch {
                delay(150)
                results.add("Task 3 completed")
            }
        }
        
        assertTrue(results.contains("Task 1 completed"))
        assertTrue(results.contains("Task 3 completed"))
    }
}

The runTest builder from the test library allows you to control virtual time and ensure deterministic test execution.

Common pitfalls

Forgetting to handle exceptions in supervised children. When using SupervisorJob, exceptions in children are not automatically propagated. You must handle them explicitly or use a CoroutineExceptionHandler.

Not joining Jobs before shutdown. If you cancel a scope without joining, cleanup code in finally blocks might not complete:

// BAD: Cleanup might not finish
scope.cancel()

// GOOD: Wait for cleanup to complete
scope.coroutineContext[Job]?.cancelAndJoin()

Blocking in coroutines instead of suspending. Using blocking calls like Thread.sleep() instead of delay() prevents cancellation from working:

// BAD: Can't be cancelled
Thread.sleep(1000)

// GOOD: Cancellation works properly
delay(1000)

Creating orphan coroutines. Launching coroutines with GlobalScope bypasses structured concurrency and can lead to resource leaks:

// BAD: No parent job, can't be cancelled with the component
GlobalScope.launch { ... }

// GOOD: Tied to component lifecycle
viewModelScope.launch { ... }

Happy coding!

David Cruz
davthecoder.com paglipat.com


Kotlin Coroutines

Las corrutinas de Kotlin exponen cuatro tipos de Job, y cada uno controla el fallo y la cancelación de una forma distinta. Un Job estándar cancela a sus hermanas cuando una hija falla, un SupervisorJob aísla el fallo de cada hija para que las demás sigan en marcha, un CompletableJob te deja completar o fallar el trabajo desde fuera de la corrutina, y NonCancellable mantiene en marcha la limpieza crítica incluso durante la cancelación. Elige el tipo que encaje con cómo quieres que se propaguen los fallos por tu jerarquía de corrutinas.

¿Qué es un Job?

Un Job representa un trozo de trabajo cancelable, con un ciclo de vida. Piénsalo como una referencia a una corrutina que te permite controlar y vigilar su ejecución. Cada corrutina que lanzas crea un Job, lo captures de forma explícita o no.

La interfaz Job aporta varias capacidades clave. Sigue el estado de la corrutina a través de propiedades como isActive, isCompleted e isCancelled. Te permite cancelar la corrutina y a todas sus hijas. Te permite esperar a que termine con la función join(). También establece las relaciones padre-hijo que usa la concurrencia estructurada.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Every launch creates a Job
    val job: Job = launch {
        delay(1000)
        println("Coroutine completed")
    }
    
    // Check job state
    println("Is active: ${job.isActive}")           // true
    println("Is completed: ${job.isCompleted}")     // false
    println("Is cancelled: ${job.isCancelled}")     // false
    
    // Wait for completion
    job.join()
    
    println("Is completed now: ${job.isCompleted}") // true
}

Estados del ciclo de vida de un Job

Cuando se crea un Job, empieza en el estado New si se crea de forma perezosa, o pasa de inmediato a Active. En el estado Active, la corrutina está en marcha y ejecuta su código. Desde Active, el Job puede pasar a Completing cuando su cuerpo termina, pero espera a que terminen las hijas. Cuando todas las hijas terminan, el Job pasa a Completed.

Otra posibilidad: desde cualquier estado, un Job se puede cancelar, y pasa a Cancelling y, al final, a Cancelled. La distinción entre Cancelling y Cancelled importa porque el código de limpieza de los bloques finally se ejecuta durante la fase Cancelling.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(start = CoroutineStart.LAZY) {
        try {
            println("Starting work...")
            delay(5000)
            println("Work completed")
        } finally {
            println("Cleanup in finally block")
        }
    }
    
    println("Job state - New: ${job.isActive}")  // false (not started yet)
    
    job.start()
    println("Job state - Active: ${job.isActive}")  // true
    
    delay(100)
    job.cancel()
    println("Job state - Cancelling: ${job.isCancelled}")  // true
    
    job.join()
    println("Job state - Cancelled: ${job.isCancelled && job.isCompleted}")  // true
}

La opción de arranque perezoso te da un control fino sobre cuándo empieza a ejecutarse una corrutina. Sirve cuando necesitas preparar la corrutina pero retrasar su arranque hasta que se cumplan ciertas condiciones.

El Job estándar: propagación jerárquica del fallo

El Job estándar implementa lo que se llama concurrencia estructurada con cancelación bidireccional. Cuando una corrutina hija falla con una excepción, el fallo se propaga hacia arriba, al Job padre, que entonces cancela a todas las demás hijas. Este comportamiento garantiza que el trabajo relacionado falle junto, y evita escenarios de finalización parcial que podrían dejar la aplicación en un estado inconsistente.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parentJob = launch {
        val child1 = launch {
            try {
                println("Child 1: Starting long operation")
                delay(5000)
                println("Child 1: Completed")
            } catch (e: CancellationException) {
                println("Child 1: Was cancelled")
            }
        }
        
        val child2 = launch {
            delay(500)
            println("Child 2: About to fail")
            throw RuntimeException("Child 2 failed!")
        }
        
        val child3 = launch {
            try {
                println("Child 3: Starting work")
                delay(3000)
                println("Child 3: Completed")
            } catch (e: CancellationException) {
                println("Child 3: Was cancelled")
            }
        }
    }
    
    try {
        parentJob.join()
    } catch (e: Exception) {
        println("Parent caught: ${e.message}")
    }
    
    println("Parent job cancelled: ${parentJob.isCancelled}")
}

En este ejemplo, cuando la hija 2 lanza una excepción a los 500 milisegundos, el Job padre recibe la excepción y cancela tanto a la hija 1 como a la hija 3. Esta cancelación en cascada es el comportamiento por defecto para operaciones muy acopladas.

Piensa en un caso en el que estás pidiendo datos de usuario a varios endpoints. Si un endpoint crítico falla, probablemente quieras cancelar el resto de peticiones en lugar de seguir con datos incompletos. El comportamiento del Job estándar lo resuelve solo.

SupervisorJob: fallo independiente de las hijas

A veces quieres que las corrutinas hijas fallen de forma independiente, sin afectar a sus hermanas. Un SupervisorJob crea un ámbito en el que el fallo de cada hija queda aislado, así que las demás siguen en marcha.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    
    val scope = CoroutineScope(coroutineContext + supervisor)
    
    val child1 = scope.launch {
        try {
            println("Child 1: Starting resilient operation")
            delay(3000)
            println("Child 1: Completed successfully")
        } catch (e: CancellationException) {
            println("Child 1: Was cancelled")
        }
    }
    
    val child2 = scope.launch {
        delay(500)
        println("Child 2: Throwing exception")
        throw RuntimeException("Child 2 failed!")
    }
    
    val child3 = scope.launch {
        try {
            println("Child 3: Starting independent work")
            delay(2000)
            println("Child 3: Completed successfully")
        } catch (e: CancellationException) {
            println("Child 3: Was cancelled")
        }
    }
    
    // Wait for all children
    joinAll(child1, child2, child3)
    
    println("Supervisor still active: ${supervisor.isActive}")
    
    supervisor.cancel()
}

Cuando la hija 2 falla, la hija 1 y la hija 3 siguen ejecutándose y terminan con éxito. El SupervisorJob no propaga el fallo a las hermanas. Eso sí, tienes que gestionar las excepciones en cada corrutina hija, o usar un CoroutineExceptionHandler al nivel del ámbito.

El builder supervisorScope ofrece una forma cómoda de crear un ámbito supervisado sin crear a mano un SupervisorJob:

import kotlinx.coroutines.*

suspend fun fetchMultipleResources() = supervisorScope {
    val results = mutableListOf<String>()
    
    val job1 = async {
        delay(1000)
        "Resource 1 loaded"
    }
    
    val job2 = async {
        delay(500)
        throw IOException("Network error for Resource 2")
    }
    
    val job3 = async {
        delay(800)
        "Resource 3 loaded"
    }
    
    // Each result is handled independently
    listOf(job1, job2, job3).forEach { deferred ->
        try {
            results.add(deferred.await())
        } catch (e: Exception) {
            println("One resource failed: ${e.message}")
        }
    }
    
    results
}

fun main() = runBlocking {
    val resources = fetchMultipleResources()
    println("Successfully loaded: $resources")
}

En un panel, que un widget falle no debería impedir que los demás muestren su contenido.

Caso real: un ViewModel de Android con SupervisorJob

Los ViewModels de Android suelen usar un SupervisorJob para cargas independientes:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

class DashboardViewModel(
    private val userRepository: UserRepository,
    private val analyticsRepository: AnalyticsRepository,
    private val notificationsRepository: NotificationsRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow(DashboardUiState())
    val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
    
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        // Log the error but don't crash the app
        println("Caught exception: ${throwable.message}")
    }
    
    fun loadDashboard() {
        // Each section loads independently using supervisorScope behaviour
        // viewModelScope already uses SupervisorJob internally
        
        viewModelScope.launch(exceptionHandler) {
            loadUserProfile()
        }
        
        viewModelScope.launch(exceptionHandler) {
            loadAnalytics()
        }
        
        viewModelScope.launch(exceptionHandler) {
            loadNotifications()
        }
    }
    
    private suspend fun loadUserProfile() {
        _uiState.update { it.copy(userLoading = true) }
        try {
            val user = userRepository.getUser()
            _uiState.update { it.copy(user = user, userLoading = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(userError = e.message, userLoading = false) }
        }
    }
    
    private suspend fun loadAnalytics() {
        _uiState.update { it.copy(analyticsLoading = true) }
        try {
            val analytics = analyticsRepository.getWeeklyStats()
            _uiState.update { it.copy(analytics = analytics, analyticsLoading = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(analyticsError = e.message, analyticsLoading = false) }
        }
    }
    
    private suspend fun loadNotifications() {
        _uiState.update { it.copy(notificationsLoading = true) }
        try {
            val notifications = notificationsRepository.getRecent()
            _uiState.update { it.copy(notifications = notifications, notificationsLoading = false) }
        } catch (e: Exception) {
            _uiState.update { it.copy(notificationsError = e.message, notificationsLoading = false) }
        }
    }
}

data class DashboardUiState(
    val user: User? = null,
    val userLoading: Boolean = false,
    val userError: String? = null,
    val analytics: AnalyticsData? = null,
    val analyticsLoading: Boolean = false,
    val analyticsError: String? = null,
    val notifications: List<Notification> = emptyList(),
    val notificationsLoading: Boolean = false,
    val notificationsError: String? = null
)

Si la API de analítica está caída, el perfil y las notificaciones pueden seguir cargando. viewModelScope usa un SupervisorJob, así que ese aislamiento ya está ahí.

CompletableJob: control externo de la finalización

Un CompletableJob amplía la interfaz Job con métodos para completar o fallar el job a mano desde fuera. Sirve cuando necesitas señalar la finalización a partir de eventos externos, en lugar de por la terminación natural de la corrutina.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val completableJob = Job()  // Job() returns a CompletableJob
    
    val scope = CoroutineScope(Dispatchers.Default + completableJob)
    
    scope.launch {
        var counter = 0
        while (isActive) {
            println("Working... count: ${++counter}")
            delay(500)
        }
        println("Work stopped")
    }
    
    delay(2500)
    
    // Complete the job externally
    completableJob.complete()
    println("Job completed externally")
    
    // Or you could fail it with an exception:
    // completableJob.completeExceptionally(RuntimeException("External failure"))
    
    completableJob.join()
    println("All work finished")
}

La interfaz CompletableJob ofrece dos métodos clave: complete() marca el job como completado con éxito, y completeExceptionally(exception) lo marca como fallido con la excepción dada. Los dos métodos devuelven true si el job se completó como resultado de la llamada, o false si ya estaba completado.

Un caso de uso práctico es gestionar el ciclo de vida de una conexión:

import kotlinx.coroutines.*

class WebSocketManager {
    private var connectionJob: CompletableJob? = null
    private var scope: CoroutineScope? = null
    
    fun connect(url: String) {
        connectionJob = SupervisorJob()
        scope = CoroutineScope(Dispatchers.IO + connectionJob!!)
        
        scope?.launch {
            println("Connecting to $url...")
            // Simulate connection setup
            delay(1000)
            println("Connected!")
            
            // Start listening for messages
            launch {
                listenForMessages()
            }
            
            // Start heartbeat
            launch {
                sendHeartbeats()
            }
        }
    }
    
    private suspend fun listenForMessages() {
        while (currentCoroutineContext().isActive) {
            println("Listening for messages...")
            delay(2000)
        }
    }
    
    private suspend fun sendHeartbeats() {
        while (currentCoroutineContext().isActive) {
            println("Sending heartbeat...")
            delay(5000)
        }
    }
    
    fun disconnect() {
        println("Disconnecting...")
        connectionJob?.complete()
        connectionJob = null
        scope = null
    }
    
    fun disconnectWithError(reason: String) {
        println("Disconnecting due to error: $reason")
        connectionJob?.completeExceptionally(RuntimeException(reason))
        connectionJob = null
        scope = null
    }
}

fun main() = runBlocking {
    val manager = WebSocketManager()
    
    manager.connect("wss://example.com/socket")
    
    delay(8000)
    
    manager.disconnect()
    
    delay(1000)
    println("Application shutdown")
}

NonCancellable: garantizar que las operaciones críticas terminen

A veces tienes limpieza, u operaciones críticas, que tienen que terminar incluso cuando se está cancelando una corrutina. El contexto de Job NonCancellable permite que el código se ejecute hasta el final, hagan lo que hagan las peticiones de cancelación.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            println("Starting operation...")
            delay(5000)
            println("Operation completed normally")
        } catch (e: CancellationException) {
            println("Operation was cancelled")
            throw e  // Always rethrow CancellationException
        } finally {
            // This would fail without NonCancellable:
            // delay(1000)  // Throws CancellationException in cancelled coroutine
            
            // Use NonCancellable for cleanup that involves suspension
            withContext(NonCancellable) {
                println("Starting critical cleanup...")
                delay(1000)  // Now this works even during cancellation
                println("Critical cleanup completed")
            }
        }
    }
    
    delay(500)
    println("Cancelling job...")
    job.cancelAndJoin()
    
    println("Job is cancelled: ${job.isCancelled}")
}

Usa NonCancellable para una transacción de base de datos, una escritura en fichero o una llamada de red que tenga que terminar para que los datos sigan siendo consistentes:

import kotlinx.coroutines.*

class TransactionManager(private val database: Database) {
    
    suspend fun performTransaction(
        operations: suspend () -> Unit
    ) = coroutineScope {
        val transaction = database.beginTransaction()
        
        try {
            operations()
            transaction.commit()
            println("Transaction committed successfully")
        } catch (e: CancellationException) {
            // Even if cancelled, we must rollback properly
            withContext(NonCancellable) {
                println("Rolling back transaction due to cancellation...")
                transaction.rollback()
                println("Rollback completed")
            }
            throw e
        } catch (e: Exception) {
            withContext(NonCancellable) {
                println("Rolling back transaction due to error: ${e.message}")
                transaction.rollback()
                println("Rollback completed")
            }
            throw e
        }
    }
}

// Simulated database classes for the example
class Database {
    fun beginTransaction() = Transaction()
}

class Transaction {
    suspend fun commit() {
        delay(100)
        println("Database: Commit executed")
    }
    
    suspend fun rollback() {
        delay(100)
        println("Database: Rollback executed")
    }
}

fun main() = runBlocking {
    val db = Database()
    val manager = TransactionManager(db)
    
    val job = launch {
        manager.performTransaction {
            println("Performing database operations...")
            delay(5000)  // Long running operation
            println("Operations completed")
        }
    }
    
    delay(500)
    println("Cancelling transaction...")
    job.cancelAndJoin()
    
    println("Transaction handling complete")
}

Sin NonCancellable, la operación de rollback se saltaría durante la cancelación, y podría dejar la base de datos en un estado inconsistente.

¿Qué tipo de Job deberías usar?

Job estándar. Úsalo cuando el fallo de una hija deba cancelar a las hermanas, cuando las operaciones estén muy acopladas y tengan que salir bien juntas, y cuando quieras una limpieza automática ante cualquier fallo. Los casos habituales incluyen envíos de formularios en varios pasos, llamadas a API que dependen unas de otras y operaciones atómicas.

SupervisorJob. Es ideal cuando las hijas deben fallar de forma independiente, cuando un éxito parcial es aceptable y cuando estás construyendo paneles o agregando varias fuentes de datos. Úsalo para la carga en paralelo de secciones de la interfaz, para el procesado por lotes en el que los fallos individuales son aceptables y para servicios de larga duración con varias tareas independientes.

CompletableJob. Encaja en escenarios que exigen control externo del ciclo de vida, cuando la finalización depende de eventos de fuera de la corrutina y cuando estás construyendo una gestión de ámbitos a medida. Los ejemplos incluyen gestores de conexión, pools de recursos y control de flujo a medida.

NonCancellable. Hace falta para operaciones de limpieza que implican suspensión, cuando las operaciones tienen que terminar haga lo que haga la cancelación y para mantener la integridad de los datos durante el cierre. Las transacciones de base de datos, las operaciones de sistema de ficheros y el registro de auditoría son casos de uso típicos.

Patrón avanzado: un ámbito a medida con tipos de Job mezclados

En aplicaciones complejas puede que necesites combinar distintos tipos de Job con estrategia:

import kotlinx.coroutines.*

class ApplicationScope {
    // Top-level supervisor allows services to fail independently
    private val supervisorJob = SupervisorJob()
    private val scope = CoroutineScope(Dispatchers.Default + supervisorJob)
    
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Uncaught exception in application scope: ${throwable.message}")
    }
    
    // Critical service that should cancel all related work on failure
    fun launchCriticalService(block: suspend CoroutineScope.() -> Unit): Job {
        return scope.launch(exceptionHandler) {
            // Uses regular Job - children fail together
            coroutineScope {
                block()
            }
        }
    }
    
    // Independent service that shouldn't affect others
    fun launchIndependentService(block: suspend CoroutineScope.() -> Unit): Job {
        return scope.launch(exceptionHandler) {
            // Each call is isolated by the parent SupervisorJob
            block()
        }
    }
    
    // Batch processor where individual items can fail
    fun <T> launchBatchProcessor(
        items: List<T>,
        processor: suspend (T) -> Unit
    ): Job {
        return scope.launch(exceptionHandler) {
            supervisorScope {
                items.forEach { item ->
                    launch {
                        try {
                            processor(item)
                        } catch (e: Exception) {
                            println("Failed to process item $item: ${e.message}")
                        }
                    }
                }
            }
        }
    }
    
    fun shutdown() {
        supervisorJob.cancel()
    }
}

fun main() = runBlocking {
    val appScope = ApplicationScope()
    
    // Launch critical service - all children fail together
    appScope.launchCriticalService {
        launch { 
            delay(1000)
            println("Critical task 1 done") 
        }
        launch { 
            delay(500)
            println("Critical task 2 done") 
        }
    }
    
    // Launch independent service
    appScope.launchIndependentService {
        delay(800)
        println("Independent service completed")
    }
    
    // Process batch with individual failure tolerance
    appScope.launchBatchProcessor(listOf("A", "B", "C", "D")) { item ->
        if (item == "C") {
            throw RuntimeException("Failed on item C")
        }
        delay(300)
        println("Processed item: $item")
    }
    
    delay(3000)
    appScope.shutdown()
    
    println("Application shutdown complete")
}

Consideraciones de rendimiento

Crear un Job es barato. Lanzar miles para un trabajo trivial se acaba notando. Para trabajo de alta frecuencia y vida corta, usa una sola corrutina con un bucle.

La comprobación de la cancelación es cooperativa y exige que tu código compruebe isActive o llame a funciones de suspensión. Los bucles cerrados sin puntos de suspensión no van a responder a la cancelación:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var sum = 0L
        
        // BAD: Won't respond to cancellation
        // for (i in 1..1_000_000_000) {
        //     sum += i
        // }
        
        // GOOD: Checks for cancellation periodically
        for (i in 1..1_000_000_000) {
            sum += i
            if (i % 1_000_000 == 0) {
                yield()  // Check for cancellation
            }
        }
        
        println("Sum: $sum")
    }
    
    delay(100)
    job.cancelAndJoin()
    println("Job cancelled")
}

La función yield() es una forma ligera de comprobar la cancelación y dar a otras corrutinas la oportunidad de ejecutarse. En trabajo intensivo de CPU, llamar a yield() de vez en cuando garantiza que tu código siga respondiendo a la cancelación.

¿Cómo pruebas las corrutinas que usan Jobs?

Probar código de corrutinas exige controlar el entorno de ejecución. La biblioteca kotlinx-coroutines-test da herramientas para eso:

import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.Test
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class JobTest {
    
    @Test
    fun `test job cancellation`() = runTest {
        var wasCompleted = false
        var wasCancelled = false
        
        val job = launch {
            try {
                delay(1000)
                wasCompleted = true
            } catch (e: CancellationException) {
                wasCancelled = true
                throw e
            }
        }
        
        advanceTimeBy(500)
        job.cancel()
        
        advanceUntilIdle()
        
        assertFalse(wasCompleted)
        assertTrue(wasCancelled)
        assertTrue(job.isCancelled)
    }
    
    @Test
    fun `test supervisor job isolation`() = runTest {
        val results = mutableListOf<String>()
        
        supervisorScope {
            launch {
                delay(100)
                results.add("Task 1 completed")
            }
            
            launch {
                delay(50)
                throw RuntimeException("Task 2 failed")
            }
            
            launch {
                delay(150)
                results.add("Task 3 completed")
            }
        }
        
        assertTrue(results.contains("Task 1 completed"))
        assertTrue(results.contains("Task 3 completed"))
    }
}

El builder runTest de la biblioteca de tests te permite controlar el tiempo virtual y garantizar una ejecución determinista de los tests.

Errores habituales

Olvidar gestionar las excepciones en las hijas supervisadas. Cuando usas SupervisorJob, las excepciones de las hijas no se propagan de forma automática. Tienes que gestionarlas de forma explícita o usar un CoroutineExceptionHandler.

No hacer join de los Jobs antes del cierre. Si cancelas un ámbito sin hacer join, el código de limpieza de los bloques finally podría no terminar:

// BAD: Cleanup might not finish
scope.cancel()

// GOOD: Wait for cleanup to complete
scope.coroutineContext[Job]?.cancelAndJoin()

Bloquear dentro de las corrutinas en lugar de suspender. Usar llamadas bloqueantes como Thread.sleep() en lugar de delay() impide que la cancelación funcione:

// BAD: Can't be cancelled
Thread.sleep(1000)

// GOOD: Cancellation works properly
delay(1000)

Crear corrutinas huérfanas. Lanzar corrutinas con GlobalScope se salta la concurrencia estructurada y puede llevar a fugas de recursos:

// BAD: No parent job, can't be cancelled with the component
GlobalScope.launch { ... }

// GOOD: Tied to component lifecycle
viewModelScope.launch { ... }

¡A programar!

David Cruz
davthecoder.com paglipat.com

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…