1. Overview

Scheduling repeating tasks is a common need in programming. We may have seen it in applications such as data updates, sensor monitoring, and sending notifications.

In this tutorial, we’ll discuss approaches that make it possible to perform tasks repeatedly and at specific intervals in Kotlin.

2. Using Timer.schedule()

Timer is a Java class in the java.util package that we can use to schedule tasks to be executed repeatedly or only once at a certain time:

val timer = Timer()

Each Timer object executes its tasks sequentially in a background thread. Tasks should complete quickly to avoid delaying subsequent ones. If a task takes too long, it can cause delays and a rapid succession of tasks once it finishes.

To create a schedule, we simply call the schedule() method:

timer.schedule(object : TimerTask() {
    override fun run() {
        println("Timer ticked!")
    }
}, 0, 1000)

Then, Kotlin simplifies it to kotlin.concurrent.Timer.kt:

timer.schedule(0L, 1000L) {
    println("Timer ticked!")
}

The task will continue to run until we stop it by calling cancel():

timer.cancel()

3. Using ScheduledExecutorService

ScheduledExecutorService is part of the java.util.concurrent API and allows us to schedule and execute tasks at certain times or repeatedly at certain intervals:

val scheduler = Executors.newScheduledThreadPool(1)

It uses an internal thread pool to execute scheduled tasks. This makes it more efficient than using Timer and TimerTask, which only use one thread.

Let’s see it in action:

scheduler.scheduleAtFixedRate({
    println("Complex task completed!")
}, 0, 1, TimeUnit.SECONDS)

This task will run until we call shutdown() to stop it:

scheduler.shutdown()

It’s a versatile replacement for Timer because it allows multiple service threads, accepts various time units, and doesn’t require subclassing TimerTask.

4. Using Coroutines

We can also use coroutines, which are designed to handle asynchronous processes smoothly.

Coroutines are also suitable for scheduling repeating tasks because they’re non-blocking, lightweight, and flexible, and they allow error handling without disrupting overall program execution.

4.1. Using repeat() and delay()

The repeat() function is Kotlin’s built-in extension function to repeat a specific block of code a specified number of times.

Meanwhile, delay() is a function in Kotlin Coroutines to delay the execution of a coroutine for a certain duration without blocking the thread.

Let’s see an example that uses these two functions to repeat a task:

var count = 0
repeat(10) {
    count++
    println("Timer ticked! $count")
    delay(1000.milliseconds)
}

assertEquals(10, count)

Here, we use repeat(10) to run the task 10 times. Each time the task is executed, we increment the count value and print a message.

Then, we use delay(1000.milliseconds) to delay execution for one second before executing the task again.

4.2. Using withTimeout()

withTimeout() is a function in Kotlin Coroutines that we use to set the maximum time limit for completing a code block.

If a timeout occurs, it stops execution of the coroutine code block and throws a TimeoutCancellationException. Therefore, we need to wrap it with a try-catch or use assertThrows in unit tests:

var count = 0
assertThrows<TimeoutCancellationException> {
    withTimeout(5000.milliseconds) {
        while (true) {
            count++
            println("Waiting for timeout")
            delay(1000.milliseconds)
        }
    }
}
assertEquals(5, count)

Inside the withTimeout(5000.milliseconds) block, we run a loop that increments the count value every second.

TimeoutCancellationException will always be thrown when the timeout is reached.

5. Using Coroutines Flow

Coroutines Flow — often called Kotlin Flow or simply Flow — is an additional library built on top of Coroutines to handle streaming data asynchronously.

A Flow is cold compared to a Channel, in that it holds no resources intrinsically and nothing executes until a collect() starts.

So, a Flow is suitable for handling the scheduling of repeatable tasks, especially in the context of applications that require operations that are repeated periodically.

Let’s create a simple scenario:

val flow = flow {
    while (true) {
        emit(Unit)
        delay(1000.milliseconds)
    }
}

Every time a data stream is executed, this function will send a counter value and then delay execution for one second before sending the next value.

This process will repeat itself endlessly and create a data flow that repeats itself at one-second intervals.

5.1. Using collect()

collect() is one of the operations in Flow used to consume the data stream that the Flow produces. It will execute the code blocks defined within it for each data element sent by the Flow.

Thus, collect allows us to perform actions or operations on each item received from the data stream:

var count = 0

flow.collect{
    count++
    println(count)
}

So, collect functions as a mechanism to collect or capture values received from the Flow and perform related operations on each value received.

The output of the code will be an infinite loop that prints Long values sequentially at one-second intervals:

1
2
3
...

5.2. Using take() and collect()

The take() function is an operation in Flow that’s used to retrieve the first number of elements from the data flow. This function is useful when we only want to retrieve a small portion of the data stream that the Flow generates — for example, to limit the number of elements processed or displayed.

Let’s call take() and see what effect it has:

flow.take(10).collect{
    count++
    println("Task executed $count")
}

take(10) takes the first 10 elements of the flow. We then use .collect() to collect the values from the Flow.

Whenever a value is collected, the code block within it is executed. In this case, every time a value is emitted, we increment count and print a message indicating that a task has been executed along with the current count.

6. Conclusion

In this tutorial, we’ve discussed approaches to scheduling repeating tasks.

Timer provides an easy approach and is suitable for light tasks. However, ScheduledExecutorService offers a more advanced approach and is suitable for cases where scheduled tasks require separate threads.

Additionally, Kotlin has coroutines capable of handling threads more efficiently than the usual thread-based approach. However, we need to be careful to avoid blocking when using them.

If we need continuous data processing, then Flow is better suited for the job, as it allows responsive and non-blocking task scheduling.

As usual, all the code examples can be found over on GitHub.

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments