Skip to Content
Course content

234: Building a Simple Job Scheduler with Coroutines

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

I've always found that the best way to understand Coroutine orchestration is to try and build something that feels like it should be easy, only to realize that "easy" usually means "I forgot how to stop it." Today, let's build a simple Job Scheduler. Imagine we're building a telemetry system for a drone; we need to send battery levels every 2 seconds and GPS coordinates every 5 seconds.

The "Just Loop It" Approach

My first instinct is usually to just throw a while(true) loop inside a coroutine. It's the path of least resistance. Let's see what happens when I try to launch a couple of these sync tasks.

val scope = CoroutineScope(Dispatchers.Default)

fun startSync(taskName: String, intervalMs: Long) {
    scope.launch {
        while (true) {
            println("[$taskName] Syncing data at ${System.currentTimeMillis()}")
            delay(intervalMs)
        }
    }
}

// Triggering them
startSync("Battery", 2000)
startSync("GPS", 5000)

At first glance, this is great. It's non-blocking, it's lightweight, and the logs are filling up exactly as expected. But here's the problem: I have no way to stop the Battery sync without killing the entire scope. In a real app, if the user toggles "Battery Monitoring" off, this loop just keeps chewing through CPU and battery in the background. I've essentially created a leak.

Getting a Grip on the Job

To fix this, I need a handle. In Kotlin, launch returns a Job. If I keep a reference to that Job, I can call cancel() on it. I'll try modifying the function to return that Job so the caller can manage it.

fun startSync(taskName: String, intervalMs: Long): Job {
    return scope.launch {
        while (isActive) { // Using isActive instead of true is safer for cancellation
            println("[$taskName] Syncing data...")
            delay(intervalMs)
        }
    }
}

val batteryJob = startSync("Battery", 2000)
// Later...
batteryJob.cancel()

That's better. I used isActive in the loop—which is a property of the CoroutineScope—to ensure the loop terminates immediately when the job is cancelled. But wait, if I'm building a scheduler, I don't want the UI layer or the calling service to have to manually track five different Job variables. That's a recipe for a mess.

Centralizing the Schedule

I'm thinking we should wrap this in a class that manages a map of jobs. This way, I can say "Start the GPS task," and if the GPS task is already running, I can either ignore the request or restart it. Let's go with the "replace" logic—if you reschedule a task, the old one should die first.

class JobScheduler(private val scope: CoroutineScope) {
    private val activeJobs = mutableMapOf<String, Job>()

    fun schedule(id: String, intervalMs: Long, action: suspend () -> Unit) {
        // If there's already a job with this ID, kill it first
        activeJobs[id]?.cancel()

        activeJobs[id] = scope.launch {
            while (isActive) {
                action()
                delay(intervalMs)
            }
        }
    }

    fun cancelJob(id: String) {
        activeJobs[id]?.cancel()
        activeJobs.remove(id)
    }
}

Now this feels like a professional tool. I can pass in any suspend function as the action. I'm not tied to println anymore; I could be hitting a Retrofit API or writing to a Room database.

Dealing with the "Overlapping" Problem

There's one flaw here. What happens if the action() takes longer than the intervalMs? If the GPS sync takes 6 seconds but the interval is 5 seconds, the delay(intervalMs) only happens after the action finishes. This means my tasks won't "drift," but they also won't be strictly periodic.

If I wanted them to be strictly periodic regardless of how long the work takes, I'd have to launch the work in a separate child coroutine inside the loop. But honestly? In 90% of software engineering cases, the "sequential" approach I wrote above is actually what you want. It prevents the system from piling up a backlog of unfinished sync tasks if the network gets slow. I'll stick with the sequential version for now to keep the system stable.




📋 Practical Task

Build a Telemetry Heartbeat Manager

Create a class called HeartbeatManager that uses a CoroutineScope to manage multiple background heartbeats. Your manager must implement the following requirements:

  • A function startHeartbeat(deviceId: String, interval: Long) that starts a loop printing "Heartbeat from [deviceId]" to the console.
  • The manager must ensure that if startHeartbeat is called again with the same deviceId, the previous heartbeat for that device is cancelled before the new one starts.
  • A function stopHeartbeat(deviceId: String) that halts the loop for a specific device.
  • A function stopAll() that cancels every active heartbeat currently being managed.

Test your implementation by starting three different device heartbeats at different intervals, stopping one specifically, and then calling stopAll() to ensure no coroutines are left leaking.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.