Skip to Content
Course content

100: CoroutineContext and Job Hierarchy

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably already used launch and async to get things running in the background, but there is a subtle architectural shift that happens when you move from "making it work" to "making it production-ready." That shift is understanding the CoroutineContext and, more importantly, the Job hierarchy. If you treat coroutines as isolated threads, you're going to run into "ghost" processes—tasks that keep churning away in the background long after the user has closed the screen or the network request has timed out.

The "Fire and Forget" Fallacy

Early on, I see a lot of developers reach for GlobalScope.launch. It feels intuitive. You have a task—say, uploading a set of logs to a server—and you just want it to happen without blocking the UI. You think, "I'll just throw this into the GlobalScope and let it run."

// The naive way: No one is watching these jobs
fun uploadLogs(logs: List<Log>) {
    logs.forEach { log ->
        GlobalScope.launch(Dispatchers.IO) {
            api.send(log)
        }
    }
}

Here is the problem: GlobalScope creates top-level coroutines. They have no parent. Because they have no parent, they aren't bound to any lifecycle. If the user logs out or the service is destroyed, these uploads keep running. Worse, if one of those uploads crashes with an exception, you have no structured way to catch it or cancel the remaining siblings. You've essentially created a bunch of orphans that you can't track, manage, or kill. It's a memory leak waiting to happen.

Connecting the Dots with Job Hierarchy

In Kotlin, the CoroutineContext is more than just a way to set the Dispatcher. It's a map that contains a Job. When you launch a coroutine inside another coroutine's scope, the new coroutine inherits the context of its parent, but it creates its own Job that becomes a child of the parent's Job. This is the "Hierarchy" part of the equation.

If we refactor that log uploader to use a proper scope—or better yet, a coroutineScope builder—the behavior changes entirely. I prefer using coroutineScope when I need to group multiple concurrent operations that must all complete before the function returns.

// The professional way: Structured Concurrency
suspend fun uploadLogs(logs: List<Log>) = coroutineScope {
    logs.forEach { log ->
        launch(Dispatchers.IO) {
            api.send(log)
        }
    }
    // The function won't return until all launched children are done
}

By using coroutineScope, you've created a boundary. If the parent scope that called uploadLogs is cancelled, all the individual log uploads are cancelled instantly. There's no guesswork. The Job hierarchy ensures that cancellation propagates downwards. If the parent dies, the children die. It's a clean, predictable tree.

When One Failure Shouldn't Kill Everything

Now, there's a catch. By default, if one child in this hierarchy fails with an exception, it cancels its parent, which in turn cancels all other children. In a log uploader, if log #2 fails because of a malformed string, you probably don't want logs #3 through #100 to be aborted. This is where the standard Job hierarchy can feel too aggressive.

To fix this, we use a SupervisorJob. A supervisor changes the rule: failure of a child doesn't propagate upwards to the parent. I usually implement this by using supervisorScope. It gives you the same structured concurrency (waiting for children to finish) but prevents a single failing task from nuking the entire operation.

The trade-off is simple: use coroutineScope when the tasks are interdependent (if one fails, the whole operation is useless); use supervisorScope when the tasks are independent (if one fails, the others should keep going). Understanding this distinction is what separates a junior's "it works on my machine" code from a senior's resilient system.




📋 Practical Task

Refactoring the Leaky Image Processing Pipeline

You are reviewing a teammate's code for an image editor. They've implemented a processImage function that applies three different filters (Blur, Contrast, and Saturation) in parallel. However, they used GlobalScope, meaning if the user hits "Cancel" or leaves the screen, the CPU-intensive filtering keeps running in the background.

Your Task: Refactor the following code to use structured concurrency. Replace GlobalScope with a coroutineScope or supervisorScope. Ensure that if the main processImage call is cancelled, all three filter jobs are also cancelled immediately.

suspend fun processImage(image: Bitmap) {
    // This is the problematic implementation
    GlobalScope.launch(Dispatchers.Default) {
        applyBlur(image)
    }
    GlobalScope.launch(Dispatchers.Default) {
        applyContrast(image)
    }
    GlobalScope.launch(Dispatchers.Default) {
        applySaturation(image)
    }
    println("Filters started...") 
    // Problem: This prints immediately, and the function returns 
    // while the filters are still running in the background!
}

Requirements:

  • The function must not return until all three filters have completed.
  • The filters must run concurrently (not sequentially).
  • The implementation must be cancellable from the caller's side.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.